Skip to content
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
2 changes: 2 additions & 0 deletions flextrack/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ android {
}

compileOptions {
isCoreLibraryDesugaringEnabled = true
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
}
Expand All @@ -47,6 +48,7 @@ android {
}

dependencies {
coreLibraryDesugaring(libs.desugar.jdk.libs)
testImplementation(libs.junit.jupiter)
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
package dev.taghizadeh.flextrack.event

import dev.taghizadeh.flextrack.routing.EventCategory
import dev.taghizadeh.flextrack.routing.TrackerGroup
import java.util.Collections

/** Adds properties without changing the identity or metadata of [original]. */
public class EnrichedEvent(
public val original: FlexEvent,
extraProperties: Map<String, Any?>,
) : FlexEvent(original.eventId, original.timestamp) {
public val extraProperties: Map<String, Any?> =
Collections.unmodifiableMap(LinkedHashMap(extraProperties))

override val name: String get() = original.name
override val properties: Map<String, Any?> =
Collections.unmodifiableMap(
LinkedHashMap<String, Any?>().apply {
original.properties?.let(::putAll)
putAll(extraProperties)
},
)
override val category: EventCategory? get() = original.category
override val preferredGroup: TrackerGroup? get() = original.preferredGroup
override val containsPII: Boolean get() = original.containsPII
override val requiresConsent: Boolean get() = original.requiresConsent
override val isHighVolume: Boolean get() = original.isHighVolume
override val isEssential: Boolean get() = original.isEssential
override val userId: String? get() = original.userId
override val sessionId: String? get() = original.sessionId
}

public fun interface EventTransformer {
public fun transform(event: FlexEvent): FlexEvent
}

internal tailrec fun FlexEvent.originalEvent(): FlexEvent =
if (this is EnrichedEvent) original.originalEvent() else this
Original file line number Diff line number Diff line change
@@ -0,0 +1,43 @@
package dev.taghizadeh.flextrack.event

import dev.taghizadeh.flextrack.routing.EventCategory
import dev.taghizadeh.flextrack.routing.TrackerGroup
import java.time.Instant
import java.util.UUID

/** One immutable analytics event occurrence. */
public abstract class FlexEvent(
public val eventId: String = UUID.randomUUID().toString(),
public val timestamp: Instant = Instant.now(),
) {
init {
require(eventId.isNotEmpty()) { "eventId cannot be empty" }
}

public abstract val name: String
public abstract val properties: Map<String, Any?>?

public open val category: EventCategory? = null
public open val preferredGroup: TrackerGroup? = null
public open val containsPII: Boolean = false
public open val requiresConsent: Boolean = true
public open val isHighVolume: Boolean = false
public open val isEssential: Boolean = false
public open val userId: String? = null
public open val sessionId: String? = null

public fun toMap(): Map<String, Any?> = linkedMapOf(
"eventId" to eventId,
"name" to name,
"properties" to properties,
"category" to category?.name,
"preferredGroup" to preferredGroup?.name,
"containsPII" to containsPII,
"requiresConsent" to requiresConsent,
"isHighVolume" to isHighVolume,
"isEssential" to isEssential,
"timestamp" to timestamp.toString(),
"userId" to userId,
"sessionId" to sessionId,
)
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package dev.taghizadeh.flextrack.event

/** Ordered, failure-isolated event transformation. */
public class TransformerPipeline {
private val transformers: MutableList<EventTransformer> = mutableListOf()

public fun add(transformer: EventTransformer) {
transformers += transformer
}

public fun remove(transformer: EventTransformer): Boolean =
transformers.remove(transformer)

public fun clear() {
transformers.clear()
}

public fun transform(event: FlexEvent): FlexEvent {
var current = event
transformers.toList().forEach { transformer ->
current = runCatching { transformer.transform(current) }
.getOrDefault(current)
}
return current
}

public val size: Int get() = transformers.size
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package dev.taghizadeh.flextrack.routing

@JvmInline
public value class EventCategory(public val name: String) {
init {
require(name.isNotEmpty()) { "category name cannot be empty" }
}

public companion object {
public val Business: EventCategory = EventCategory("business")
public val User: EventCategory = EventCategory("user")
public val Technical: EventCategory = EventCategory("technical")
public val Sensitive: EventCategory = EventCategory("sensitive")
public val Marketing: EventCategory = EventCategory("marketing")
public val System: EventCategory = EventCategory("system")
public val Security: EventCategory = EventCategory("security")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package dev.taghizadeh.flextrack.routing

import dev.taghizadeh.flextrack.event.FlexEvent
import dev.taghizadeh.flextrack.sampling.DeterministicSampler
import dev.taghizadeh.flextrack.sampling.EventSampler

public data class RoutingConfiguration(
val rules: List<RoutingRule>,
val customGroups: Map<String, TrackerGroup> = emptyMap(),
val defaultGroup: TrackerGroup? = null,
val enableSampling: Boolean = true,
val enableConsentChecking: Boolean = true,
val isDebugMode: Boolean = false,
val sampler: EventSampler = DeterministicSampler,
) {
public fun matchingRules(event: FlexEvent): List<RoutingRule> {
val matches = rules
.filter { it.matches(event, isDebugMode) }
.sortedByDescending(RoutingRule::priority)
if (matches.isNotEmpty()) return matches

rules.firstOrNull(RoutingRule::isDefault)?.let { return listOf(it) }
return defaultGroup?.let {
listOf(
RoutingRule(
isDefault = true,
targetGroup = it,
description = "Fallback default rule",
),
)
}.orEmpty()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package dev.taghizadeh.flextrack.routing

import dev.taghizadeh.flextrack.event.FlexEvent

public class RoutingEngine(public val configuration: RoutingConfiguration) {
public fun route(
event: FlexEvent,
consent: ConsentState = ConsentState(),
availableTrackers: Set<String> = emptySet(),
): RoutingResult {
val matchingRules = configuration.matchingRules(event)
if (matchingRules.isEmpty()) {
return RoutingResult(
event = event,
targetTrackers = emptyList(),
appliedRules = emptyList(),
skippedRules = emptyList(),
warnings = listOf("No routing rules matched the event"),
)
}

val applied = mutableListOf<RoutingRule>()
val skipped = mutableListOf<SkippedRule>()
val warnings = mutableListOf<String>()
val targets = linkedSetOf<String>()
var winningPriority: Int? = null

for (rule in matchingRules) {
if (winningPriority != null && rule.priority < winningPriority) break

if (configuration.enableConsentChecking && !rule.passesConsent(event, consent)) {
skipped += SkippedRule(rule, CONSENT_REJECTION)
continue
}
if (configuration.enableSampling &&
!rule.passesSampling(event, configuration.sampler)
) {
skipped += SkippedRule(rule, "Event was sampled out")
continue
}

val resolved = resolve(rule.targetGroup, availableTrackers)
if (resolved.isEmpty()) {
skipped += SkippedRule(rule, "No available trackers in target group")
warnings += "Rule resolved to no available trackers"
continue
}

if (winningPriority == null) winningPriority = rule.priority
targets += resolved
applied += rule
}

return RoutingResult(event, targets.toList(), applied, skipped, warnings)
}

public fun debug(
event: FlexEvent,
consent: ConsentState = ConsentState(),
availableTrackers: Set<String> = emptySet(),
): RoutingDebugInfo {
val result = route(event, consent, availableTrackers)
val applied = result.appliedRules.toSet()
val skipped = result.skippedRules.associate { it.rule to it.reason }
val decisions = configuration.rules.map { rule ->
val matches = rule.matches(event, configuration.isDebugMode)
RuleDecision(
rule = rule,
matched = matches,
applied = rule in applied,
reason = when {
rule in skipped -> skipped[rule]
!matches -> "Rule conditions did not match"
rule !in applied -> "Lower priority tier was not evaluated"
else -> null
},
)
}
return RoutingDebugInfo(event, decisions, result)
}

private fun resolve(group: TrackerGroup, available: Set<String>): List<String> =
if (group.includesAll) available.toList()
else group.trackerIds.filter(available::contains)

private companion object {
const val CONSENT_REJECTION = "Consent requirements not met"
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package dev.taghizadeh.flextrack.routing

import dev.taghizadeh.flextrack.event.FlexEvent

public data class SkippedRule(
val rule: RoutingRule,
val reason: String,
)

public data class RoutingResult(
val event: FlexEvent,
val targetTrackers: List<String>,
val appliedRules: List<RoutingRule>,
val skippedRules: List<SkippedRule>,
val warnings: List<String>,
) {
val willBeTracked: Boolean get() = targetTrackers.isNotEmpty()
val hasIssues: Boolean get() = warnings.isNotEmpty() || skippedRules.isNotEmpty()
}

public data class RuleDecision(
val rule: RoutingRule,
val matched: Boolean,
val applied: Boolean,
val reason: String?,
)

public data class RoutingDebugInfo(
val event: FlexEvent,
val decisions: List<RuleDecision>,
val routingResult: RoutingResult,
)
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
package dev.taghizadeh.flextrack.routing

import dev.taghizadeh.flextrack.event.FlexEvent
import dev.taghizadeh.flextrack.event.originalEvent
import dev.taghizadeh.flextrack.sampling.DeterministicSampler
import dev.taghizadeh.flextrack.sampling.EventSampler

public data class ConsentState(
val general: Boolean = false,
val pii: Boolean = false,
)

public data class RoutingRule(
val id: String? = null,
val eventType: Class<out FlexEvent>? = null,
val eventNameContains: String? = null,
val eventNameRegex: Regex? = null,
val category: EventCategory? = null,
val hasProperty: String? = null,
val propertyValue: Any? = null,
val containsPII: Boolean? = null,
val isHighVolume: Boolean? = null,
val isEssential: Boolean? = null,
val isDefault: Boolean = false,
val targetGroup: TrackerGroup,
val sampleRate: Double = 1.0,
val requireConsent: Boolean = true,
val requirePIIConsent: Boolean = false,
val debugOnly: Boolean = false,
val productionOnly: Boolean = false,
val priority: Int = 0,
val description: String? = null,
) {
init {
require(sampleRate in 0.0..1.0) { "sampleRate must be between 0 and 1" }
require(!(debugOnly && productionOnly)) {
"a rule cannot be both debug-only and production-only"
}
}

public fun matches(event: FlexEvent, isDebugMode: Boolean = false): Boolean {
if (debugOnly && !isDebugMode) return false
if (productionOnly && isDebugMode) return false
if (eventType != null && !eventType.isAssignableFrom(event.originalEvent().javaClass)) return false
if (eventNameContains != null && !event.name.contains(eventNameContains)) return false
if (eventNameRegex != null && !eventNameRegex.containsMatchIn(event.name)) return false
if (category != null && event.category != category) return false
if (hasProperty != null) {
val properties = event.properties ?: return false
if (!properties.containsKey(hasProperty)) return false
if (propertyValue != null && properties[hasProperty] != propertyValue) return false
}
if (containsPII != null && event.containsPII != containsPII) return false
if (isHighVolume != null && event.isHighVolume != isHighVolume) return false
if (isEssential != null && event.isEssential != isEssential) return false
return true
}

public fun passesConsent(event: FlexEvent, consent: ConsentState): Boolean {
if (event.isEssential) return true
if (requireConsent && !consent.general) return false
if (requirePIIConsent && !consent.pii) return false
if (event.requiresConsent && !consent.general) return false
return true
}

public fun passesSampling(
event: FlexEvent,
sampler: EventSampler = DeterministicSampler,
): Boolean = sampler.shouldSample(event, sampleRate)
}
Loading
Loading