String filtering problem

Heya! I’ve run into an interesting problem with a small wrapper I wrote for Roblox’s Playfab API.

I use playfab to log errors that occur in my game. Both the error message and the stack trace are reported to the analytics. As part of this process, I filter certain pieces of information from the strings, including the player’s name. For example, if a stack trace has Players.Noble_Draconian.Modules.TableUtils, line 180, the username gets scrubbed, resulting in Players.[Player].Modules.TableUtils, line 180.

This works perfectly fine both in my test environments as well as the production environment. However, for some odd reason, strings that reference platyer’s avatar models under Workspace don’t get filtered sometimes. If a string has Foobar is not a valid member of Workspace.Noble_Draconian, it won’t get filtered, and the username shows up in the analytics. Other times, it will get filtered and end up as Foobar is not a valid member of Workspace.[Player], as seen below:

I’m at a loss as to how this happens - the string filtering is done serverside, using string.gsub. It works 100% of the time when I test locally, but it fails to filter randomly in production.
I was hoping an extra set of eyes might be able to spot a problem that I haven’t been able to.

Here’s the code, for reference:

Server

--[[
	Handles the various aspects of the game's analytics
--]]

local AnalyticsService = {Client = {}}
AnalyticsService.Client.Server = AnalyticsService

---------------------
-- Roblox Services --
---------------------
local RbxAnalyticsService = game:GetService("AnalyticsService")
local ScriptContextService = game:GetService("ScriptContext")
local ReplicatedStorage = game:GetService("ReplicatedStorage")
local Players = game:GetService("Players")
local RunService = game:GetService("RunService")

-------------
-- Defines --
-------------
local DEBUG_ENABLED = false
local GAME_VERSION = ReplicatedStorage._GameVersion.Value
local ServerErrorCache = {}
local PlayersErrorCache = {}

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- Helper functions
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
local function ScrubPlayerFromOutput(Player,OutputString)

	return string.gsub(OutputString,Player.Name,"[Player]")
end

local function ScrubBadCharsFromOutput(OutputString)
	OutputString = string.gsub(OutputString,"\r","")
	OutputString = string.gsub(OutputString,"\n","")
	OutputString = string.gsub(OutputString,",","")
	OutputString = string.gsub(OutputString,'"',"")

	return OutputString
end

local function CleanOutput(Player,OutputString)
	if Player ~= nil then
		OutputString = ScrubPlayerFromOutput(Player,OutputString)
	end
	OutputString = ScrubBadCharsFromOutput(OutputString)

	return OutputString
end

local function LogErrorToAnalytics(Player,ErrorMessage,ErrorStackTrace)
	if (game.GameId == 2799591695 or game.GameId == 2022463223) and not RunService:IsStudio() then
		RbxAnalyticsService:FireLogEvent(
			Player,
			Enum.AnalyticsLogLevel.Error,
			ErrorMessage,
			{
				errorCode = "ScriptContext.Error",
				stackTrace = ErrorStackTrace
			},
			{
				GameVersion = GAME_VERSION,
				GameId = game.GameId
			}
		)
	end

	if DEBUG_ENABLED then
		warn("Logged error!")
		warn("Message",ErrorMessage)
		warn("Stack trace",ErrorStackTrace)
	end
end

local function HandlePlayerAdded(Player)
	PlayersErrorCache[tostring(Player.UserId)] = {}
end

local function HandlePlayerLeaving(Player)
	PlayersErrorCache[tostring(Player.UserId)] = nil
end

local function GetPlayerErrorCache(Player)
	return PlayersErrorCache[tostring(Player.UserId)]
end

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- API Methods
----------------------------------------------------------------------------------------------------------------------------------------------------------------------

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- @Name : Client.RequestProcessError
-- @Description : Processes the specified error for the calling player
-- @Params : string "ErrorMessage" - The error message of the error that occured
--           string "StackTrace" - The stack trace of the error that occured
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
function AnalyticsService.Client:RequestProcessError(Player,ErrorMessage,StackTrace)
	local CleanedErrorMessage = CleanOutput(Player,ErrorMessage)
	local CleanedStackTrace = CleanOutput(Player,StackTrace)
	local FullErrorString = CleanedErrorMessage .. " | " .. CleanedStackTrace
	local PlayerErrorCache = GetPlayerErrorCache(Player)

	if table.find(PlayerErrorCache,FullErrorString) == nil then
		table.insert(PlayerErrorCache,FullErrorString)
		LogErrorToAnalytics(Player,CleanedErrorMessage,CleanedStackTrace)
	end
end

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- @Name : Init
-- @Description : Called when the service module is first loaded.
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
function AnalyticsService:Init()

	self:DebugLog("[Analytics Service] Initialized!")
