local events = { ProcessEventsInterval = 8, GameKey = "", SecretKey = "", Build = "", _availableResourceCurrencies = {}, _availableResourceItemTypes = {}, } local store = require(script.Parent.Store) local logger = require(script.Parent.Logger) local version = require(script.Parent.Version) local validation = require(script.Parent.Validation) local threading = require(script.Parent.Threading) local http_api = require(script.Parent.HttpApi) local utilities = require(script.Parent.Utilities) local GAResourceFlowType = require(script.Parent.GAResourceFlowType) local GAProgressionStatus = require(script.Parent.GAProgressionStatus) local GAErrorSeverity = require(script.Parent.GAErrorSeverity) local HTTP = game:GetService("HttpService") local CategorySessionStart = "user" local CategorySessionEnd = "session_end" local CategoryBusiness = "business" local CategoryResource = "resource" local CategoryProgression = "progression" local CategoryDesign = "design" local CategoryError = "error" local CategorySdkError = "sdk_error" local MAX_EVENTS_TO_SEND_IN_ONE_BATCH = 500 local MAX_AGGREGATED_EVENTS = 2000 local function addCustomFieldsToEvent(eventData, customFields) if not (eventData and customFields) then return end local fields = {} for key, value in pairs(customFields) do local v = tostring(value) if #v > 256 then logger:w("Custom field value is too long. Max length is 256 characters. Field: " .. key) v = string.sub(v, 1, 256) end fields[key] = v end if fields and next(fields) then eventData["custom_fields"] = fields end end local function addDimensionsToEvent(playerId, eventData) if not eventData or not playerId then return end local PlayerData = store:GetPlayerDataFromCache(playerId) -- add to dict (if not nil) if PlayerData and PlayerData.CurrentCustomDimension01 and #PlayerData.CurrentCustomDimension01 > 0 then eventData["custom_01"] = PlayerData.CurrentCustomDimension01 end if PlayerData and PlayerData.CurrentCustomDimension02 and #PlayerData.CurrentCustomDimension02 > 0 then eventData["custom_02"] = PlayerData.CurrentCustomDimension02 end if PlayerData and PlayerData.CurrentCustomDimension03 and #PlayerData.CurrentCustomDimension03 > 0 then eventData["custom_03"] = PlayerData.CurrentCustomDimension03 end end local function getClientTsAdjusted(playerId) if not playerId then return os.time() end local PlayerData = store:GetPlayerDataFromCache(playerId) local clientTs = os.time() local clientTsAdjustedInteger = clientTs + PlayerData.ClientServerTimeOffset if validation:validateClientTs(clientTsAdjustedInteger) then return clientTsAdjustedInteger else return clientTs end end local DUMMY_SESSION_ID = HTTP:GenerateGUID(false):lower() local function Length(Table) local counter = 0 for _, _ in pairs(Table) do counter += 1 end return counter end local function getEventAnnotations(playerId) local PlayerData local id if playerId then id = playerId PlayerData = store:GetPlayerDataFromCache(playerId) else id = "DummyId" PlayerData = { OS = "uwp_desktop 0.0.0", Platform = "uwp_desktop", SessionID = DUMMY_SESSION_ID, Sessions = 1, CustomUserId = "Server", } end local annotations = { -- ---- REQUIRED ---- -- collector event API version ["v"] = 2, -- User identifier ["user_id"] = tostring(id) .. PlayerData.CustomUserId, -- Client Timestamp (the adjusted timestamp) ["client_ts"] = getClientTsAdjusted(playerId), -- SDK version ["sdk_version"] = "roblox " .. version.SdkVersion, -- Operation system version ["os_version"] = PlayerData.OS, -- Device make (hardcoded to apple) ["manufacturer"] = "unknown", -- Device version ["device"] = "unknown", -- Platform (operating system) ["platform"] = PlayerData.Platform, -- Session identifier ["session_id"] = PlayerData.SessionID, -- Session number ["session_num"] = PlayerData.Sessions, } if not utilities:isStringNullOrEmpty(PlayerData.CountryCode) then annotations["country_code"] = PlayerData.CountryCode else annotations["country_code"] = "unknown" end if validation:validateBuild(events.Build) then annotations["build"] = events.Build end if PlayerData.Configurations and Length(PlayerData.Configurations) > 0 then annotations["configurations"] = PlayerData.Configurations end if not utilities:isStringNullOrEmpty(PlayerData.AbId) then annotations["ab_id"] = PlayerData.AbId end if not utilities:isStringNullOrEmpty(PlayerData.AbVariantId) then annotations["ab_variant_id"] = PlayerData.AbVariantId end return annotations end local function addEventToStore(playerId, eventData) -- Get default annotations local ev = getEventAnnotations(playerId) -- Merge with eventData for k in pairs(eventData) do ev[k] = eventData[k] end -- Create json string representation local json = HTTP:JSONEncode(ev) -- output if VERBOSE LOG enabled logger:ii("Event added to queue: " .. json) -- Add to store store.EventsQueue[#store.EventsQueue + 1] = ev end local function dequeueMaxEvents() if #store.EventsQueue <= MAX_EVENTS_TO_SEND_IN_ONE_BATCH then local eventsQueue = store.EventsQueue store.EventsQueue = {} return eventsQueue else logger:w( ("More than %d events queued! Sending %d."):format( MAX_EVENTS_TO_SEND_IN_ONE_BATCH, MAX_EVENTS_TO_SEND_IN_ONE_BATCH ) ) if #store.EventsQueue > MAX_AGGREGATED_EVENTS then logger:w(("DROPPING EVENTS: More than %d events queued!"):format(MAX_AGGREGATED_EVENTS)) end -- Expensive operation to get ordered events cleared out (O(n)) local eventsQueue = table.create(MAX_EVENTS_TO_SEND_IN_ONE_BATCH) for i = 1, MAX_EVENTS_TO_SEND_IN_ONE_BATCH do eventsQueue[i] = store.EventsQueue[i] end -- Shift everything down and overwrite old events local eventCount = #store.EventsQueue for i = 1, math.min(MAX_AGGREGATED_EVENTS, eventCount) do store.EventsQueue[i] = store.EventsQueue[i + MAX_EVENTS_TO_SEND_IN_ONE_BATCH] end -- Clear additional events for i = MAX_AGGREGATED_EVENTS + 1, eventCount do store.EventsQueue[i] = nil end return eventsQueue end end local function processEvents() local queue = dequeueMaxEvents() if #queue == 0 then logger:i("Event queue: No events to send") return end -- Log logger:i("Event queue: Sending " .. tostring(#queue) .. " events.") local eventsResult = http_api:sendEventsInArray(events.GameKey, events.SecretKey, queue) local statusCode = eventsResult.statusCode local responseBody = eventsResult.body if statusCode == http_api.EGAHTTPApiResponse.Ok and responseBody then logger:i("Event queue: " .. tostring(#queue) .. " events sent.") else if statusCode == http_api.EGAHTTPApiResponse.NoResponse then logger:w("Event queue: Failed to send events to collector - Retrying next time") for _, e in pairs(queue) do if #store.EventsQueue < MAX_AGGREGATED_EVENTS then store.EventsQueue[#store.EventsQueue + 1] = e else break end end else if statusCode == http_api.EGAHTTPApiResponse.BadRequest and responseBody then logger:w( "Event queue: " .. tostring(#queue) .. " events sent. " .. tostring(#responseBody) .. " events failed GA server validation." ) else logger:w("Event queue: Failed to send events.") end end end end function events:processEventQueue() processEvents() threading:scheduleTimer(events.ProcessEventsInterval, function() events:processEventQueue() end) end function events:setBuild(build) if not validation:validateBuild(build) then logger:w("Validation fail - configure build: Cannot be null, empty or above 32 length. String: " .. build) return end self.Build = build logger:i("Set build version: " .. build) end function events:setAvailableResourceCurrencies(availableResourceCurrencies) if not validation:validateResourceCurrencies(availableResourceCurrencies) then return end self._availableResourceCurrencies = availableResourceCurrencies logger:i("Set available resource currencies: (" .. table.concat(availableResourceCurrencies, ", ") .. ")") end function events:setAvailableResourceItemTypes(availableResourceItemTypes) if not validation:validateResourceCurrencies(availableResourceItemTypes) then return end self._availableResourceItemTypes = availableResourceItemTypes logger:i("Set available resource item types: (" .. table.concat(availableResourceItemTypes, ", ") .. ")") end function events:addSessionStartEvent(playerId, teleportData, customFields) local PlayerData = store:GetPlayerDataFromCache(playerId) if teleportData then PlayerData.Sessions = teleportData.Sessions else local eventDict = {} -- Event specific data eventDict["category"] = CategorySessionStart -- Increment session number and persist PlayerData.Sessions = PlayerData.Sessions + 1 -- Add custom dimensions addDimensionsToEvent(playerId, eventDict) -- Add to store addEventToStore(playerId, eventDict) addCustomFieldsToEvent(eventDict, customFields) logger:i("Add SESSION START event") processEvents() end end function events:addSessionEndEvent(playerId, customFields) local PlayerData = store:GetPlayerDataFromCache(playerId) local session_start_ts = PlayerData.SessionStart local client_ts_adjusted = getClientTsAdjusted(playerId) local sessionLength = 0 if client_ts_adjusted ~= nil and session_start_ts ~= nil then sessionLength = client_ts_adjusted - session_start_ts end if sessionLength < 0 then -- Should never happen. -- Could be because of edge cases regarding time altering on device. logger:w("Session length was calculated to be less then 0. Should not be possible. Resetting to 0.") sessionLength = 0 end -- Event specific data local eventDict = {} eventDict["category"] = CategorySessionEnd eventDict["length"] = sessionLength -- Add custom dimensions addDimensionsToEvent(playerId, eventDict) addCustomFieldsToEvent(eventDict, customFields) -- Add to store addEventToStore(playerId, eventDict) PlayerData.SessionStart = 0 logger:i("Add SESSION END event.") processEvents() end function events:addBusinessEvent(playerId, currency, amount, itemType, itemId, cartType, customFields) -- Validate event params if not validation:validateBusinessEvent(currency, amount, cartType, itemType, itemId) then -- TODO: add sdk error event return end -- Create empty eventData local eventDict = {} -- Increment transaction number and persist local PlayerData = store:GetPlayerDataFromCache(playerId) PlayerData.Transactions = PlayerData.Transactions + 1 -- Required eventDict["event_id"] = itemType .. ":" .. itemId eventDict["category"] = CategoryBusiness eventDict["currency"] = currency eventDict["amount"] = amount eventDict["transaction_num"] = PlayerData.Transactions -- Optional if not utilities:isStringNullOrEmpty(cartType) then eventDict["cart_type"] = cartType end -- Add custom dimensions addDimensionsToEvent(playerId, eventDict) addCustomFieldsToEvent(eventDict, customFields) logger:i( "Add BUSINESS event: {currency:" .. currency .. ", amount:" .. tostring(amount) .. ", itemType:" .. itemType .. ", itemId:" .. itemId .. ", cartType:" .. cartType .. "}" ) -- Send to store addEventToStore(playerId, eventDict) end function events:addResourceEvent(playerId, flowType, currency, amount, itemType, itemId, customFields) -- Validate event params if not validation:validateResourceEvent( GAResourceFlowType, flowType, currency, amount, itemType, itemId, self._availableResourceCurrencies, self._availableResourceItemTypes ) then -- TODO: add sdk error event return end -- If flow type is sink reverse amount if flowType == GAResourceFlowType.Sink then amount = (-1 * amount) end -- Create empty eventData local eventDict = {} -- insert event specific values local flowTypeString = GAResourceFlowType[flowType] eventDict["event_id"] = flowTypeString .. ":" .. currency .. ":" .. itemType .. ":" .. itemId eventDict["category"] = CategoryResource eventDict["amount"] = amount -- Add custom dimensions addDimensionsToEvent(playerId, eventDict) addCustomFieldsToEvent(eventDict, customFields) logger:i( "Add RESOURCE event: {currency:" .. currency .. ", amount:" .. tostring(amount) .. ", itemType:" .. itemType .. ", itemId:" .. itemId .. "}" ) -- Send to store addEventToStore(playerId, eventDict) end function events:addProgressionEvent( playerId, progressionStatus, progression01, progression02, progression03, score, customFields ) -- Validate event params if not validation:validateProgressionEvent( GAProgressionStatus, progressionStatus, progression01, progression02, progression03 ) then -- TODO: add sdk error event return end -- Create empty eventData local eventDict = {} -- Progression identifier local progressionIdentifier if utilities:isStringNullOrEmpty(progression02) then progressionIdentifier = progression01 elseif utilities:isStringNullOrEmpty(progression03) then progressionIdentifier = progression01 .. ":" .. progression02 else progressionIdentifier = progression01 .. ":" .. progression02 .. ":" .. progression03 end local statusString = GAProgressionStatus[progressionStatus] -- Append event specifics eventDict["category"] = CategoryProgression eventDict["event_id"] = statusString .. ":" .. progressionIdentifier -- Attempt local attempt_num = 0 -- Add score if specified and status is not start if score ~= nil and progressionStatus ~= GAProgressionStatus.Start then eventDict["score"] = score end local PlayerData = store:GetPlayerDataFromCache(playerId) -- Count attempts on each progression fail and persist if progressionStatus == GAProgressionStatus.Fail then -- Increment attempt number local progressionTries = PlayerData.ProgressionTries[progressionIdentifier] or 0 PlayerData.ProgressionTries[progressionIdentifier] = progressionTries + 1 end -- increment and add attempt_num on complete and delete persisted if progressionStatus == GAProgressionStatus.Complete then -- Increment attempt number local progressionTries = PlayerData.ProgressionTries[progressionIdentifier] or 0 PlayerData.ProgressionTries[progressionIdentifier] = progressionTries + 1 -- Add to event attempt_num = PlayerData.ProgressionTries[progressionIdentifier] eventDict["attempt_num"] = attempt_num -- Clear PlayerData.ProgressionTries[progressionIdentifier] = 0 end -- Add custom dimensions addDimensionsToEvent(playerId, eventDict) addCustomFieldsToEvent(eventDict, customFields) local progression02String = "" if not utilities:isStringNullOrEmpty(progression02) then progression02String = progression02 end local progression03String = "" if not utilities:isStringNullOrEmpty(progression03) then progression03String = progression03 end logger:i( "Add PROGRESSION event: {status:" .. statusString .. ", progression01:" .. progression01 .. ", progression02:" .. progression02String .. ", progression03:" .. progression03String .. ", score:" .. tostring(score) .. ", attempt:" .. tostring(attempt_num) .. "}" ) -- Send to store addEventToStore(playerId, eventDict) end function events:addDesignEvent(playerId, eventId, value, customFields) -- Validate if not validation:validateDesignEvent(eventId) then -- TODO: add sdk error event return end -- Create empty eventData local eventData = {} -- Append event specifics eventData["category"] = CategoryDesign eventData["event_id"] = eventId if value ~= nil then eventData["value"] = value end -- Add custom dimensions addDimensionsToEvent(playerId, eventData) addCustomFieldsToEvent(eventData, customFields) logger:i("Add DESIGN event: {eventId:" .. eventId .. ", value:" .. tostring(value) .. "}") -- Send to store addEventToStore(playerId, eventData) end function events:addErrorEvent(playerId, severity, message, customFields) -- Validate if not validation:validateErrorEvent(GAErrorSeverity, severity, message) then -- TODO: add sdk error event return end -- Create empty eventData local eventData = {} local severityString = GAErrorSeverity[severity] eventData["category"] = CategoryError eventData["severity"] = severityString eventData["message"] = message -- Add custom dimensions addDimensionsToEvent(playerId, eventData) addCustomFieldsToEvent(eventData, customFields) local messageString = "" if not utilities:isStringNullOrEmpty(message) then messageString = message end logger:i("Add ERROR event: {severity:" .. severityString .. ", message:" .. messageString .. "}") -- Send to store addEventToStore(playerId, eventData) end function events:addSdkErrorEvent(playerId, category, area, action, parameter, reason) -- Create empty eventData local eventData = {} eventData["category"] = CategorySdkError eventData["error_category"] = category eventData["error_area"] = area eventData["error_action"] = action if not utilities:isStringNullOrEmpty(parameter) then eventData["error_parameter"] = parameter end if not utilities:isStringNullOrEmpty(reason) then eventData["reason"] = reason end logger:i( "Add SDK ERROR event: {error_category:" .. category .. ", error_area:" .. area .. ", error_action:" .. action .. "}" ) -- Send to store addEventToStore(playerId, eventData) end return events