From 8a008bc556ae497833179d869b05773873d7ebc6 Mon Sep 17 00:00:00 2001 From: Reza Taghizadeh Date: Mon, 17 Aug 2026 22:20:12 +0200 Subject: [PATCH] implement core behavior --- flextrack/build.gradle.kts | 2 + .../flextrack/event/EnrichedEvent.kt | 38 ++++++ .../taghizadeh/flextrack/event/FlexEvent.kt | 43 ++++++ .../flextrack/event/TransformerPipeline.kt | 28 ++++ .../flextrack/routing/EventCategory.kt | 18 +++ .../flextrack/routing/RoutingConfiguration.kt | 33 +++++ .../flextrack/routing/RoutingEngine.kt | 89 ++++++++++++ .../flextrack/routing/RoutingResult.kt | 32 +++++ .../flextrack/routing/RoutingRule.kt | 71 ++++++++++ .../flextrack/routing/TrackerGroup.kt | 30 ++++ .../sampling/DeterministicSampler.kt | 40 ++++++ .../flextrack/event/EventContractTest.kt | 74 ++++++++++ .../flextrack/routing/RoutingEngineTest.kt | 129 ++++++++++++++++++ .../sampling/DeterministicSamplerTest.kt | 37 +++++ gradle/libs.versions.toml | 2 + 15 files changed, 666 insertions(+) create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/EnrichedEvent.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/FlexEvent.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/TransformerPipeline.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/EventCategory.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingConfiguration.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngine.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingResult.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingRule.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/TrackerGroup.kt create mode 100644 flextrack/src/main/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSampler.kt create mode 100644 flextrack/src/test/kotlin/dev/taghizadeh/flextrack/event/EventContractTest.kt create mode 100644 flextrack/src/test/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngineTest.kt create mode 100644 flextrack/src/test/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSamplerTest.kt diff --git a/flextrack/build.gradle.kts b/flextrack/build.gradle.kts index 742ad75..8779489 100644 --- a/flextrack/build.gradle.kts +++ b/flextrack/build.gradle.kts @@ -25,6 +25,7 @@ android { } compileOptions { + isCoreLibraryDesugaringEnabled = true sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 } @@ -47,6 +48,7 @@ android { } dependencies { + coreLibraryDesugaring(libs.desugar.jdk.libs) testImplementation(libs.junit.jupiter) } diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/EnrichedEvent.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/EnrichedEvent.kt new file mode 100644 index 0000000..228ad95 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/EnrichedEvent.kt @@ -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, +) : FlexEvent(original.eventId, original.timestamp) { + public val extraProperties: Map = + Collections.unmodifiableMap(LinkedHashMap(extraProperties)) + + override val name: String get() = original.name + override val properties: Map = + Collections.unmodifiableMap( + LinkedHashMap().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 diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/FlexEvent.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/FlexEvent.kt new file mode 100644 index 0000000..1f293be --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/FlexEvent.kt @@ -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? + + 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 = 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, + ) +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/TransformerPipeline.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/TransformerPipeline.kt new file mode 100644 index 0000000..9a050a8 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/event/TransformerPipeline.kt @@ -0,0 +1,28 @@ +package dev.taghizadeh.flextrack.event + +/** Ordered, failure-isolated event transformation. */ +public class TransformerPipeline { + private val transformers: MutableList = 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 +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/EventCategory.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/EventCategory.kt new file mode 100644 index 0000000..a221d7d --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/EventCategory.kt @@ -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") + } +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingConfiguration.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingConfiguration.kt new file mode 100644 index 0000000..5ce928b --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingConfiguration.kt @@ -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, + val customGroups: Map = 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 { + 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() + } +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngine.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngine.kt new file mode 100644 index 0000000..2d4d7e8 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngine.kt @@ -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 = 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() + val skipped = mutableListOf() + val warnings = mutableListOf() + val targets = linkedSetOf() + 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 = 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): List = + if (group.includesAll) available.toList() + else group.trackerIds.filter(available::contains) + + private companion object { + const val CONSENT_REJECTION = "Consent requirements not met" + } +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingResult.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingResult.kt new file mode 100644 index 0000000..dfff41a --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingResult.kt @@ -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, + val appliedRules: List, + val skippedRules: List, + val warnings: List, +) { + 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, + val routingResult: RoutingResult, +) diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingRule.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingRule.kt new file mode 100644 index 0000000..7939c58 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/RoutingRule.kt @@ -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? = 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) +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/TrackerGroup.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/TrackerGroup.kt new file mode 100644 index 0000000..c386ba6 --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/routing/TrackerGroup.kt @@ -0,0 +1,30 @@ +package dev.taghizadeh.flextrack.routing + +import java.util.Collections + +public class TrackerGroup( + public val name: String, + trackerIds: List, +) { + public val trackerIds: List = + Collections.unmodifiableList(trackerIds.distinct()) + + public val includesAll: Boolean get() = ALL_TRACKERS in trackerIds + + init { + require(name.isNotEmpty()) { "group name cannot be empty" } + } + + override fun equals(other: Any?): Boolean = + other is TrackerGroup && name == other.name && trackerIds == other.trackerIds + + override fun hashCode(): Int = 31 * name.hashCode() + trackerIds.hashCode() + + override fun toString(): String = "TrackerGroup($name: ${trackerIds.joinToString()})" + + public companion object { + public const val ALL_TRACKERS: String = "*" + public val All: TrackerGroup = TrackerGroup("all", listOf(ALL_TRACKERS)) + public val Development: TrackerGroup = TrackerGroup("development", listOf("console")) + } +} diff --git a/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSampler.kt b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSampler.kt new file mode 100644 index 0000000..73caa1d --- /dev/null +++ b/flextrack/src/main/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSampler.kt @@ -0,0 +1,40 @@ +package dev.taghizadeh.flextrack.sampling + +import dev.taghizadeh.flextrack.event.FlexEvent + +public fun interface EventSampler { + public fun shouldSample(event: FlexEvent, sampleRate: Double): Boolean +} + +/** Cross-platform FNV-1a sampler defined by FlexTrack Core Spec 1.0.0. */ +public object DeterministicSampler : EventSampler { + private const val FNV_OFFSET_BASIS: Long = 2_166_136_261L + private const val FNV_PRIME: Long = 16_777_619L + private const val UINT_32_MASK: Long = 0xffff_ffffL + private const val UINT_32_RANGE: Double = 4_294_967_296.0 + + override fun shouldSample(event: FlexEvent, sampleRate: Double): Boolean { + if (event.isEssential) return true + return shouldSample(samplingKey(event), sampleRate) + } + + public fun shouldSample(identity: String, sampleRate: Double): Boolean = when { + sampleRate <= 0.0 -> false + sampleRate >= 1.0 -> true + else -> stableHash(identity) / UINT_32_RANGE < sampleRate + } + + public fun stableHash(input: String): Long { + var hash = FNV_OFFSET_BASIS + input.toByteArray(Charsets.UTF_8).forEach { byte -> + hash = hash xor byte.toUByte().toLong() + hash = (hash * FNV_PRIME) and UINT_32_MASK + } + return hash + } + + public fun samplingKey(event: FlexEvent): String = + event.userId?.takeIf(String::isNotEmpty) + ?: event.sessionId?.takeIf(String::isNotEmpty) + ?: event.name +} diff --git a/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/event/EventContractTest.kt b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/event/EventContractTest.kt new file mode 100644 index 0000000..78febd0 --- /dev/null +++ b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/event/EventContractTest.kt @@ -0,0 +1,74 @@ +package dev.taghizadeh.flextrack.event + +import dev.taghizadeh.flextrack.routing.EventCategory +import java.time.Instant +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertNotEquals +import org.junit.jupiter.api.Assertions.assertSame +import org.junit.jupiter.api.Assertions.assertThrows +import org.junit.jupiter.api.Test + +class EventContractTest { + @Test + fun `new events have stable UUID v4 identity and UTC timestamp`() { + val first = TestEvent() + val second = TestEvent() + + assertNotEquals(first.eventId, second.eventId) + assertEquals(4, first.eventId.substring(14, 15).toInt()) + assertSame(first.timestamp, first.timestamp) + assertEquals("Z", first.timestamp.toString().takeLast(1)) + } + + @Test + fun `reconstructed event preserves supplied metadata`() { + val timestamp = Instant.parse("2026-08-17T12:30:00Z") + val event = TestEvent("fixture-id", timestamp) + + assertEquals("fixture-id", event.eventId) + assertSame(timestamp, event.timestamp) + assertThrows(IllegalArgumentException::class.java) { TestEvent("") } + } + + @Test + fun `nested enrichment preserves metadata and later properties win`() { + val original = TestEvent(propertiesValue = mapOf("plan" to "free")) + val first = EnrichedEvent(original, mapOf("plan" to "pro")) + val second = EnrichedEvent(first, mapOf("route" to "/pay")) + + assertEquals(original.eventId, second.eventId) + assertSame(original.timestamp, second.timestamp) + assertEquals(mapOf("plan" to "pro", "route" to "/pay"), second.properties) + assertEquals(EventCategory.Business, second.category) + } + + @Test + fun `transformers run in order and isolate failures`() { + val pipeline = TransformerPipeline() + pipeline.add { EnrichedEvent(it, mapOf("first" to true)) } + pipeline.add { error("broken") } + pipeline.add { EnrichedEvent(it, mapOf("last" to true)) } + + val transformed = pipeline.transform(TestEvent()) + + assertEquals(mapOf("first" to true, "last" to true), transformed.properties) + } +} + +internal open class TestEvent( + eventId: String = java.util.UUID.randomUUID().toString(), + timestamp: Instant = Instant.now(), + private val eventName: String = "purchase", + private val propertiesValue: Map? = null, + override val requiresConsent: Boolean = true, + override val containsPII: Boolean = false, + override val isEssential: Boolean = false, + override val userId: String? = null, + override val sessionId: String? = null, +) : FlexEvent(eventId, timestamp) { + override val name: String = eventName + override val properties: Map? = propertiesValue + override val category: EventCategory = EventCategory.Business +} + +internal class ChildTestEvent : TestEvent() diff --git a/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngineTest.kt b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngineTest.kt new file mode 100644 index 0000000..dfb19d2 --- /dev/null +++ b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/routing/RoutingEngineTest.kt @@ -0,0 +1,129 @@ +package dev.taghizadeh.flextrack.routing + +import dev.taghizadeh.flextrack.event.ChildTestEvent +import dev.taghizadeh.flextrack.event.EnrichedEvent +import dev.taghizadeh.flextrack.event.TestEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class RoutingEngineTest { + private val analytics = TrackerGroup("analytics", listOf("analytics")) + private val archive = TrackerGroup("archive", listOf("archive")) + private val available = linkedSetOf("analytics", "archive") + + @Test + fun `higher priority wins over matching default`() { + val engine = engine( + RoutingRule(category = EventCategory.Business, priority = 10, targetGroup = analytics), + RoutingRule(isDefault = true, priority = 0, targetGroup = archive), + ) + + val result = engine.route(TestEvent(), ConsentState(general = true), available) + + assertEquals(listOf("analytics"), result.targetTrackers) + assertEquals(listOf(10), result.appliedRules.map(RoutingRule::priority)) + } + + @Test + fun `same tier rules merge targets in stable order`() { + val engine = engine( + RoutingRule(eventNameContains = "purchase", priority = 5, targetGroup = analytics), + RoutingRule( + eventNameContains = "purchase", + priority = 5, + targetGroup = TrackerGroup("both", listOf("archive", "analytics")), + ), + ) + + val result = engine.route(TestEvent(), ConsentState(general = true), available) + + assertEquals(listOf("analytics", "archive"), result.targetTrackers) + assertEquals(listOf(5, 5), result.appliedRules.map(RoutingRule::priority)) + } + + @Test + fun `blocked high tier falls through to lower tier`() { + val engine = engine( + RoutingRule( + priority = 10, + requirePIIConsent = true, + targetGroup = analytics, + ), + RoutingRule(priority = 0, requireConsent = false, targetGroup = archive), + ) + + val result = engine.route( + TestEvent(requiresConsent = false), + ConsentState(), + available, + ) + + assertEquals(listOf("archive"), result.targetTrackers) + assertEquals("Consent requirements not met", result.skippedRules.single().reason) + } + + @Test + fun `default group provides fallback when nothing matches`() { + val config = RoutingConfiguration( + rules = listOf( + RoutingRule(eventNameContains = "other", targetGroup = analytics), + ), + defaultGroup = archive, + ) + val result = RoutingEngine(config).route( + TestEvent(requiresConsent = false), + availableTrackers = available, + consent = ConsentState(general = true), + ) + + assertEquals(listOf("archive"), result.targetTrackers) + assertEquals(listOf(0), result.appliedRules.map(RoutingRule::priority)) + } + + @Test + fun `general and PII consent are default deny`() { + val generalResult = engine( + RoutingRule(targetGroup = analytics), + ).route(TestEvent(), availableTrackers = available) + val piiResult = engine( + RoutingRule(requirePIIConsent = true, targetGroup = analytics), + ).route( + TestEvent(containsPII = true), + ConsentState(general = true), + available, + ) + + assertTrue(generalResult.targetTrackers.isEmpty()) + assertTrue(piiResult.targetTrackers.isEmpty()) + } + + @Test + fun `essential event bypasses consent and sampling`() { + val rule = RoutingRule(sampleRate = 0.0, targetGroup = analytics) + val result = engine(rule).route( + TestEvent(isEssential = true), + availableTrackers = available, + ) + + assertEquals(listOf("analytics"), result.targetTrackers) + } + + @Test + fun `type condition unwraps enrichment and supports subtypes`() { + val event = EnrichedEvent(ChildTestEvent(), mapOf("route" to "/pay")) + val rule = RoutingRule( + eventType = TestEvent::class.java, + hasProperty = "route", + propertyValue = "/pay", + targetGroup = analytics, + ) + + val result = engine(rule).route(event, ConsentState(general = true), available) + + assertEquals(listOf("analytics"), result.targetTrackers) + } + + private fun engine(vararg rules: RoutingRule): RoutingEngine = + RoutingEngine(RoutingConfiguration(rules.toList())) +} diff --git a/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSamplerTest.kt b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSamplerTest.kt new file mode 100644 index 0000000..d6dc3dd --- /dev/null +++ b/flextrack/src/test/kotlin/dev/taghizadeh/flextrack/sampling/DeterministicSamplerTest.kt @@ -0,0 +1,37 @@ +package dev.taghizadeh.flextrack.sampling + +import dev.taghizadeh.flextrack.event.TestEvent +import org.junit.jupiter.api.Assertions.assertEquals +import org.junit.jupiter.api.Assertions.assertFalse +import org.junit.jupiter.api.Assertions.assertTrue +import org.junit.jupiter.api.Test + +class DeterministicSamplerTest { + @Test + fun `matches shared Unicode FNV vector`() { + assertEquals(538_106_393L, DeterministicSampler.stableHash("नमस्ते")) + assertTrue(DeterministicSampler.shouldSample("नमस्ते", 0.25)) + } + + @Test + fun `uses user then session then event name as identity`() { + assertEquals( + "user-1", + DeterministicSampler.samplingKey( + TestEvent(userId = "user-1", sessionId = "session-1"), + ), + ) + assertEquals( + "session-1", + DeterministicSampler.samplingKey(TestEvent(userId = "", sessionId = "session-1")), + ) + assertEquals("purchase", DeterministicSampler.samplingKey(TestEvent())) + } + + @Test + fun `handles boundaries and essential bypass`() { + assertFalse(DeterministicSampler.shouldSample("value", 0.0)) + assertTrue(DeterministicSampler.shouldSample("value", 1.0)) + assertTrue(DeterministicSampler.shouldSample(TestEvent(isEssential = true), 0.0)) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 0e544aa..1d24d57 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,6 +8,7 @@ junitVersion = "1.1.5" espressoCore = "3.5.1" appcompat = "1.6.1" material = "1.10.0" +desugarJdkLibs = "2.1.5" [libraries] androidx-core-ktx = { group = "androidx.core", name = "core-ktx", version.ref = "coreKtx" } @@ -17,6 +18,7 @@ androidx-junit = { group = "androidx.test.ext", name = "junit", version.ref = "j androidx-espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espressoCore" } androidx-appcompat = { group = "androidx.appcompat", name = "appcompat", version.ref = "appcompat" } material = { group = "com.google.android.material", name = "material", version.ref = "material" } +desugar-jdk-libs = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugarJdkLibs" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" }