end

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- @Name : Start
-- @Description : Called after all services are loaded.
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
function AnalyticsService:Start()
	self:DebugLog("[Analytics Service] Started!")

	------------------------------------------------------------------
	-- Creating/destroying player error caches on player join/leave --
	------------------------------------------------------------------
	for _,Player in pairs(Players:GetPlayers()) do
		coroutine.wrap(HandlePlayerAdded)(Player)
	end
	Players.PlayerAdded:connect(HandlePlayerAdded)

	Players.PlayerRemoving:connect(HandlePlayerLeaving)

	---------------------------------------------------
	-- Reporting errors to analytics when they occur --
	---------------------------------------------------
	ScriptContextService.Error:connect(function(ErrorMessage,ErrorStackTrace)
		local FullErrorString = ErrorMessage .. " | " .. ErrorStackTrace

		if table.find(ServerErrorCache,FullErrorString) == nil then
			table.insert(ServerErrorCache,FullErrorString)
			LogErrorToAnalytics(nil,CleanOutput(nil,ErrorMessage),CleanOutput(nil,ErrorStackTrace))
		end
	end)

	----------------------------------------
	-- Flushing error caches every minute --
	----------------------------------------
	while true do
		task.wait(60)
		ServerErrorCache = {}
		for Key,_ in pairs(PlayersErrorCache) do
			PlayersErrorCache[Key] = {}
		end
	end
end

return AnalyticsService

Client

--[[
	Handles the client-sided aspects of analytics logging
--]]

local AnalyticsController = {}

---------------------
-- Roblox Services --
---------------------
local ScriptContextService = game:GetService("ScriptContext")

------------------
-- Dependencies --
------------------
local AnalyticsService;

-------------
-- Defines --
-------------
local ErrorCache = {}

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- @Name : Init
-- @Description : Called when the Controller module is first loaded.
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
function AnalyticsController:Init()
	AnalyticsService = self:GetService("AnalyticsService")

	self:DebugLog("[Analytics Controller] Initialized!")
end

----------------------------------------------------------------------------------------------------------------------------------------------------------------------
-- @Name : Start
-- @Description : Called after all Controllers are loaded.
----------------------------------------------------------------------------------------------------------------------------------------------------------------------
function AnalyticsController:Start()
	self:DebugLog("[Analytics Controller] Started!")

	------------------------------------------------------------
	-- Reporting client-side errors to server when they occur --
	------------------------------------------------------------
	ScriptContextService.Error:connect(function(ErrorMessage,ErrorStackTrace)
		local FullErrorString = ErrorMessage .. " | " .. ErrorStackTrace

		if table.find(ErrorCache,FullErrorString) == nil then
			table.insert(ErrorCache,FullErrorString)
			AnalyticsService:RequestProcessError(ErrorMessage,ErrorStackTrace)
		end
	end)

	---------------------------------------
	-- Clearing error cache every minute --
	---------------------------------------
	while true do
		task.wait(60)
		ErrorCache = {}
	end
end

return AnalyticsController

Thank you in advance! :slight_smile:

1 Like

It’s worth noting that this issue occurs for client scripts as well, so the Player object is definitely being passed to the string cleaning functions:

Is there a chance the errors fire cause of the player leaving the game? If yes the error will probably occur after the player has left causing the if Player ~= nil then statement to return false.

That’s not really possible though. The API that handles the processing of client-sided errors accepts the Player parameter as its first argument, which is provided by default by Roblox when a remote function is invoked. It’s just an abstraction for remote events & functions.

function AnalyticsService.Client:RequestProcessError(Player,ErrorMessage,StackTrace)

Regardless of if the player leaves while the server is processing this, it still has a reference to their Player object, and as such, it will not be garbage collected.

Is there any chance LogErrorToAnalytics(nil,CleanOutput(nil,ErrorMessage),CleanOutput(nil,ErrorStackTrace)) has to do with it? If Player isn’t a nil object it’s the only function using nil. Have you tried logging errors in a way that shows if it was from ScriptContextService or no?

Server-sided errors don’t scrub the player’s name, since the Player object isn’t passed when it’s a server error. That cause I’ve identified.

My main confusion lies with the client errors not having playernames scrubbed, because the Player object is passed. Client errors are handled on line 97 of the server module.

Have you tried storing Player.Name in a variable when you have access to it? It may show if the issue is related to the Player object falsely getting garbage collected(somehow) or the string itself.

Neither of those are really possible though, if there is a reference to an object it cannot be garbage collected. If there was bugs with garbage collection of that magnitude it would’ve broken a lot of games by now.

string.gsub can be incredibly sensitive especially when certain characters are included within it’s input. You can find these “special characters” here

These characters alter the functionality of gsub and the way that it interprets the string information. Therefore performing some filtration prior to substituting is always something to be mindful of.

Hopefully that helps!

1 Like

Ooo, this might be it! Will investigate.

Yep, this was the cause! Thank you! :slight_smile:

1 Like