Skip to content

Feature: Custom Event #2339

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Aug 1, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -163,4 +163,23 @@ object JSONUtils {
`object`
}
}

/**
* Check if an object is JSON-serializable.
* Recursively check each item if object is a map or a list.
*/
fun isValidJsonObject(value: Any?): Boolean {
return when (value) {
null,
is Boolean,
is Number,
is String,
is JSONObject,
is JSONArray,
-> true
is Map<*, *> -> value.keys.all { it is String } && value.values.all { isValidJsonObject(it) }
is List<*> -> value.all { isValidJsonObject(it) }
else -> false
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,11 +14,13 @@ import com.onesignal.user.internal.operations.RefreshUserOperation
import com.onesignal.user.internal.operations.SetAliasOperation
import com.onesignal.user.internal.operations.SetPropertyOperation
import com.onesignal.user.internal.operations.SetTagOperation
import com.onesignal.user.internal.operations.TrackCustomEventOperation
import com.onesignal.user.internal.operations.TrackPurchaseOperation
import com.onesignal.user.internal.operations.TrackSessionEndOperation
import com.onesignal.user.internal.operations.TrackSessionStartOperation
import com.onesignal.user.internal.operations.TransferSubscriptionOperation
import com.onesignal.user.internal.operations.UpdateSubscriptionOperation
import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserFromSubscriptionOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor
Expand Down Expand Up @@ -60,6 +62,7 @@ internal class OperationModelStore(prefs: IPreferencesService) : ModelStore<Oper
UpdateUserOperationExecutor.TRACK_SESSION_START -> TrackSessionStartOperation()
UpdateUserOperationExecutor.TRACK_SESSION_END -> TrackSessionEndOperation()
UpdateUserOperationExecutor.TRACK_PURCHASE -> TrackPurchaseOperation()
CustomEventOperationExecutor.CUSTOM_EVENT -> TrackCustomEventOperation()
else -> throw Exception("Unrecognized operation: $operationName")
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,4 +166,15 @@ interface IUserManager {
* Remove an observer from the user state.
*/
fun removeObserver(observer: IUserStateObserver)

/**
* Tracks a custom event performed by the current user
*
* @param name for the custom event
* @param properties an optional property dictionary, must be serializable into a JSON Object
*/
fun trackEvent(
name: String,
properties: Map<String, Any>? = null,
)
}
Original file line number Diff line number Diff line change
Expand Up @@ -16,9 +16,14 @@ import com.onesignal.user.internal.backend.impl.SubscriptionBackendService
import com.onesignal.user.internal.backend.impl.UserBackendService
import com.onesignal.user.internal.builduser.IRebuildUserService
import com.onesignal.user.internal.builduser.impl.RebuildUserService
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
import com.onesignal.user.internal.customEvents.ICustomEventController
import com.onesignal.user.internal.customEvents.impl.CustomEventBackendService
import com.onesignal.user.internal.customEvents.impl.CustomEventController
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.migrations.RecoverConfigPushSubscription
import com.onesignal.user.internal.migrations.RecoverFromDroppedLoginBug
import com.onesignal.user.internal.operations.impl.executors.CustomEventOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.IdentityOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserFromSubscriptionOperationExecutor
import com.onesignal.user.internal.operations.impl.executors.LoginUserOperationExecutor
Expand Down Expand Up @@ -71,6 +76,9 @@ internal class UserModule : IModule {
builder.register<LoginUserFromSubscriptionOperationExecutor>().provides<IOperationExecutor>()
builder.register<RefreshUserOperationExecutor>().provides<IOperationExecutor>()
builder.register<UserManager>().provides<IUserManager>()
builder.register<CustomEventController>().provides<ICustomEventController>()
builder.register<CustomEventOperationExecutor>().provides<IOperationExecutor>()
builder.register<CustomEventBackendService>().provides<ICustomEventBackendService>()

builder.register<UserRefreshService>().provides<IStartableService>()

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package com.onesignal.user.internal

import com.onesignal.common.IDManager
import com.onesignal.common.JSONUtils
import com.onesignal.common.OneSignalUtils
import com.onesignal.common.events.EventProducer
import com.onesignal.common.modeling.ISingletonModelStoreChangeHandler
Expand All @@ -10,6 +11,7 @@ import com.onesignal.debug.LogLevel
import com.onesignal.debug.internal.logging.Logging
import com.onesignal.user.IUserManager
import com.onesignal.user.internal.backend.IdentityConstants
import com.onesignal.user.internal.customEvents.ICustomEventController
import com.onesignal.user.internal.identity.IdentityModel
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.properties.PropertiesModel
Expand All @@ -25,6 +27,7 @@ internal open class UserManager(
private val _subscriptionManager: ISubscriptionManager,
private val _identityModelStore: IdentityModelStore,
private val _propertiesModelStore: PropertiesModelStore,
private val _customEventController: ICustomEventController,
private val _languageContext: ILanguageContext,
) : IUserManager, ISingletonModelStoreChangeHandler<IdentityModel> {
override val onesignalId: String
Expand Down Expand Up @@ -244,6 +247,18 @@ internal open class UserManager(
changeHandlersNotifier.unsubscribe(observer)
}

override fun trackEvent(
name: String,
properties: Map<String, Any>?,
) {
if (!JSONUtils.isValidJsonObject(properties)) {
Logging.log(LogLevel.ERROR, "Custom event properties are not JSON-serializable")
return
}

_customEventController.sendCustomEvent(name, properties)
}

override fun onModelReplaced(
model: IdentityModel,
tag: String,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package com.onesignal.user.internal.customEvents

import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.user.internal.customEvents.impl.CustomEventMetadata

/**
* The backend service for custom events.
*/
interface ICustomEventBackendService {
/**
* Send an custom event to the backend and return the response.
*
* @param customEvent The custom event to send up.
*/
suspend fun sendCustomEvent(
appId: String,
onesignalId: String,
externalId: String?,
timestamp: Long,
eventName: String,
eventProperties: String?,
metadata: CustomEventMetadata,
): ExecutionResponse
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.onesignal.user.internal.customEvents

interface ICustomEventController {
fun sendCustomEvent(
name: String,
properties: Map<String, Any>?,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package com.onesignal.user.internal.customEvents.impl

import com.onesignal.common.DateUtils
import com.onesignal.common.exceptions.BackendException
import com.onesignal.core.internal.http.IHttpClient
import com.onesignal.core.internal.operations.ExecutionResponse
import com.onesignal.core.internal.operations.ExecutionResult
import com.onesignal.user.internal.customEvents.ICustomEventBackendService
import org.json.JSONArray
import org.json.JSONObject
import java.util.TimeZone

internal class CustomEventBackendService(
private val _httpClient: IHttpClient,
) : ICustomEventBackendService {
override suspend fun sendCustomEvent(
appId: String,
onesignalId: String,
externalId: String?,
timestamp: Long,
eventName: String,
eventProperties: String?,
metadata: CustomEventMetadata,
): ExecutionResponse {
val body = JSONObject()
body.put("name", eventName)
body.put("onesignal_id", onesignalId)
externalId?.let { body.put("external_id", it) }
body.put(
"timestamp",
DateUtils.iso8601Format().apply {
timeZone = TimeZone.getTimeZone("UTC")
}.format(
timestamp,
),
)

val payload = eventProperties?.let { JSONObject(it) } ?: JSONObject()

payload.put("os_sdk", metadata.toJSONObject())

body.put("payload", payload)
val jsonObject = JSONObject().put("events", JSONArray().put(body))

// TODO: include auth header when identity verification is on

val response = _httpClient.post("apps/$appId/custom_events", jsonObject)

if (!response.isSuccess) {
throw BackendException(response.statusCode, response.payload, response.retryAfterSeconds)
}

return ExecutionResponse(ExecutionResult.SUCCESS)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
package com.onesignal.user.internal.customEvents.impl

import com.onesignal.core.internal.config.ConfigModelStore
import com.onesignal.core.internal.operations.IOperationRepo
import com.onesignal.core.internal.time.ITime
import com.onesignal.user.internal.customEvents.ICustomEventController
import com.onesignal.user.internal.identity.IdentityModelStore
import com.onesignal.user.internal.operations.TrackCustomEventOperation
import org.json.JSONArray
import org.json.JSONObject

class CustomEventController(
private val _identityModelStore: IdentityModelStore,
private val _configModelStore: ConfigModelStore,
private val _time: ITime,
private val _opRepo: IOperationRepo,
) : ICustomEventController {
override fun sendCustomEvent(
name: String,
properties: Map<String, Any>?,
) {
val op =
TrackCustomEventOperation(
_configModelStore.model.appId,
_identityModelStore.model.onesignalId,
_identityModelStore.model.externalId,
_time.currentTimeMillis,
name,
properties?.let { mapToJson(it).toString() },
)
_opRepo.enqueue(op)
}

/**
* Recursively convert a JSON-serializable map into a JSON-compatible format, handling
* nested Maps and Lists appropriately.
*/
private fun mapToJson(map: Map<String, Any>): JSONObject {
val json = JSONObject()
for ((key, value) in map) {
json.put(key, convertToJson(value))
}
return json
}

private fun convertToJson(value: Any): Any {
return when (value) {
is Map<*, *> -> {
val subMap =
value.entries
.filter { it.key is String }
.associate {
it.key as String to convertToJson(it.value!!)
}
mapToJson(subMap)
}
is List<*> -> {
val array = JSONArray()
value.forEach { array.put(convertToJson(it!!)) }
array
}
else -> value
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package com.onesignal.user.internal.customEvents.impl

import com.onesignal.common.putSafe
import org.json.JSONException
import org.json.JSONObject

class CustomEventMetadata(
val deviceType: String?,
val sdk: String?,
val appVersion: String?,
val type: String?,
val deviceModel: String?,
val deviceOS: String?,
) {
@Throws(JSONException::class)
fun toJSONObject(): JSONObject {
val json = JSONObject()
json.putSafe(SDK, sdk)
json.putSafe(APP_VERSION, appVersion)
json.putSafe(TYPE, type)
json.putSafe(DEVICE_TYPE, deviceType)
json.putSafe(DEVICE_MODEL, deviceModel)
json.putSafe(DEVICE_OS, deviceOS)
return json
}

override fun toString(): String {
return toJSONObject().toString()
}

companion object {
private const val DEVICE_TYPE = "device_type"
private const val SDK = "sdk"
private const val APP_VERSION = "app_version"
private const val TYPE = "type"
private const val DEVICE_MODEL = "device_model"
private const val DEVICE_OS = "device_os"
}
}
Loading
Loading