AdMonitor DocumentationAdMonitor Documentation
Home
Lego AdMonitor Setup
Custom Events
Billboards
Integrations
Home
Lego AdMonitor Setup
Custom Events
Billboards
Integrations
  • Custom Events

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 campaign
  • ADM: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

Download .rbxm

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



Last Updated:
Contributors: siliconAamir