Custom Events
Requirements
The following requirements ensure that we can programmatically save and report data from Roblox's custom events dashboard, removing the need to manually export each chart with each aggregation.
Warning
Missing any of the requirements will result in data loss. Please use the 'Plug-And-play Module' to ensure the events are sent up with the correct syntax
- "ADM" prefix This enables us to filter out integration events from regular Live Ops events. In the
Plug-And-Play Module
the prefix gets added automatically. - Integration prefix This lets us filter between different integrations. In the
Plug-And-Play Module
This is defined on line 4. Once set to the correct string, this will automatically be added to events. - Country code in CustomField01 Roblox doesn't let us breakdown custom event data by country, which is crucial for reporting to brands.
In the end, events will look like this:
ADM:Kidzbop:NoteCollected
- Example of a music note being collected for a Kidzbop campaignADM:MonsterJam:TycoonPurchase
- Example of a purchase inside a MonsterJam themed tycoon
With the player's country code (eg. US
, GB
) in CustomField01.
Plug-And-Play Module
To ease the process of logging these events, you can use the following Plug-And-Play Module
to track integration events, removing all the keywords and country code handling from your scripts and allowing your code to look like:
AnalyticsModule.LogCustomEvent(Player, "ShopInteraction")
To use the Plug-And-Play module download and drop the .rbxm below the following code snippet, or copy and paste the Module Code. Once it's in studio, change INTEGRATION_KEYWORD
on line 4 to the integration keyword provided to you from Gamefam, this is crucial to ensuring the data can be captured.
Once the INTEGRATION_KEYWORD has been set, you can call the .LogCustomEvent function wherever an engagement should be tracked, passing through the player and the engagement name. For example if you had to track an event whenever a player interacts with a proximity prompt:
-- EXAMPLE CODE
local AnalyticsModule = require(game.ServerScriptService.Analytics) -- Require the 'Plug-And-Play' module
local proximityPrompt: ProximityPrompt = game.Workspace.NpcInteract.ProximityPrompt -- Example proximity prompt
proximityPrompt.Triggered:Connect(function(Player)
AnalyticsModule.LogCustomEvent(Player, "NpcInteraction") -- Track an engagement called NpcInteraction
end)
Custom Field 2
Roblox has a strict limit on 100 unique event names, to prevent going over this limit we can put the description of the event in Custom Field 2. This would be appropriate if we had an 'Unlock' event to track, instead of having "Unlock_Sword1", "UnlockSword2" etc, we can have an event called "Unlock" and use Custom Field 2 to track what's being unlocked:
-- Track 3 different events, under 1 unique event name
AnalyticsModule.LogCustomEvent(Player, "Unlock", nil, "Sword1")
AnalyticsModule.LogCustomEvent(Player, "Unlock", nil, "Sword2")
AnalyticsModule.LogCustomEvent(Player, "Unlock", nil, "Sword3")
Batched Custom Events
Some events will get triggered many times a minute, this will result in hitting the Global Rate Limit for the AnalyticsService (120 + (20 * CCU)). We can avoid reaching this limit by tracking how many times each event has happened, and sending the total as the custom event's value instead of sending an event for each occurrence. We have a built-in method called LogBatchedCustomEvent
that will allow you to use this logic without building any of the overhead yourself, you can trigger this function as many times as you want per batched event.
local AnalyticsModule = require(game.ServerScriptService.Analytics) -- Require the 'Plug-And-Play' module
AnalyticsModule.LogBatchedCustomEvent(Player, "PickUpCoin") -- Track one coin picked up
AnalyticsModule.LogBatchedCustomEvent(Player, "PickUpCoin", 10) -- Track 10 coins picked up
AnalyticsModule.LogBatchedCustomEvent(Player, "PickUpCoin", 100) -- Track 100 coins picked up
This works by adding the event's name (PickUpCoin
) to a queue for each player, which keeps track of how many times it's been triggered and it sends off the total occurrences for each event in intervals (every 30 seconds) and the total occurrences for all the events when the player leaves. It's crucial that there aren't too many uniquely named batched events as any outstanding events will be logged whenever a player leaves.
The time between batched events being logged can be adjusted by changing the BATCHED_EVENT_FREQUENCY
value on line 6 of the 'Plug-And-Play' module. If a game only has 1 uniquely named batched custom event, this could be changed to 1
, whereas if a game has 5+, this could be changed to 5
-10
Manually Tracking Batched Events
If your use-case doesn't make sense to be put on an interval basis, you can manually track batched events by using the original LogCustomEvent
function, sending the total occurrences as the value and adding "BH" to the end of the event name you've been given (So our reporting system knows to look at the value) For example:
local AnalyticsModule = require(game.ServerScriptService.Analytics) -- Require the 'Plug-And-Play' module
AnalyticsModule.LogCustomEvent(Player, "PickUpCoinBH", 400) -- Track 400 coins picked up
Tracking Time
Tracking time can be incredibly valuable to brands looking to understand the player's engagement with the integration, time can be tracked using the 'Plug-And-Play' module by starting and stopping a timer that exists in the module. This makes use of two functions called StartTrackingTime
and StopTrackingTime
that requires a Player
object and the event name as arguments. Some things to note about the functions:
- Change the LOG_EVENTS variable to true at the top of the module to know when time has been successfully tracked for an event.
- If a player leaves before a StopTrackingTime function is called for any events that have started to get tracked, the module will automatically stop the event itself.
For example, if you had two bindable events that got triggered when a player starts/stops driving a branded car, here's how you could track the time spent in that car
local analyticsModule = require(game.ServerScriptService.Analytics) -- Require the 'Plug-And-Play' module
-- Example proximity prompts
local startDrivingBrandedCar: BindableEvent
local stopDrivingBrandedCar: BindableEvent
startDrivingBrandedCar.Event:Connect(function(player: Player)
analyticsModule.StartTrackingTime(player, "DriveCar")
end)
stopDrivingBrandedCar.Event:Connect(function(player: Player)
analyticsModule.StopTrackingTime(player, "DriveCar")
end)
Previously, we tracked it by passing a integer value through the normal LogCustomEvent function, this limits us on being able to track a 'global' time value behind the scenes. Incase we need this logic, click here to see an example using the OLD method of how to track time in an obby
--
-- OLD EXAMPLE, ONLY USE THIS IF SPECIFICALLY ASKED TO.
--
local AnalyticsModule = require(game.ServerScriptService.Analytics) -- Require the 'Plug-And-Play' module
local playersStartTimestamps = {}
-- In this example we will use two 'pre-made' bindable events that get triggered when a player starts/finishes a branded obby
-- This 'pre-made' event passes through the player
local obbyStartEvent: BindableEvent = game.ServerStorage.StartBrandedObby
local obbyFinishEvent: BindableEvent = game.ServerStorage.FinishedBrandedObby
-- Save the timestamp of when the player enters the obby
local function onObbyStarted(player: Player)
playersStartTimestamps[player.UserId] = os.time()
end
-- Subtract the players start time from the current time to calculate their time in the obby
-- And track that event using the 'Plug-And-Play' module
local function onObbyFinished(player: Player)
if playersStartTimestamps[player.UserId] == nil then
return
end
-- Calculate how many seconds the player spent in the obby
local timeSpentInSeconds = os.time()-playersStartTimestamps[player.UserId]
-- Track the event
AnalyticsModule.LogCustomEvent(player, "TimeSpentInObby", timeSpentInSeconds)
playersStartTimestamps[player.UserId] = nil
end
obbyStartEvent.Event:Connect(onObbyStarted)
obbyFinishEvent.Event:Connect(onObbyFinished)
-- Trigger the onObbyFinished function for when a player leaves, incase they leave during the obby
game.Players.PlayerRemoving:Connect(onObbyFinished)
Tracking Unrelated Events
There may be some gameplay elements we need to pull for reference data but don't want to necessarily contribute to the 'LegoInteraction' count. These can be tracked using the following replacement functions.
LogCustomEvent
->LogUnrelatedCustomEvent
LogBatchedCustomEvent
->LogUnrelatedBatchedCustomEvent
An example of using one of these in a game can be when a player starts a match, if we wanted to track how many matches were played in total, and how many branded matches were played the following code would handle that.
Click to see a full example of how to use LogUnrelatedEvent()
local analyticsModule = require(game.ServerStorage.AnalyticsModule) -- 'Plug and Play' Module
local matchStarted: BindableEvent = game.ReplicatedStorage.MatchStarted -- Example bindable event that gets triggered when a player starts a match
matchStarted.Event:Connect(function(player: Player, mapName: string)
analyticsModule.LogUnrelatedCustomEvent(player, "MatchStart") -- Log an unrelated event for analytics purposes
if mapName == "BrandedMap" then
analyticsModule.LogCustomEvent(player, "BrandedMatchStart") -- If it's a branded map, track it
end
end)
Module .rbxm Download
Module Code
Click to show code
-- Version 1.0.8 25/07/25
----------
---------- DOWNLOAD THE .RBXM FOR SCRIPTS PARENTED TO THE AnalyticsModule
----------
local Analytics = {}
-- Change this to the integration keyword provided to you from Gamefam. This is used to filter events from different integrations
local INTEGRATION_KEYWORD = "Sonic"
local BATCHED_EVENT_FREQUENCY = 30 -- How frequent in seconds batched events should be sent (Keep in mind there's a global limit of 120+(CCU*20) per minute)
local LOG_EVENTS = false
local SEND_TIME_EVENTS_INSTANTLY = true -- Whether time events get sent at the same time they stop (This is good to get the average per interaction, usually how it should be)
local AnalyticsService = game:GetService("AnalyticsService")
local httpService = game:GetService("HttpService")
local LocalizationService = game:GetService("LocalizationService")
local Players = game:GetService("Players")
-- Please download the .rbxm to get the LegoInteractionPlayerTracker script!
-- https://docs.ads.gamefam.com/files/AnalyticsModule.rbxm
local legoInteractionPlayerTracker = require(script.LegoInteractionPlayerTracker)
local tempPlayerData = {}
local ingestionPrefix = "ADM"-- This is used to filter integration custom events from regular Live Ops custom events
local legoInteractionOnJoin = if game.PlaceId == 121844478504211 then true else false -- If the place is solely for lego
-- Logs a Roblox custom event for a Gamefam integration
-- It prefixes the whitelisted keywords and puts the country code in CustomField01 automatically
-- Prefixed whitelisted keywords and the country in CustomField01 is crucial for tracking events and reporting the data to brands
function Analytics.LogCustomEvent(player: Player, eventName: string, value: number | nil, customField2: string | nil, customField3: string | nil, unrelatedToIntegration: boolean | nil)
-- Check if the player object has been passed through
if not player or typeof(player) ~= "Instance" or player.ClassName ~= "Player" then
warn("Missing the first parameter (Player) when logging an integration custom event.")
return
end
-- Check if an event name has been passed through
if not eventName or typeof(eventName) ~= "string" then
warn("Missing the second parameter (eventName: string) when logging an integration custom event.")
return
end
-- If it triggers before we assign a table to the player on .Added
if not tempPlayerData[player.UserId] then
warn("The player hasn't loaded yet!", player.UserId)
return
end
-- Log the event with the whitelisted prefixes and the country code in CustomField01
AnalyticsService:LogCustomEvent(player, ingestionPrefix..":"..INTEGRATION_KEYWORD..":"..eventName, value or 1, {
[Enum.AnalyticsCustomFieldKeys.CustomField01.Name] = `Country - {tempPlayerData[player.UserId].country}`,
[Enum.AnalyticsCustomFieldKeys.CustomField02.Name] = customField2,
[Enum.AnalyticsCustomFieldKeys.CustomField03.Name] = customField3,
})
if LOG_EVENTS then
print(`[ADM Analytics]: Logged {ingestionPrefix..":"..INTEGRATION_KEYWORD..":"..eventName} with a value of {value or 1} for {player}, with the custom fields: 01: Country - {tempPlayerData[player.UserId].country}\n02: {customField2}, \n03: {customField3}`)
end
-- Track that they've interacted with the integration
if not tempPlayerData[player.UserId].interactedWithIntegration and not tempPlayerData[player.UserId].leaving and not unrelatedToIntegration then
tempPlayerData[player.UserId].interactedWithIntegration = true
legoInteractionPlayerTracker.TrackPlayer(player)
if eventName ~= "LegoInteraction" then
Analytics.LogCustomEvent(player, "LegoInteraction")
end
end
end
function Analytics.LogUnrelatedCustomEvent(player: Player, eventName: string, value: number | nil, customField2: string | nil, customField3: string | nil)
Analytics.LogCustomEvent(player, eventName, value, customField2, customField3, true)
end
function Analytics.StartTrackingTime(player: Player, eventName: string)
-- Check if the player object has been passed through
if not player or typeof(player) ~= "Instance" or player.ClassName ~= "Player" then
warn("Missing the first parameter (Player) when logging an integration custom event.")
return
end
-- Check if an event name has been passed through
if not eventName or typeof(eventName) ~= "string" then
warn("Missing the second parameter (eventName: string) when logging an integration custom event.")
return
end
local tempData = tempPlayerData[player.UserId]
-- If the player has no data
if not tempData then
return
end
-- If the player hasn't got any data yet
if not tempData.timeData[eventName] then
tempData.timeData[eventName] = {total = 0}
elseif tempData.timeData[eventName].startTime then
-- Already started a timer for this event
return
end
-- Set the start time
tempData.timeData[eventName].startTime = os.time()
-- Check if the global time timer has started, if not, then start it
if not tempData.timeData.global.startTime then
tempData.timeData.global.startTime = os.time()
end
end
function Analytics.StopTrackingTime(player: Player, eventName: string)
-- Check if the player object has been passed through
if not player or typeof(player) ~= "Instance" or player.ClassName ~= "Player" then
warn("Missing the first parameter (Player) when logging an integration custom event.")
return
end
-- Check if an event name has been passed through
if not eventName or typeof(eventName) ~= "string" then
warn("Missing the second parameter (eventName: string) when logging an integration custom event.")
return
end
local tempData = tempPlayerData[player.UserId]
-- If the player has no data
if not tempData then
return
end
local eventData = tempData.timeData[eventName]
if not eventData or not eventData.startTime then
return
end
-- Calculate the total time and set the start of the timer to nil
if LOG_EVENTS then
print(`[ADM Analytics]: Added {os.time()-eventData.startTime}s onto the total time for the event: {eventName}`)
end
eventData.total += os.time()-eventData.startTime
eventData.startTime = nil
if SEND_TIME_EVENTS_INSTANTLY then
Analytics.LogCustomEvent(player, eventName, eventData.total)
eventData.total = 0
end
-- Check if global time is being tracked
if not tempData.timeData.global.startTime then
return
end
-- Check if global time timer should be stopped
local stopTimer = true
for thisName, timeInfo in tempData.timeData do
if thisName ~= "global" and timeInfo.startTime ~= nil then
stopTimer = false
break
end
end
if stopTimer then
tempData.timeData.global.total += os.time()-tempData.timeData.global.startTime
tempData.timeData.global.startTime = nil
end
end
-- Logging a custom event as 'batched' means that
-- as the developer you can spam this event as much as you like
-- and it won't affect the rate limits because we keep count of
-- how many events got triggered and send the count as the value in the custom event
-- This is especially useful for things that happen many times per minute
-- For more information check the 'Batched Custom Events' section on the docs site
function Analytics.LogBatchedCustomEvent(player: Player, eventName: string, value: number | nil, unrelatedToIntegration: boolean | nil)
-- Check if the player object has been passed through
if not player or typeof(player) ~= "Instance" or player.ClassName ~= "Player" then
warn("Missing the first parameter (Player) when logging an integration custom event.")
return
end
-- Check if an event name has been passed through
if not eventName or typeof(eventName) ~= "string" then
warn("Missing the second parameter (eventName: string) when logging an integration custom event.")
return
end
if not tempPlayerData[player.UserId] then
return
end
-- Add the batched event's identifier, making sure the developer hasn't done so already
if string.sub(eventName, -2) ~= "BH" then
eventName ..= "BH"
end
if not tempPlayerData[player.UserId].batch[eventName] then
tempPlayerData[player.UserId].batch[eventName] = {
count = 0,
added = os.time(),
unrelatedToIntegration = unrelatedToIntegration
}
end
tempPlayerData[player.UserId].batch[eventName].count += (value or 1)
end
function Analytics.LogUnrelatedBatchedCustomEvent(player: Player, eventName: string, value: number | nil)
Analytics.LogBatchedCustomEvent(player, eventName, value, true)
end
local function startBatchedEventLoop(player: Player, loopUuid: string)
local userId = player.UserId
task.spawn(function()
-- Start looping to send off batched events if they exist
while tempPlayerData[userId] and tempPlayerData[userId].uuid == loopUuid do
local batch = tempPlayerData[userId].batch
local oldestName, oldestTime
-- Find the event with the earliest .added timestamp
for name,info in batch do
if info.count > 0 then
if not oldestTime or info.added < oldestTime then
oldestTime = info.added
oldestName = name
end
end
end
-- Send the oldest if it exists
if oldestName then
local info = batch[oldestName]
Analytics.LogCustomEvent(player, oldestName, info.count, nil, nil, info.unrelatedToIntegration)
batch[oldestName] = nil
end
task.wait(BATCHED_EVENT_FREQUENCY)
end
end)
end
--INIT start
-- Get the country code for every player
local WHITELISTED_COUNTRIES = {"US", "AU", "BR", "CA", "DE", "ES", "FR", "GB", "IT", "MX"}
local function onPlayerAdded(player)
local thisUuid = httpService:GenerateGUID(false)
tempPlayerData[player.UserId] = {
uuid = thisUuid,
country = nil,
batch = {},
timeData = {
global = {
total = 0
}
},
}
local result, code = pcall(function()
return LocalizationService:GetCountryRegionForPlayerAsync(player)
end)
if not result then
warn("Failed to fetch country region for ",player)
code = "nil"
end
tempPlayerData[player.UserId].country = if table.find(WHITELISTED_COUNTRIES, code) then code else "ROW"
startBatchedEventLoop(player, thisUuid)
if legoInteractionOnJoin then
Analytics.LogCustomEvent(player, "LegoInteraction")
end
end
for _, player in Players:GetPlayers() do
onPlayerAdded(player)
end
Players.PlayerAdded:Connect(onPlayerAdded)
-- Clear the player's saved country code when they leave
Players.PlayerRemoving:Connect(function(player)
-- Clone the table so the original can get removed
local clonedTable = table.clone(tempPlayerData[player.UserId])
tempPlayerData[player.UserId].uuid = nil
tempPlayerData[player.UserId].batch = nil
tempPlayerData[player.UserId].leaving = true
-- Finish any time values and trigger the event
for eventName, timeInfo in clonedTable.timeData do
-- If the event started but never finished then calculate how long it's been
if timeInfo.startTime then
timeInfo.total += os.time()-timeInfo.startTime
timeInfo.startTime = nil
end
-- If they don't have any time towards this event then have it as 0
if timeInfo.total == 0 then
continue
end
if eventName == "global" then
eventName = "TimeSpent_Global"
end
-- Send off event
Analytics.LogCustomEvent(player, eventName, timeInfo.total)
end
-- Send off all the remaining batched events
for eventName, eventData in clonedTable.batch do
Analytics.LogCustomEvent(player, eventName, eventData.count)
end
-- Keep player data for 15s (so that any final events can be sent), if the player hasn't rejoined, remove their data
task.wait(15)
if tempPlayerData[player.UserId].uuid == nil then
tempPlayerData[player.UserId] = nil
end
end)
--INIT end
return Analytics