@founderroute/analytics-react-native 0.1.0-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/FounderRouteAnalytics.podspec +16 -0
- package/LICENSE +21 -0
- package/android/build.gradle +16 -0
- package/android/src/main/AndroidManifest.xml +1 -0
- package/android/src/main/java/com/founderroute/analytics/FounderRouteAnalytics.kt +128 -0
- package/android/src/main/java/com/founderroute/analytics/reactnative/FounderRouteAnalyticsModule.kt +25 -0
- package/bridge.js +18 -0
- package/index.d.ts +12 -0
- package/index.js +14 -0
- package/ios/FounderRouteAnalyticsModule.m +13 -0
- package/ios/FounderRouteAnalyticsModule.swift +18 -0
- package/ios/core/FounderRouteAnalytics.swift +161 -0
- package/ios/core/PrivacyInfo.xcprivacy +10 -0
- package/package.json +15 -0
- package/react-native.config.cjs +1 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
3
|
+
Pod::Spec.new do |s|
|
|
4
|
+
s.name = "FounderRouteAnalytics"
|
|
5
|
+
s.version = package["version"]
|
|
6
|
+
s.summary = "Native consent-first FounderRoute Analytics for React Native"
|
|
7
|
+
s.homepage = "https://github.com/itsreed/founderroute-analytics"
|
|
8
|
+
s.license = "MIT"
|
|
9
|
+
s.author = "FounderRoute"
|
|
10
|
+
s.source = { :git => s.homepage + ".git", :tag => "v#{s.version}" }
|
|
11
|
+
s.platform = :ios, "15.0"
|
|
12
|
+
s.swift_version = "5.9"
|
|
13
|
+
s.source_files = "ios/**/*.{swift,h,m}"
|
|
14
|
+
s.resource_bundles = {"FounderRoutePrivacy" => ["ios/core/PrivacyInfo.xcprivacy"]}
|
|
15
|
+
s.dependency "React-Core"
|
|
16
|
+
end
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 FounderRoute
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
buildscript { repositories { google(); mavenCentral() }; dependencies { classpath 'com.android.tools.build:gradle:8.9.2'; classpath 'org.jetbrains.kotlin:kotlin-gradle-plugin:2.1.20' } }
|
|
2
|
+
apply plugin: 'com.android.library'
|
|
3
|
+
apply plugin: 'org.jetbrains.kotlin.android'
|
|
4
|
+
android {
|
|
5
|
+
namespace 'com.founderroute.analytics.reactnative'
|
|
6
|
+
compileSdk 35
|
|
7
|
+
defaultConfig { minSdk 24 }
|
|
8
|
+
compileOptions { sourceCompatibility JavaVersion.VERSION_17; targetCompatibility JavaVersion.VERSION_17 }
|
|
9
|
+
kotlinOptions { jvmTarget = '17' }
|
|
10
|
+
}
|
|
11
|
+
repositories { google(); mavenCentral() }
|
|
12
|
+
dependencies {
|
|
13
|
+
implementation 'com.facebook.react:react-android'
|
|
14
|
+
implementation 'androidx.work:work-runtime-ktx:2.10.1'
|
|
15
|
+
implementation 'androidx.lifecycle:lifecycle-process:2.8.7'
|
|
16
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
<manifest xmlns:android="http://schemas.android.com/apk/res/android"><uses-permission android:name="android.permission.INTERNET"/><uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/></manifest>
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
package com.founderroute.analytics
|
|
2
|
+
|
|
3
|
+
import android.content.Context
|
|
4
|
+
import androidx.lifecycle.DefaultLifecycleObserver
|
|
5
|
+
import androidx.lifecycle.LifecycleOwner
|
|
6
|
+
import androidx.lifecycle.ProcessLifecycleOwner
|
|
7
|
+
import androidx.work.*
|
|
8
|
+
import org.json.JSONArray
|
|
9
|
+
import org.json.JSONObject
|
|
10
|
+
import java.io.File
|
|
11
|
+
import java.net.HttpURLConnection
|
|
12
|
+
import java.net.URL
|
|
13
|
+
import java.text.SimpleDateFormat
|
|
14
|
+
import java.util.Date
|
|
15
|
+
import java.util.Locale
|
|
16
|
+
import java.util.TimeZone
|
|
17
|
+
import java.util.UUID
|
|
18
|
+
import java.util.concurrent.Executors
|
|
19
|
+
import java.util.concurrent.TimeUnit
|
|
20
|
+
import java.util.concurrent.Callable
|
|
21
|
+
import java.util.concurrent.atomic.AtomicBoolean
|
|
22
|
+
|
|
23
|
+
class FounderRouteAnalytics private constructor(private val context: Context, private val key: String, private val endpoint: String, private val appId: String, private val allowedProperties: Set<String>, private val allowedTraits: Set<String>,private val verificationId:String?) : DefaultLifecycleObserver {
|
|
24
|
+
companion object {
|
|
25
|
+
private fun timestamp(value:Long):String = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'",Locale.US).apply { timeZone=TimeZone.getTimeZone("UTC") }.format(Date(value))
|
|
26
|
+
private fun timestampMillis(value:String):Long {
|
|
27
|
+
val pattern=if(value.contains('.')) "yyyy-MM-dd'T'HH:mm:ss.SSS'Z'" else "yyyy-MM-dd'T'HH:mm:ss'Z'"
|
|
28
|
+
return SimpleDateFormat(pattern,Locale.US).apply { timeZone=TimeZone.getTimeZone("UTC");isLenient=false }.parse(value)?.time ?: 0L
|
|
29
|
+
}
|
|
30
|
+
@Volatile internal var connectionFactory:(URL)->HttpURLConnection = { it.openConnection() as HttpURLConnection }
|
|
31
|
+
@Volatile private var instance: FounderRouteAnalytics? = null
|
|
32
|
+
@Synchronized fun init(context: Context, key: String, endpoint: String, appId: String, allowedProperties: Set<String> = emptySet(), allowedTraits: Set<String> = emptySet(),verificationId:String?=null): FounderRouteAnalytics {
|
|
33
|
+
require(key.startsWith("fr_pk_")); require(endpoint.startsWith("https://") || endpoint.startsWith("http://localhost"))
|
|
34
|
+
return instance ?: FounderRouteAnalytics(context.applicationContext,key,endpoint.trimEnd('/'),appId,allowedProperties,allowedTraits,verificationId).also { instance=it; android.os.Handler(android.os.Looper.getMainLooper()).post { ProcessLifecycleOwner.get().lifecycle.addObserver(it) } }
|
|
35
|
+
}
|
|
36
|
+
fun current() = instance
|
|
37
|
+
fun restore(context:Context,data:Data):FounderRouteAnalytics? {
|
|
38
|
+
val key=data.getString("key")?:return null
|
|
39
|
+
val file=File(context.noBackupFilesDir,"founderroute-${key.takeLast(16)}.json")
|
|
40
|
+
if(!file.exists())return null
|
|
41
|
+
val saved=try{JSONObject(file.readText())}catch(_:Exception){return null}
|
|
42
|
+
if(!saved.optBoolean("consent",false))return null
|
|
43
|
+
return init(context,key,data.getString("endpoint")?:return null,data.getString("appId")?:return null,
|
|
44
|
+
(data.getStringArray("properties")?:emptyArray()).toSet(),(data.getStringArray("traits")?:emptyArray()).toSet(),data.getString("verificationId")).also{it.setConsent(true)}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
private val executor = Executors.newSingleThreadScheduledExecutor()
|
|
48
|
+
private val permitted = AtomicBoolean(false)
|
|
49
|
+
@Volatile private var activeConnection:HttpURLConnection?=null
|
|
50
|
+
private var consent = false; private var foreground = true; private var events = mutableListOf<JSONObject>()
|
|
51
|
+
private var anonymousId: String? = null; private var userId: String? = null; private var accountId: String? = null; private var token: String? = null
|
|
52
|
+
private var traits = JSONObject(); private var session = ""; private var lastActivity = 0L
|
|
53
|
+
private var campaign = JSONObject()
|
|
54
|
+
private var dropped = 0; private var rejected = 0; private var acknowledged = 0; private var lastError: String? = null; private var retryAt = 0L; private var failures = 0
|
|
55
|
+
private val file get() = File(context.noBackupFilesDir,"founderroute-${key.takeLast(16)}.json")
|
|
56
|
+
init { executor.scheduleWithFixedDelay({ if(consent) { if(foreground) enqueue("fr_session","session",JSONObject(),JSONObject().put("active_ms",15000)); deliver() } },15,15,TimeUnit.SECONDS) }
|
|
57
|
+
fun setConsent(granted: Boolean) { permitted.set(granted); if(!granted)activeConnection?.disconnect(); executor.execute {
|
|
58
|
+
if(consent==granted)return@execute
|
|
59
|
+
consent=granted
|
|
60
|
+
if(!granted) { events.clear(); anonymousId=null; userId=null; accountId=null; token=null; traits=JSONObject(); campaign=JSONObject(); file.delete(); WorkManager.getInstance(context).cancelUniqueWork("founderroute-delivery"); return@execute }
|
|
61
|
+
try { if(file.exists()) { val saved=JSONObject(file.readText()); dropped=saved.optInt("dropped",0); anonymousId=saved.optString("anonymous_id").takeIf{it.isNotBlank()}; val queue=saved.optJSONArray("events")?:JSONArray(); events=(0 until queue.length()).map { queue.getJSONObject(it) }.toMutableList() } } catch(_:Exception) { lastError="storage_unavailable" }
|
|
62
|
+
if(anonymousId==null)anonymousId=UUID.randomUUID().toString()
|
|
63
|
+
prune(); persist(); schedule(); deliver()
|
|
64
|
+
} }
|
|
65
|
+
fun identify(id:String, identityToken:String?=null, userTraits:JSONObject=JSONObject()) { executor.execute { if(consent) { if(userId!=null&&userId!=id) resetIdentity(); userId=id;token=identityToken;traits=JSONObject(userTraits.toString());enqueue("fr_identify","identify",JSONObject()) } } }
|
|
66
|
+
fun setAccount(id:String?) { executor.execute { if(consent)accountId=id } }
|
|
67
|
+
fun reset() { executor.execute { if(consent) {resetIdentity();persist()} } }
|
|
68
|
+
private fun resetIdentity() { anonymousId=UUID.randomUUID().toString();userId=null;accountId=null;token=null;traits=JSONObject();session=UUID.randomUUID().toString();lastActivity=0 }
|
|
69
|
+
fun track(name:String, properties:JSONObject=JSONObject(), outcomeId:String?=null) { val snapshot=JSONObject(properties.toString());executor.execute { enqueue(name,"custom",snapshot,outcomeId=outcomeId) } }
|
|
70
|
+
fun screen(name:String) { executor.execute { enqueue("screen_view","screen",JSONObject(),JSONObject().put("screen",name.take(150))) } }
|
|
71
|
+
/** Pass an installed-app deep link only after consent. No app-store attribution is inferred. */
|
|
72
|
+
fun setCampaignContext(url:String) { executor.execute {
|
|
73
|
+
if(!consent||!permitted.get())return@execute
|
|
74
|
+
val uri=android.net.Uri.parse(url);val next=JSONObject()
|
|
75
|
+
for((key,limit) in mapOf("utm_source" to 100,"utm_medium" to 100,"utm_campaign" to 150,"utm_content" to 150)) uri.getQueryParameter(key)?.let{next.put(key,it.take(limit))}
|
|
76
|
+
uri.getQueryParameter("fr_link")?.let { try { next.put("campaign_link",UUID.fromString(it).toString()) } catch(_:Exception){} }
|
|
77
|
+
campaign=next
|
|
78
|
+
} }
|
|
79
|
+
fun flush() { executor.execute { deliver() } }
|
|
80
|
+
fun flushForWorker():Boolean = executor.submit(Callable {
|
|
81
|
+
val deadline=System.currentTimeMillis()+25000
|
|
82
|
+
while(consent&&permitted.get()&&events.isNotEmpty()&&System.currentTimeMillis()<deadline&&System.currentTimeMillis()>=retryAt)deliver()
|
|
83
|
+
!permitted.get()||events.isEmpty()
|
|
84
|
+
}).get(30,TimeUnit.SECONDS)
|
|
85
|
+
fun getDiagnostics(callback:(JSONObject)->Unit) { executor.execute { callback(JSONObject().put("consent",consent).put("queued",events.size).put("dropped",dropped).put("rejected",rejected).put("acknowledged",acknowledged).put("anonymousId",anonymousId?:JSONObject.NULL).put("lastError",lastError?:JSONObject.NULL)) } }
|
|
86
|
+
private fun sanitize(input:JSONObject, allowed:Set<String>):JSONObject { val output=JSONObject();input.keys().forEach { key -> val value=input.opt(key); if(key in allowed&&!Regex("password|secret|token|email|phone|authorization|address|full.?name",RegexOption.IGNORE_CASE).containsMatchIn(key)) { if(value is String)output.put(key,value.take(500));else if(value is Number||value is Boolean||value==JSONObject.NULL)output.put(key,value) } };return output }
|
|
87
|
+
private fun enqueue(name:String,kind:String,properties:JSONObject,extra:JSONObject=JSONObject(),outcomeId:String?=null) {
|
|
88
|
+
if(!consent||!permitted.get()||anonymousId==null)return
|
|
89
|
+
val now=System.currentTimeMillis();if(now-lastActivity>=1800000)session=UUID.randomUUID().toString();lastActivity=now
|
|
90
|
+
val ctx=JSONObject().put("sdk","android").put("sdk_version","0.1.0-beta.1").put("app_id",appId);extra.keys().forEach { ctx.put(it,extra.get(it)) }
|
|
91
|
+
verificationId?.let{ctx.put("verification_id",it)}
|
|
92
|
+
campaign.keys().forEach { ctx.put(it,campaign.get(it)) }
|
|
93
|
+
val event=JSONObject().put("event_id",UUID.randomUUID().toString()).put("protocol",1).put("name",name).put("kind",kind).put("occurred_at",timestamp(now)).put("anonymous_id",anonymousId).put("session_id",session).put("consent",true).put("properties",sanitize(properties,allowedProperties)).put("traits",sanitize(traits,allowedTraits)).put("context",ctx)
|
|
94
|
+
userId?.let{event.put("user_id",it)};accountId?.let{event.put("account_id",it)};token?.let{event.put("identity_token",it)};outcomeId?.let{event.put("outcome_id",it)}
|
|
95
|
+
if(event.toString().toByteArray().size>8192){rejected++;lastError="event_too_large";return}
|
|
96
|
+
events.add(event);prune();persist();schedule()
|
|
97
|
+
}
|
|
98
|
+
private fun prune() {
|
|
99
|
+
val cutoff=System.currentTimeMillis()-7*86400000L;val size=events.size
|
|
100
|
+
events.removeAll { try { timestampMillis(it.getString("occurred_at"))<cutoff } catch(_:Exception){true} };dropped+=size-events.size
|
|
101
|
+
while(events.size>10000||JSONArray(events).toString().toByteArray().size>10*1024*1024){events.removeAt(0);dropped++}
|
|
102
|
+
}
|
|
103
|
+
private fun persist() { if(!consent||!permitted.get())return;try { val temp=File(file.path+".tmp");temp.writeText(JSONObject().put("consent",true).put("dropped",dropped).put("anonymous_id",anonymousId).put("events",JSONArray(events)).toString()); if(!temp.renameTo(file)){file.writeText(temp.readText());temp.delete()} }catch(_:Exception){lastError="storage_unavailable"} }
|
|
104
|
+
private fun schedule() { val constraints=Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build();val request=OneTimeWorkRequestBuilder<AnalyticsDeliveryWorker>().setInputData(workDataOf("key" to key,"endpoint" to endpoint,"appId" to appId,"properties" to allowedProperties.toTypedArray(),"traits" to allowedTraits.toTypedArray(),"verificationId" to verificationId)).setConstraints(constraints).setBackoffCriteria(BackoffPolicy.EXPONENTIAL,30,TimeUnit.SECONDS).build();WorkManager.getInstance(context).enqueueUniqueWork("founderroute-delivery",ExistingWorkPolicy.KEEP,request) }
|
|
105
|
+
private fun deliver() {
|
|
106
|
+
if(!consent||!permitted.get()||System.currentTimeMillis()<retryAt)return
|
|
107
|
+
prune();val batch=mutableListOf<JSONObject>();for(event in events.take(50)){if(JSONObject().put("key",key).put("events",JSONArray(batch+event)).toString().toByteArray().size>65536)break;batch.add(event)};if(batch.isEmpty())return
|
|
108
|
+
var connection:HttpURLConnection?=null
|
|
109
|
+
try {
|
|
110
|
+
connection=connectionFactory(URL("$endpoint/api/analytics/v1/collect"));activeConnection=connection;connection.requestMethod="POST";connection.setRequestProperty("Content-Type","application/json");connection.doOutput=true;connection.connectTimeout=10000;connection.readTimeout=15000
|
|
111
|
+
if(!permitted.get())return
|
|
112
|
+
connection.outputStream.use{it.write(JSONObject().put("key",key).put("events",JSONArray(batch)).toString().toByteArray())}
|
|
113
|
+
val status=connection.responseCode
|
|
114
|
+
if(!permitted.get())return
|
|
115
|
+
if(status==429||status>=500){retry("delivery_$status");return}
|
|
116
|
+
if(status!=200){val ids=batch.map{it.getString("event_id")}.toSet();events.removeAll{it.getString("event_id") in ids};rejected+=ids.size;lastError="delivery_rejected";persist();return}
|
|
117
|
+
val response=JSONObject(connection.inputStream.bufferedReader().use{it.readText()});val results=response.optJSONArray("results")?:JSONArray();val ids=mutableSetOf<String>()
|
|
118
|
+
for(i in 0 until results.length()){val receipt=results.getJSONObject(i);val state=receipt.optString("status");if(state in listOf("accepted","duplicate","rejected")){ids.add(receipt.getString("event_id"));if(state=="rejected"){rejected++;lastError=receipt.optString("reason")}else acknowledged++}}
|
|
119
|
+
events.removeAll{it.getString("event_id") in ids};failures=0;retryAt=0;persist()
|
|
120
|
+
}catch(_:Exception){retry("network_unavailable")}finally{connection?.disconnect();activeConnection=null}
|
|
121
|
+
}
|
|
122
|
+
private fun retry(reason:String){lastError=reason;failures++;retryAt=System.currentTimeMillis()+minOf(300000L,1000L shl minOf(failures,8));schedule()}
|
|
123
|
+
override fun onStart(owner:LifecycleOwner){executor.execute{foreground=true;retryAt=0;deliver()}}
|
|
124
|
+
override fun onStop(owner:LifecycleOwner){executor.execute{foreground=false;deliver()}}
|
|
125
|
+
}
|
|
126
|
+
class AnalyticsDeliveryWorker(context:Context,params:WorkerParameters):Worker(context,params){
|
|
127
|
+
override fun doWork():Result { val client=FounderRouteAnalytics.current()?:FounderRouteAnalytics.restore(applicationContext,inputData)?:return Result.success();return try{if(client.flushForWorker())Result.success()else Result.retry()}catch(_:Exception){Result.retry()} }
|
|
128
|
+
}
|
package/android/src/main/java/com/founderroute/analytics/reactnative/FounderRouteAnalyticsModule.kt
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
package com.founderroute.analytics.reactnative
|
|
2
|
+
import com.facebook.react.bridge.*
|
|
3
|
+
import com.facebook.react.ReactPackage
|
|
4
|
+
import com.facebook.react.uimanager.ViewManager
|
|
5
|
+
import com.founderroute.analytics.FounderRouteAnalytics
|
|
6
|
+
import org.json.JSONObject
|
|
7
|
+
|
|
8
|
+
class FounderRouteAnalyticsModule(private val context:ReactApplicationContext):ReactContextBaseJavaModule(context) {
|
|
9
|
+
override fun getName()="FounderRouteAnalyticsModule"
|
|
10
|
+
private var client:FounderRouteAnalytics?=null
|
|
11
|
+
@ReactMethod fun configure(key:String,endpoint:String,appId:String,properties:ReadableArray,traits:ReadableArray,verificationId:String?) { client=FounderRouteAnalytics.init(context,key,endpoint,appId,properties.toArrayList().map{it.toString()}.toSet(),traits.toArrayList().map{it.toString()}.toSet(),verificationId) }
|
|
12
|
+
@ReactMethod fun setConsent(value:Boolean){client?.setConsent(value)}
|
|
13
|
+
@ReactMethod fun identify(id:String,token:String?,traits:ReadableMap){client?.identify(id,token,JSONObject(traits.toHashMap()))}
|
|
14
|
+
@ReactMethod fun setAccount(id:String?){client?.setAccount(id)}
|
|
15
|
+
@ReactMethod fun track(name:String,properties:ReadableMap,outcomeId:String?){client?.track(name,JSONObject(properties.toHashMap()),outcomeId)}
|
|
16
|
+
@ReactMethod fun screen(name:String){client?.screen(name)}
|
|
17
|
+
@ReactMethod fun setCampaignContext(url:String){client?.setCampaignContext(url)}
|
|
18
|
+
@ReactMethod fun reset(){client?.reset()}
|
|
19
|
+
@ReactMethod fun flush(){client?.flush()}
|
|
20
|
+
@ReactMethod fun getDiagnostics(promise:Promise){val analytics=client;if(analytics==null){promise.reject("not_initialized","Initialize FounderRoute first.");return};analytics.getDiagnostics{data->val result=Arguments.createMap();data.keys().forEach{key->when(val v=data.get(key)){is Boolean->result.putBoolean(key,v);is Number->result.putDouble(key,v.toDouble());JSONObject.NULL->result.putNull(key);else->result.putString(key,v.toString())}};promise.resolve(result)}}
|
|
21
|
+
}
|
|
22
|
+
class FounderRouteAnalyticsPackage:ReactPackage {
|
|
23
|
+
override fun createNativeModules(context:ReactApplicationContext)=listOf<NativeModule>(FounderRouteAnalyticsModule(context))
|
|
24
|
+
override fun createViewManagers(context:ReactApplicationContext)=emptyList<ViewManager<*,*>>()
|
|
25
|
+
}
|
package/bridge.js
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// All persistence, consent, identity, and delivery live in the native SDK once.
|
|
2
|
+
export function createBridge(native, platform, options) {
|
|
3
|
+
if (!native) throw new Error("Link FounderRoute's native module and rebuild the application.");
|
|
4
|
+
const config = {...options, ...options[platform]};
|
|
5
|
+
if (!config.key || !config.appId) throw new Error(`Register a ${platform} property and provide its public key and app ID.`);
|
|
6
|
+
native.configure(config.key, config.endpoint, config.appId, config.allowedProperties ?? [], config.allowedTraits ?? [],config.verificationId??null);
|
|
7
|
+
return {
|
|
8
|
+
setConsent: granted => native.setConsent(Boolean(granted)),
|
|
9
|
+
identify: (id, {token, traits = {}} = {}) => native.identify(id, token ?? null, traits),
|
|
10
|
+
setAccount: id => native.setAccount(id ?? null),
|
|
11
|
+
track: (name, properties = {}, {outcomeId} = {}) => native.track(name, properties, outcomeId ?? null),
|
|
12
|
+
screen: name => native.screen(name),
|
|
13
|
+
setCampaignContext: url => native.setCampaignContext(url),
|
|
14
|
+
reset: () => native.reset(),
|
|
15
|
+
flush: () => native.flush(),
|
|
16
|
+
getDiagnostics: () => native.getDiagnostics(),
|
|
17
|
+
};
|
|
18
|
+
}
|
package/index.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export interface Options { endpoint: string; verificationId?:string; ios: {key:string;appId:string}; android: {key:string;appId:string}; allowedProperties?:string[]; allowedTraits?:string[] }
|
|
2
|
+
export interface Client {
|
|
3
|
+
setConsent(granted:boolean):void;
|
|
4
|
+
identify(id:string,options?:{token?:string;traits?:Record<string,string|number|boolean|null>}):void;
|
|
5
|
+
setAccount(id:string|null):void;
|
|
6
|
+
track(name:string,properties?:Record<string,string|number|boolean|null>,options?:{outcomeId?:string}):void;
|
|
7
|
+
screen(name:string):void; reset():void; flush():void;
|
|
8
|
+
setCampaignContext(url:string):void;
|
|
9
|
+
getDiagnostics():Promise<{consent:boolean;queued:number;dropped:number;anonymousId:string|null}>;
|
|
10
|
+
}
|
|
11
|
+
export function init(options:Options):Client;
|
|
12
|
+
export function navigationAdapter(client:Client,ref:{current?:{getCurrentRoute():{name:string}|undefined}|null}):()=>void;
|
package/index.js
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { NativeModules, Platform } from "react-native";
|
|
2
|
+
import { createBridge } from "./bridge.js";
|
|
3
|
+
let client;
|
|
4
|
+
export function init(options) {
|
|
5
|
+
client ??= createBridge(NativeModules.FounderRouteAnalyticsModule, Platform.OS, options);
|
|
6
|
+
return client;
|
|
7
|
+
}
|
|
8
|
+
export function navigationAdapter(client, navigationRef) {
|
|
9
|
+
let previous;
|
|
10
|
+
return () => {
|
|
11
|
+
const name = navigationRef.current?.getCurrentRoute()?.name;
|
|
12
|
+
if (name && name !== previous) { previous = name; client.screen(name); }
|
|
13
|
+
};
|
|
14
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
#import <React/RCTBridgeModule.h>
|
|
2
|
+
@interface RCT_EXTERN_MODULE(FounderRouteAnalyticsModule, NSObject)
|
|
3
|
+
RCT_EXTERN_METHOD(configure:(NSString *)key endpoint:(NSString *)endpoint appId:(NSString *)appId properties:(NSArray *)properties traits:(NSArray *)traits verificationId:(NSString *)verificationId)
|
|
4
|
+
RCT_EXTERN_METHOD(setConsent:(BOOL)value)
|
|
5
|
+
RCT_EXTERN_METHOD(identify:(NSString *)identifier token:(NSString *)token traits:(NSDictionary *)traits)
|
|
6
|
+
RCT_EXTERN_METHOD(setAccount:(NSString *)identifier)
|
|
7
|
+
RCT_EXTERN_METHOD(track:(NSString *)name properties:(NSDictionary *)properties outcomeId:(NSString *)outcomeId)
|
|
8
|
+
RCT_EXTERN_METHOD(screen:(NSString *)name)
|
|
9
|
+
RCT_EXTERN_METHOD(setCampaignContext:(NSString *)url)
|
|
10
|
+
RCT_EXTERN_METHOD(reset)
|
|
11
|
+
RCT_EXTERN_METHOD(flush)
|
|
12
|
+
RCT_EXTERN_METHOD(getDiagnostics:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject)
|
|
13
|
+
@end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import React
|
|
3
|
+
|
|
4
|
+
@objc(FounderRouteAnalyticsModule)
|
|
5
|
+
final class FounderRouteAnalyticsModule: NSObject {
|
|
6
|
+
private let analytics = FounderRouteAnalytics.shared
|
|
7
|
+
@objc static func requiresMainQueueSetup() -> Bool { false }
|
|
8
|
+
@objc func configure(_ key: String, endpoint: String, appId: String, properties: [String], traits: [String], verificationId: String?) { analytics.configure(key: key, endpoint: endpoint, appId: appId, allowedProperties: properties, allowedTraits: traits, verificationId: verificationId) }
|
|
9
|
+
@objc func setConsent(_ value: Bool) { analytics.setConsent(value) }
|
|
10
|
+
@objc func identify(_ id: String, token: String?, traits: [String: Any]) { analytics.identify(id, token: token, traits: traits) }
|
|
11
|
+
@objc func setAccount(_ id: String?) { analytics.setAccount(id) }
|
|
12
|
+
@objc func track(_ name: String, properties: [String: Any], outcomeId: String?) { analytics.track(name, properties: properties, outcomeId: outcomeId) }
|
|
13
|
+
@objc func screen(_ name: String) { analytics.screen(name) }
|
|
14
|
+
@objc func setCampaignContext(_ url: String) { analytics.setCampaignContext(url) }
|
|
15
|
+
@objc func reset() { analytics.reset() }
|
|
16
|
+
@objc func flush() { analytics.flush() }
|
|
17
|
+
@objc func getDiagnostics(_ resolve: @escaping RCTPromiseResolveBlock, rejecter reject: @escaping RCTPromiseRejectBlock) { analytics.getDiagnostics { resolve($0) } }
|
|
18
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
#if canImport(UIKit)
|
|
3
|
+
import UIKit
|
|
4
|
+
#endif
|
|
5
|
+
|
|
6
|
+
public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
7
|
+
public static let shared = FounderRouteAnalytics()
|
|
8
|
+
private let work = DispatchQueue(label: "com.founderroute.analytics", qos: .utility)
|
|
9
|
+
private var key = "", endpoint = "", appId = ""
|
|
10
|
+
private var verificationId: String?
|
|
11
|
+
private var campaign: [String:Any] = [:]
|
|
12
|
+
private var consent = false, sending = false, foreground = true
|
|
13
|
+
private var anonymousId: String?, userId: String?, accountId: String?, identityToken: String?
|
|
14
|
+
private var traits: [String: Any] = [:], allowedTraits: Set<String> = [], allowedProperties: Set<String> = []
|
|
15
|
+
private var events: [[String: Any]] = []
|
|
16
|
+
private var sessionId = "", lastActivity = Date.distantPast
|
|
17
|
+
private var dropped = 0, rejected = 0, acknowledged = 0, failures = 0, generation = 0
|
|
18
|
+
private var retryAt = Date.distantPast, lastError: String?
|
|
19
|
+
private var timer: DispatchSourceTimer?, task: URLSessionDataTask?
|
|
20
|
+
private var observers: [NSObjectProtocol] = []
|
|
21
|
+
private let transport: URLSession
|
|
22
|
+
public init(transport: URLSession = .shared) { self.transport = transport }
|
|
23
|
+
|
|
24
|
+
public func configure(key: String, endpoint: String, appId: String, allowedProperties: [String] = [], allowedTraits: [String] = [], verificationId: String? = nil) {
|
|
25
|
+
work.async {
|
|
26
|
+
guard self.key.isEmpty else { return }
|
|
27
|
+
guard key.hasPrefix("fr_pk_"), let url = URL(string: endpoint), url.scheme == "https" || url.host == "localhost" else { self.lastError = "invalid_configuration"; return }
|
|
28
|
+
self.key = key; self.endpoint = endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")); self.appId = appId
|
|
29
|
+
self.verificationId = verificationId
|
|
30
|
+
self.allowedProperties = Set(allowedProperties); self.allowedTraits = Set(allowedTraits)
|
|
31
|
+
#if canImport(UIKit)
|
|
32
|
+
self.observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in
|
|
33
|
+
self?.work.async { self?.foreground = false; self?.deliver() }
|
|
34
|
+
})
|
|
35
|
+
self.observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { [weak self] _ in
|
|
36
|
+
self?.work.async { self?.foreground = true; self?.retryAt = .distantPast; self?.deliver() }
|
|
37
|
+
})
|
|
38
|
+
#endif
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
private var fileURL: URL? {
|
|
42
|
+
guard !key.isEmpty else { return nil }
|
|
43
|
+
return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("founderroute-\(key.suffix(16)).json")
|
|
44
|
+
}
|
|
45
|
+
public func setConsent(_ granted: Bool) {
|
|
46
|
+
work.async {
|
|
47
|
+
guard granted != self.consent, !self.key.isEmpty else { return }
|
|
48
|
+
self.consent = granted; self.generation += 1
|
|
49
|
+
if !granted {
|
|
50
|
+
self.task?.cancel(); self.timer?.cancel(); self.timer = nil; self.events = []; self.anonymousId = nil
|
|
51
|
+
self.userId = nil; self.accountId = nil; self.identityToken = nil; self.traits = [:]; self.campaign = [:]
|
|
52
|
+
if let file = self.fileURL { try? FileManager.default.removeItem(at: file) }; return
|
|
53
|
+
}
|
|
54
|
+
if let file = self.fileURL, let data = try? Data(contentsOf: file), let saved = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
|
55
|
+
self.dropped = saved["dropped"] as? Int ?? 0
|
|
56
|
+
self.events = saved["events"] as? [[String: Any]] ?? []; self.anonymousId = saved["anonymous_id"] as? String
|
|
57
|
+
}
|
|
58
|
+
self.anonymousId = self.anonymousId ?? UUID().uuidString
|
|
59
|
+
self.prune(); self.persist()
|
|
60
|
+
let timer = DispatchSource.makeTimerSource(queue: self.work)
|
|
61
|
+
timer.schedule(deadline: .now() + 15, repeating: 15)
|
|
62
|
+
timer.setEventHandler { [weak self] in
|
|
63
|
+
guard let self else { return }
|
|
64
|
+
if self.foreground { self.enqueue("fr_session", kind: "session", properties: [:], context: ["active_ms": 15000]) }
|
|
65
|
+
self.deliver()
|
|
66
|
+
}
|
|
67
|
+
self.timer = timer; timer.resume(); self.deliver()
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
public func identify(_ userId: String, token: String? = nil, traits: [String: Any] = [:]) {
|
|
71
|
+
work.async {
|
|
72
|
+
guard self.consent else { return }
|
|
73
|
+
if let old = self.userId, old != userId { self.resetIdentity() }
|
|
74
|
+
self.userId = userId; self.identityToken = token; self.traits = traits
|
|
75
|
+
self.enqueue("fr_identify", kind: "identify", properties: [:])
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
public func setAccount(_ id: String?) { work.async { if self.consent { self.accountId = id } } }
|
|
79
|
+
public func reset() { work.async { if self.consent { self.resetIdentity(); self.persist() } } }
|
|
80
|
+
private func resetIdentity() { anonymousId = UUID().uuidString; userId = nil; accountId = nil; identityToken = nil; traits = [:]; sessionId = UUID().uuidString; lastActivity = .distantPast }
|
|
81
|
+
public func track(_ name: String, properties: [String: Any] = [:], outcomeId: String? = nil) { work.async { self.enqueue(name, kind: "custom", properties: properties, outcomeId: outcomeId) } }
|
|
82
|
+
public func screen(_ name: String) { work.async { self.enqueue("screen_view", kind: "screen", properties: [:], context: ["screen": String(name.prefix(150))]) } }
|
|
83
|
+
/// Supply an installed-app deep link after consent; this does not infer app-store attribution.
|
|
84
|
+
public func setCampaignContext(_ url: String) { work.async {
|
|
85
|
+
guard self.consent, let items = URLComponents(string:url)?.queryItems else { return }
|
|
86
|
+
var next: [String:Any] = [:]
|
|
87
|
+
for item in items {
|
|
88
|
+
guard let value = item.value else { continue }
|
|
89
|
+
if let limit = ["utm_source":100,"utm_medium":100,"utm_campaign":150,"utm_content":150][item.name] { next[item.name] = String(value.prefix(limit)) }
|
|
90
|
+
if item.name == "fr_link", let id = UUID(uuidString:value) { next["campaign_link"] = id.uuidString.lowercased() }
|
|
91
|
+
}
|
|
92
|
+
self.campaign = next
|
|
93
|
+
} }
|
|
94
|
+
public func flush() { work.async { self.deliver() } }
|
|
95
|
+
public func getDiagnostics(_ completion: @escaping ([String: Any]) -> Void) {
|
|
96
|
+
work.async { let result: [String: Any] = ["consent": self.consent, "queued": self.events.count, "dropped": self.dropped, "rejected": self.rejected, "acknowledged": self.acknowledged, "anonymousId": self.anonymousId.map { $0 as Any } ?? NSNull(), "lastError": self.lastError.map { $0 as Any } ?? NSNull()]; completion(result) }
|
|
97
|
+
}
|
|
98
|
+
private func sanitize(_ input: [String: Any], allowed: Set<String>) -> [String: Any] {
|
|
99
|
+
var result: [String: Any] = [:]
|
|
100
|
+
for (key, value) in input where allowed.contains(key) && key.range(of: "password|secret|token|email|phone|authorization|address|full.?name", options: .regularExpression) == nil {
|
|
101
|
+
if let string = value as? String { result[key] = String(string.prefix(500)) }
|
|
102
|
+
else if value is NSNumber || value is NSNull { result[key] = value }
|
|
103
|
+
}
|
|
104
|
+
return result
|
|
105
|
+
}
|
|
106
|
+
private func enqueue(_ name: String, kind: String, properties: [String: Any], context: [String: Any] = [:], outcomeId: String? = nil) {
|
|
107
|
+
guard consent, let anonymousId else { return }
|
|
108
|
+
let now = Date(); if now.timeIntervalSince(lastActivity) >= 1800 { sessionId = UUID().uuidString }; lastActivity = now
|
|
109
|
+
var ctx: [String: Any] = ["sdk": "ios", "sdk_version": "0.1.0-beta.1", "app_id": appId]
|
|
110
|
+
ctx["verification_id"] = verificationId
|
|
111
|
+
for (key, value) in campaign { ctx[key] = value }
|
|
112
|
+
for (key, value) in context { ctx[key] = value }
|
|
113
|
+
var event: [String: Any] = ["event_id": UUID().uuidString, "protocol": 1, "name": name, "kind": kind, "occurred_at": ISO8601DateFormatter().string(from: now), "anonymous_id": anonymousId, "session_id": sessionId, "consent": true, "properties": sanitize(properties, allowed: allowedProperties), "traits": sanitize(traits, allowed: allowedTraits), "context": ctx]
|
|
114
|
+
event["user_id"] = userId; event["identity_token"] = identityToken; event["account_id"] = accountId; event["outcome_id"] = outcomeId
|
|
115
|
+
guard let bytes = try? JSONSerialization.data(withJSONObject: event), bytes.count <= 8192 else { rejected += 1; lastError = "event_too_large"; return }
|
|
116
|
+
events.append(event); prune(); persist()
|
|
117
|
+
}
|
|
118
|
+
private func prune() {
|
|
119
|
+
let cutoff = Date().addingTimeInterval(-7 * 86400); let before = events.count
|
|
120
|
+
events.removeAll { event in guard let at = event["occurred_at"] as? String, let date = ISO8601DateFormatter().date(from: at) else { return true }; return date < cutoff }
|
|
121
|
+
dropped += before - events.count
|
|
122
|
+
while events.count > 10000 || ((try? JSONSerialization.data(withJSONObject: events).count) ?? 0) > 10 * 1024 * 1024 { events.removeFirst(); dropped += 1 }
|
|
123
|
+
}
|
|
124
|
+
private func persist() {
|
|
125
|
+
guard consent, let file = fileURL else { return }
|
|
126
|
+
do {
|
|
127
|
+
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
128
|
+
let data = try JSONSerialization.data(withJSONObject: ["anonymous_id": anonymousId ?? "", "events": events, "dropped": dropped])
|
|
129
|
+
try data.write(to: file, options: .atomic)
|
|
130
|
+
var resource = URLResourceValues(); resource.isExcludedFromBackup = true; var mutable = file; try mutable.setResourceValues(resource)
|
|
131
|
+
} catch { lastError = "storage_unavailable" }
|
|
132
|
+
}
|
|
133
|
+
private func deliver() {
|
|
134
|
+
guard consent, !sending, Date() >= retryAt, let url = URL(string: endpoint + "/api/analytics/v1/collect") else { return }
|
|
135
|
+
prune(); var batch: [[String: Any]] = []
|
|
136
|
+
for event in events.prefix(50) {
|
|
137
|
+
guard let data = try? JSONSerialization.data(withJSONObject: ["key": key, "events": batch + [event]]), data.count <= 65536 else { break }; batch.append(event)
|
|
138
|
+
}
|
|
139
|
+
guard !batch.isEmpty, let body = try? JSONSerialization.data(withJSONObject: ["key": key, "events": batch]) else { return }
|
|
140
|
+
var request = URLRequest(url: url); request.httpMethod = "POST"; request.httpBody = body; request.setValue("application/json", forHTTPHeaderField: "Content-Type"); request.timeoutInterval = 20
|
|
141
|
+
sending = true; let currentGeneration = generation; let batchIDs = Set(batch.compactMap { $0["event_id"] as? String })
|
|
142
|
+
task = transport.dataTask(with: request) { [weak self] data, response, error in
|
|
143
|
+
guard let self else { return }
|
|
144
|
+
self.work.async {
|
|
145
|
+
self.sending = false; guard self.consent, self.generation == currentGeneration else { return }
|
|
146
|
+
guard error == nil, let response = response as? HTTPURLResponse else { self.retry("network_unavailable"); return }
|
|
147
|
+
let status = response.statusCode
|
|
148
|
+
if status == 429 || status >= 500 { self.retry("delivery_\(status)"); return }
|
|
149
|
+
guard status == 200, let data, let result = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any], let receipts = result["results"] as? [[String: Any]] else {
|
|
150
|
+
self.events.removeAll { batchIDs.contains($0["event_id"] as? String ?? "") }; self.rejected += batchIDs.count; self.lastError = "delivery_rejected"; self.persist(); return
|
|
151
|
+
}
|
|
152
|
+
var ids = Set<String>()
|
|
153
|
+
for receipt in receipts { if let id = receipt["event_id"] as? String, let state = receipt["status"] as? String, ["accepted", "duplicate", "rejected"].contains(state) { ids.insert(id); if state == "rejected" { self.rejected += 1; self.lastError = receipt["reason"] as? String } else { self.acknowledged += 1 } } }
|
|
154
|
+
self.events.removeAll { ids.contains($0["event_id"] as? String ?? "") }; self.failures = 0; self.retryAt = .distantPast; self.persist()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
task?.resume()
|
|
158
|
+
}
|
|
159
|
+
private func retry(_ error: String) { lastError = error; failures += 1; retryAt = Date().addingTimeInterval(min(300, pow(2, Double(min(8, failures))))); persist() }
|
|
160
|
+
deinit { timer?.cancel(); task?.cancel(); observers.forEach { NotificationCenter.default.removeObserver($0) } }
|
|
161
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0"><dict>
|
|
4
|
+
<key>NSPrivacyTracking</key><false/>
|
|
5
|
+
<key>NSPrivacyTrackingDomains</key><array/>
|
|
6
|
+
<key>NSPrivacyAccessedAPITypes</key><array/>
|
|
7
|
+
<key>NSPrivacyCollectedDataTypes</key><array>
|
|
8
|
+
<dict><key>NSPrivacyCollectedDataType</key><string>NSPrivacyCollectedDataTypeProductInteraction</string><key>NSPrivacyCollectedDataTypeLinked</key><true/><key>NSPrivacyCollectedDataTypeTracking</key><false/><key>NSPrivacyCollectedDataTypePurposes</key><array><string>NSPrivacyCollectedDataTypePurposeAnalytics</string></array></dict>
|
|
9
|
+
<dict><key>NSPrivacyCollectedDataType</key><string>NSPrivacyCollectedDataTypeUserID</string><key>NSPrivacyCollectedDataTypeLinked</key><true/><key>NSPrivacyCollectedDataTypeTracking</key><false/><key>NSPrivacyCollectedDataTypePurposes</key><array><string>NSPrivacyCollectedDataTypePurposeAnalytics</string></array></dict>
|
|
10
|
+
</array></dict></plist>
|
package/package.json
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@founderroute/analytics-react-native",
|
|
3
|
+
"version": "0.1.0-beta.1",
|
|
4
|
+
"description": "Consent-first FounderRoute analytics for React Native",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"type": "module",
|
|
7
|
+
"main": "index.js",
|
|
8
|
+
"types": "index.d.ts",
|
|
9
|
+
"files": ["*.js", "*.cjs", "*.d.ts", "*.podspec", "android", "ios", "LICENSE"],
|
|
10
|
+
"peerDependencies": {"react": ">=18 <20", "react-native": ">=0.76 <1"},
|
|
11
|
+
"repository": { "type": "git", "url": "git+https://github.com/itsreed/founderroute-analytics.git", "directory": "packages/react-native" },
|
|
12
|
+
"homepage": "https://github.com/itsreed/founderroute-analytics#readme",
|
|
13
|
+
"bugs": { "url": "https://github.com/itsreed/founderroute-analytics/issues" },
|
|
14
|
+
"publishConfig": { "access": "public", "provenance": true }
|
|
15
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
module.exports = { dependency: { platforms: { android: { packageImportPath: 'import com.founderroute.analytics.reactnative.FounderRouteAnalyticsPackage;', packageInstance: 'new FounderRouteAnalyticsPackage()' } } } };
|