@founderroute/analytics-react-native 0.1.0-beta.1 → 1.0.0-rc.2
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 +1 -1
- package/android/src/main/java/com/founderroute/analytics/FounderRouteAnalytics.kt +63 -20
- package/android/src/main/java/com/founderroute/analytics/reactnative/FounderRouteAnalyticsModule.kt +5 -1
- package/bridge.js +5 -1
- package/index.d.ts +4 -2
- package/index.js +4 -1
- package/ios/FounderRouteAnalyticsModule.m +5 -1
- package/ios/FounderRouteAnalyticsModule.swift +5 -1
- package/ios/core/FounderRouteAnalytics.swift +86 -18
- package/package.json +27 -7
|
@@ -3,7 +3,7 @@ package = JSON.parse(File.read(File.join(__dir__, "package.json")))
|
|
|
3
3
|
Pod::Spec.new do |s|
|
|
4
4
|
s.name = "FounderRouteAnalytics"
|
|
5
5
|
s.version = package["version"]
|
|
6
|
-
s.summary = "Native
|
|
6
|
+
s.summary = "Native FounderRoute Analytics for React Native"
|
|
7
7
|
s.homepage = "https://github.com/itsreed/founderroute-analytics"
|
|
8
8
|
s.license = "MIT"
|
|
9
9
|
s.author = "FounderRoute"
|
|
@@ -20,7 +20,7 @@ import java.util.concurrent.TimeUnit
|
|
|
20
20
|
import java.util.concurrent.Callable
|
|
21
21
|
import java.util.concurrent.atomic.AtomicBoolean
|
|
22
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 {
|
|
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?, private val requestedMode:String?, private var propertyId:String?, private var environment:String?) : DefaultLifecycleObserver {
|
|
24
24
|
companion object {
|
|
25
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
26
|
private fun timestampMillis(value:String):Long {
|
|
@@ -29,39 +29,81 @@ class FounderRouteAnalytics private constructor(private val context: Context, pr
|
|
|
29
29
|
}
|
|
30
30
|
@Volatile internal var connectionFactory:(URL)->HttpURLConnection = { it.openConnection() as HttpURLConnection }
|
|
31
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 {
|
|
32
|
+
@Synchronized fun init(context: Context, key: String, endpoint: String, appId: String, allowedProperties: Set<String> = emptySet(), allowedTraits: Set<String> = emptySet(),verificationId:String?=null, collectionMode:String?=null, propertyId:String?=null, environment:String?=null): FounderRouteAnalytics {
|
|
33
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) } }
|
|
34
|
+
return instance ?: FounderRouteAnalytics(context.applicationContext,key,endpoint.trimEnd('/'),appId,allowedProperties,allowedTraits,verificationId,collectionMode,propertyId,environment).also { instance=it; android.os.Handler(android.os.Looper.getMainLooper()).post { ProcessLifecycleOwner.get().lifecycle.addObserver(it) } }
|
|
35
35
|
}
|
|
36
36
|
fun current() = instance
|
|
37
37
|
fun restore(context:Context,data:Data):FounderRouteAnalytics? {
|
|
38
38
|
val key=data.getString("key")?:return null
|
|
39
|
-
val
|
|
39
|
+
val scope=if(data.getString("propertyId")!=null) "${data.getString("propertyId")}-${data.getString("environment")}" else key.takeLast(16)
|
|
40
|
+
val file=File(context.noBackupFilesDir,"founderroute-$scope.json")
|
|
41
|
+
if(File(context.noBackupFilesDir,"founderroute-$scope.refusal").exists())return null
|
|
40
42
|
if(!file.exists())return null
|
|
41
43
|
val saved=try{JSONObject(file.readText())}catch(_:Exception){return null}
|
|
42
|
-
if(!saved.optBoolean("consent",false))return null
|
|
44
|
+
if(!saved.optBoolean("collection_enabled",saved.optBoolean("consent",false)))return null
|
|
43
45
|
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{
|
|
46
|
+
(data.getStringArray("properties")?:emptyArray()).toSet(),(data.getStringArray("traits")?:emptyArray()).toSet(),data.getString("verificationId"),data.getString("collectionMode"),data.getString("propertyId"),data.getString("environment")).also{client->client.executor.execute{client.consentState=saved.optString("consent_state",if(saved.optBoolean("consent",false))"granted" else "not_provided");client.reconcileCollection()}}
|
|
45
47
|
}
|
|
46
48
|
}
|
|
47
49
|
private val executor = Executors.newSingleThreadScheduledExecutor()
|
|
48
50
|
private val permitted = AtomicBoolean(false)
|
|
51
|
+
private val stopRequested = AtomicBoolean(false)
|
|
52
|
+
private var configurationReady = false
|
|
49
53
|
@Volatile private var activeConnection:HttpURLConnection?=null
|
|
54
|
+
private var collectionMode:String? = null
|
|
55
|
+
private var consentState = "not_provided"
|
|
56
|
+
private var refused = false
|
|
57
|
+
private var destroyed = false
|
|
50
58
|
private var consent = false; private var foreground = true; private var events = mutableListOf<JSONObject>()
|
|
51
59
|
private var anonymousId: String? = null; private var userId: String? = null; private var accountId: String? = null; private var token: String? = null
|
|
52
60
|
private var traits = JSONObject(); private var session = ""; private var lastActivity = 0L
|
|
53
61
|
private var campaign = JSONObject()
|
|
54
62
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
63
|
+
private val scope get() = if(propertyId!=null&&environment!=null) "$propertyId-$environment" else key.takeLast(16)
|
|
64
|
+
private val file get() = File(context.noBackupFilesDir,"founderroute-$scope.json")
|
|
65
|
+
private val legacyFile get() = File(context.noBackupFilesDir,"founderroute-${key.takeLast(16)}.json")
|
|
66
|
+
private val refusalFile get() = File(context.noBackupFilesDir,"founderroute-$scope.refusal")
|
|
67
|
+
private fun saveRefusal(value:Boolean) {
|
|
68
|
+
refused=value
|
|
69
|
+
try { if(value) refusalFile.writeText("refused") else if(refusalFile.exists()&&!refusalFile.delete())throw IllegalStateException() }
|
|
70
|
+
catch(_:Exception){lastError="preference_storage_unavailable"}
|
|
71
|
+
}
|
|
72
|
+
private fun configureCollection() {
|
|
73
|
+
try {
|
|
74
|
+
require(requestedMode==null||requestedMode in listOf("automatic","consent"))
|
|
75
|
+
if(propertyId==null||environment==null||requestedMode==null) {
|
|
76
|
+
val connection=connectionFactory(URL("$endpoint/api/analytics/v2/config?key=${java.net.URLEncoder.encode(key,"UTF-8")}"))
|
|
77
|
+
val config=try { connection.connectTimeout=10000;connection.readTimeout=10000;require(connection.responseCode==200);JSONObject(connection.inputStream.bufferedReader().use{it.readText()}) }finally{connection.disconnect()}
|
|
78
|
+
propertyId=config.getString("property_id");environment=config.getString("environment");collectionMode=requestedMode?:config.getString("collection_mode")
|
|
79
|
+
} else collectionMode=requestedMode
|
|
80
|
+
require(propertyId!!.matches(Regex("[a-zA-Z0-9_-]+"))&&environment in listOf("production","test")&&collectionMode in listOf("automatic","consent"))
|
|
81
|
+
refused=refused||refusalFile.exists()
|
|
82
|
+
if(refused)saveRefusal(true)
|
|
83
|
+
configurationReady=true
|
|
84
|
+
reconcileCollection()
|
|
85
|
+
} catch(_:Exception){lastError="configuration_unavailable";permitted.set(false)}
|
|
86
|
+
}
|
|
87
|
+
fun optOut() { stopRequested.set(true);permitted.set(false);activeConnection?.disconnect();executor.execute{consentState="denied";saveRefusal(true);reconcileCollection()} }
|
|
88
|
+
fun optIn() { stopRequested.set(false);executor.execute{saveRefusal(false);if(consentState=="denied")consentState="not_provided";reconcileCollection()} }
|
|
89
|
+
fun setCollectionMode(mode:String) { require(mode in listOf("automatic","consent"));executor.execute{collectionMode=mode;reconcileCollection()} }
|
|
90
|
+
fun destroy() { synchronized(Companion){if(instance===this)instance=null};stopRequested.set(true);permitted.set(false);activeConnection?.disconnect();executor.execute{destroyed=true;consent=false;WorkManager.getInstance(context).cancelUniqueWork("founderroute-delivery");android.os.Handler(android.os.Looper.getMainLooper()).post{ProcessLifecycleOwner.get().lifecycle.removeObserver(this)};synchronized(Companion){if(instance===this)instance=null};executor.shutdown()} }
|
|
91
|
+
private fun reconcileCollection() {
|
|
92
|
+
val enabled=configurationReady&&!stopRequested.get()&&!destroyed&&!refused&&(collectionMode=="automatic"||(collectionMode=="consent"&&consentState=="granted"))
|
|
93
|
+
setCollecting(enabled)
|
|
94
|
+
}
|
|
95
|
+
init { executor.execute{configureCollection()};executor.scheduleWithFixedDelay({ if(consent) { if(foreground) enqueue("fr_session","session",JSONObject(),JSONObject().put("active_ms",15000)); deliver() } },15,15,TimeUnit.SECONDS) }
|
|
96
|
+
fun setConsent(granted: Boolean) { stopRequested.set(!granted);if(!granted){permitted.set(false);activeConnection?.disconnect()};executor.execute{consentState=if(granted)"granted" else "denied";saveRefusal(!granted);reconcileCollection()} }
|
|
97
|
+
private fun setCollecting(requested: Boolean) { val granted=requested&&!stopRequested.get();permitted.set(granted); if(!granted)activeConnection?.disconnect();
|
|
98
|
+
if(!granted&&refused){file.delete();legacyFile.delete()}
|
|
99
|
+
if(consent==granted)return
|
|
59
100
|
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
|
|
61
|
-
|
|
101
|
+
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 }
|
|
102
|
+
val restoreFile=if(file.exists())file else legacyFile
|
|
103
|
+
try { if(restoreFile.exists()) { val saved=JSONObject(restoreFile.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
104
|
if(anonymousId==null)anonymousId=UUID.randomUUID().toString()
|
|
63
|
-
prune(); persist(); schedule(); deliver()
|
|
64
|
-
}
|
|
105
|
+
prune(); persist(); if(file.exists()&&legacyFile!=file)legacyFile.delete(); schedule(); deliver()
|
|
106
|
+
}
|
|
65
107
|
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
108
|
fun setAccount(id:String?) { executor.execute { if(consent)accountId=id } }
|
|
67
109
|
fun reset() { executor.execute { if(consent) {resetIdentity();persist()} } }
|
|
@@ -82,15 +124,15 @@ class FounderRouteAnalytics private constructor(private val context: Context, pr
|
|
|
82
124
|
while(consent&&permitted.get()&&events.isNotEmpty()&&System.currentTimeMillis()<deadline&&System.currentTimeMillis()>=retryAt)deliver()
|
|
83
125
|
!permitted.get()||events.isEmpty()
|
|
84
126
|
}).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)) } }
|
|
127
|
+
fun getDiagnostics(callback:(JSONObject)->Unit) { executor.execute { callback(JSONObject().put("consent",consentState=="granted").put("collectionMode",collectionMode?:JSONObject.NULL).put("consentState",consentState).put("optedOut",refused).put("collectionEnabled",consent&&permitted.get()).put("queued",events.size).put("dropped",dropped).put("rejected",rejected).put("acknowledged",acknowledged).put("anonymousId",anonymousId?:JSONObject.NULL).put("lastError",lastError?:JSONObject.NULL)) } }
|
|
86
128
|
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
129
|
private fun enqueue(name:String,kind:String,properties:JSONObject,extra:JSONObject=JSONObject(),outcomeId:String?=null) {
|
|
88
130
|
if(!consent||!permitted.get()||anonymousId==null)return
|
|
89
131
|
val now=System.currentTimeMillis();if(now-lastActivity>=1800000)session=UUID.randomUUID().toString();lastActivity=now
|
|
90
|
-
val ctx=JSONObject().put("sdk","android").put("sdk_version","
|
|
132
|
+
val ctx=JSONObject().put("sdk","android").put("sdk_version","1.0.0-rc.2").put("app_id",appId);extra.keys().forEach { ctx.put(it,extra.get(it)) }
|
|
91
133
|
verificationId?.let{ctx.put("verification_id",it)}
|
|
92
134
|
campaign.keys().forEach { ctx.put(it,campaign.get(it)) }
|
|
93
|
-
val event=JSONObject().put("event_id",UUID.randomUUID().toString()).put("protocol",
|
|
135
|
+
val event=JSONObject().put("event_id",UUID.randomUUID().toString()).put("protocol",2).put("name",name).put("kind",kind).put("occurred_at",timestamp(now)).put("anonymous_id",anonymousId).put("session_id",session).put("collection_mode",collectionMode).put("consent_state",if(consentState=="granted")"granted" else "not_provided").put("properties",sanitize(properties,allowedProperties)).put("traits",sanitize(traits,allowedTraits)).put("context",ctx)
|
|
94
136
|
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
137
|
if(event.toString().toByteArray().size>8192){rejected++;lastError="event_too_large";return}
|
|
96
138
|
events.add(event);prune();persist();schedule()
|
|
@@ -100,14 +142,14 @@ class FounderRouteAnalytics private constructor(private val context: Context, pr
|
|
|
100
142
|
events.removeAll { try { timestampMillis(it.getString("occurred_at"))<cutoff } catch(_:Exception){true} };dropped+=size-events.size
|
|
101
143
|
while(events.size>10000||JSONArray(events).toString().toByteArray().size>10*1024*1024){events.removeAt(0);dropped++}
|
|
102
144
|
}
|
|
103
|
-
private fun persist() { if(!consent||!permitted.get())return;try { val temp=File(file.path+".tmp");temp.writeText(JSONObject().put("
|
|
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) }
|
|
145
|
+
private fun persist() { if(!consent||!permitted.get())return;try { val temp=File(file.path+".tmp");temp.writeText(JSONObject().put("collection_enabled",true).put("consent_state",consentState).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"} }
|
|
146
|
+
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,"collectionMode" to collectionMode,"propertyId" to propertyId,"environment" to environment)).setConstraints(constraints).setBackoffCriteria(BackoffPolicy.EXPONENTIAL,30,TimeUnit.SECONDS).build();WorkManager.getInstance(context).enqueueUniqueWork("founderroute-delivery",ExistingWorkPolicy.KEEP,request) }
|
|
105
147
|
private fun deliver() {
|
|
106
148
|
if(!consent||!permitted.get()||System.currentTimeMillis()<retryAt)return
|
|
107
149
|
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
150
|
var connection:HttpURLConnection?=null
|
|
109
151
|
try {
|
|
110
|
-
connection=connectionFactory(URL("$endpoint/api/analytics/
|
|
152
|
+
connection=connectionFactory(URL("$endpoint/api/analytics/v2/collect"));activeConnection=connection;connection.requestMethod="POST";connection.setRequestProperty("Content-Type","application/json");connection.doOutput=true;connection.connectTimeout=10000;connection.readTimeout=15000
|
|
111
153
|
if(!permitted.get())return
|
|
112
154
|
connection.outputStream.use{it.write(JSONObject().put("key",key).put("events",JSONArray(batch)).toString().toByteArray())}
|
|
113
155
|
val status=connection.responseCode
|
|
@@ -115,6 +157,7 @@ class FounderRouteAnalytics private constructor(private val context: Context, pr
|
|
|
115
157
|
if(status==429||status>=500){retry("delivery_$status");return}
|
|
116
158
|
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
159
|
val response=JSONObject(connection.inputStream.bufferedReader().use{it.readText()});val results=response.optJSONArray("results")?:JSONArray();val ids=mutableSetOf<String>()
|
|
160
|
+
if(!permitted.get())return
|
|
118
161
|
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
162
|
events.removeAll{it.getString("event_id") in ids};failures=0;retryAt=0;persist()
|
|
120
163
|
}catch(_:Exception){retry("network_unavailable")}finally{connection?.disconnect();activeConnection=null}
|
package/android/src/main/java/com/founderroute/analytics/reactnative/FounderRouteAnalyticsModule.kt
CHANGED
|
@@ -8,7 +8,11 @@ import org.json.JSONObject
|
|
|
8
8
|
class FounderRouteAnalyticsModule(private val context:ReactApplicationContext):ReactContextBaseJavaModule(context) {
|
|
9
9
|
override fun getName()="FounderRouteAnalyticsModule"
|
|
10
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) }
|
|
11
|
+
@ReactMethod fun configure(key:String,endpoint:String,appId:String,properties:ReadableArray,traits:ReadableArray,verificationId:String?,collectionMode:String?,propertyId:String?,environment:String?) { client=FounderRouteAnalytics.init(context,key,endpoint,appId,properties.toArrayList().map{it.toString()}.toSet(),traits.toArrayList().map{it.toString()}.toSet(),verificationId,collectionMode,propertyId,environment) }
|
|
12
|
+
@ReactMethod fun optOut(){client?.optOut()}
|
|
13
|
+
@ReactMethod fun optIn(){client?.optIn()}
|
|
14
|
+
@ReactMethod fun setCollectionMode(mode:String){client?.setCollectionMode(mode)}
|
|
15
|
+
@ReactMethod fun destroy(){client?.destroy();client=null}
|
|
12
16
|
@ReactMethod fun setConsent(value:Boolean){client?.setConsent(value)}
|
|
13
17
|
@ReactMethod fun identify(id:String,token:String?,traits:ReadableMap){client?.identify(id,token,JSONObject(traits.toHashMap()))}
|
|
14
18
|
@ReactMethod fun setAccount(id:String?){client?.setAccount(id)}
|
package/bridge.js
CHANGED
|
@@ -3,8 +3,12 @@ export function createBridge(native, platform, options) {
|
|
|
3
3
|
if (!native) throw new Error("Link FounderRoute's native module and rebuild the application.");
|
|
4
4
|
const config = {...options, ...options[platform]};
|
|
5
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);
|
|
6
|
+
native.configure(config.key, config.endpoint, config.appId, config.allowedProperties ?? [], config.allowedTraits ?? [],config.verificationId??null, config.collectionMode??null, config.propertyId??null, config.environment??null);
|
|
7
7
|
return {
|
|
8
|
+
optOut: () => native.optOut(),
|
|
9
|
+
optIn: () => native.optIn(),
|
|
10
|
+
setCollectionMode: mode => native.setCollectionMode(mode),
|
|
11
|
+
destroy: () => native.destroy(),
|
|
8
12
|
setConsent: granted => native.setConsent(Boolean(granted)),
|
|
9
13
|
identify: (id, {token, traits = {}} = {}) => native.identify(id, token ?? null, traits),
|
|
10
14
|
setAccount: id => native.setAccount(id ?? null),
|
package/index.d.ts
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
|
-
export interface
|
|
1
|
+
export interface PropertyOptions {key:string;appId:string;propertyId?:string;environment?:"production"|"test";collectionMode?:"automatic"|"consent"}
|
|
2
|
+
export interface Options { endpoint: string; verificationId?:string; collectionMode?:"automatic"|"consent"; ios: PropertyOptions; android: PropertyOptions; allowedProperties?:string[]; allowedTraits?:string[] }
|
|
2
3
|
export interface Client {
|
|
4
|
+
optOut():void; optIn():void; destroy():void; setCollectionMode(mode:"automatic"|"consent"):void;
|
|
3
5
|
setConsent(granted:boolean):void;
|
|
4
6
|
identify(id:string,options?:{token?:string;traits?:Record<string,string|number|boolean|null>}):void;
|
|
5
7
|
setAccount(id:string|null):void;
|
|
6
8
|
track(name:string,properties?:Record<string,string|number|boolean|null>,options?:{outcomeId?:string}):void;
|
|
7
9
|
screen(name:string):void; reset():void; flush():void;
|
|
8
10
|
setCampaignContext(url:string):void;
|
|
9
|
-
getDiagnostics():Promise<{consent:boolean;queued:number;dropped:number;anonymousId:string|null}>;
|
|
11
|
+
getDiagnostics():Promise<{consent:boolean;collectionMode:"automatic"|"consent"|null;consentState:"not_provided"|"granted"|"denied";optedOut:boolean;collectionEnabled:boolean;queued:number;dropped:number;anonymousId:string|null}>;
|
|
10
12
|
}
|
|
11
13
|
export function init(options:Options):Client;
|
|
12
14
|
export function navigationAdapter(client:Client,ref:{current?:{getCurrentRoute():{name:string}|undefined}|null}):()=>void;
|
package/index.js
CHANGED
|
@@ -2,7 +2,10 @@ import { NativeModules, Platform } from "react-native";
|
|
|
2
2
|
import { createBridge } from "./bridge.js";
|
|
3
3
|
let client;
|
|
4
4
|
export function init(options) {
|
|
5
|
-
|
|
5
|
+
if (!client) {
|
|
6
|
+
const bridge = createBridge(NativeModules.FounderRouteAnalyticsModule, Platform.OS, options);
|
|
7
|
+
client = {...bridge, destroy() { bridge.destroy(); client = undefined; }};
|
|
8
|
+
}
|
|
6
9
|
return client;
|
|
7
10
|
}
|
|
8
11
|
export function navigationAdapter(client, navigationRef) {
|
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
#import <React/RCTBridgeModule.h>
|
|
2
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)
|
|
3
|
+
RCT_EXTERN_METHOD(configure:(NSString *)key endpoint:(NSString *)endpoint appId:(NSString *)appId properties:(NSArray *)properties traits:(NSArray *)traits verificationId:(NSString *)verificationId collectionMode:(NSString *)collectionMode propertyId:(NSString *)propertyId environment:(NSString *)environment)
|
|
4
|
+
RCT_EXTERN_METHOD(optOut)
|
|
5
|
+
RCT_EXTERN_METHOD(optIn)
|
|
6
|
+
RCT_EXTERN_METHOD(destroy)
|
|
7
|
+
RCT_EXTERN_METHOD(setCollectionMode:(NSString *)mode)
|
|
4
8
|
RCT_EXTERN_METHOD(setConsent:(BOOL)value)
|
|
5
9
|
RCT_EXTERN_METHOD(identify:(NSString *)identifier token:(NSString *)token traits:(NSDictionary *)traits)
|
|
6
10
|
RCT_EXTERN_METHOD(setAccount:(NSString *)identifier)
|
|
@@ -5,7 +5,11 @@ import React
|
|
|
5
5
|
final class FounderRouteAnalyticsModule: NSObject {
|
|
6
6
|
private let analytics = FounderRouteAnalytics.shared
|
|
7
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) }
|
|
8
|
+
@objc func configure(_ key: String, endpoint: String, appId: String, properties: [String], traits: [String], verificationId: String?, collectionMode: String?, propertyId: String?, environment: String?) { analytics.configure(key: key, endpoint: endpoint, appId: appId, allowedProperties: properties, allowedTraits: traits, verificationId: verificationId, collectionMode: collectionMode, propertyId: propertyId, environment: environment) }
|
|
9
|
+
@objc func optOut() { analytics.optOut() }
|
|
10
|
+
@objc func optIn() { analytics.optIn() }
|
|
11
|
+
@objc func setCollectionMode(_ mode: String) { analytics.setCollectionMode(mode) }
|
|
12
|
+
@objc func destroy() { analytics.destroy() }
|
|
9
13
|
@objc func setConsent(_ value: Bool) { analytics.setConsent(value) }
|
|
10
14
|
@objc func identify(_ id: String, token: String?, traits: [String: Any]) { analytics.identify(id, token: token, traits: traits) }
|
|
11
15
|
@objc func setAccount(_ id: String?) { analytics.setAccount(id) }
|
|
@@ -9,6 +9,12 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
9
9
|
private var key = "", endpoint = "", appId = ""
|
|
10
10
|
private var verificationId: String?
|
|
11
11
|
private var campaign: [String:Any] = [:]
|
|
12
|
+
private var collectionMode: String?, propertyId: String?, environment: String?
|
|
13
|
+
private var consentState = "not_provided", refused = false, configurationReady = false, destroyed = false
|
|
14
|
+
private let permissionLock = NSLock()
|
|
15
|
+
private var stopRequested = false
|
|
16
|
+
private func requestStop(_ stop: Bool) { permissionLock.lock(); stopRequested = stop; permissionLock.unlock() }
|
|
17
|
+
private var stopped: Bool { permissionLock.lock(); defer { permissionLock.unlock() }; return stopRequested }
|
|
12
18
|
private var consent = false, sending = false, foreground = true
|
|
13
19
|
private var anonymousId: String?, userId: String?, accountId: String?, identityToken: String?
|
|
14
20
|
private var traits: [String: Any] = [:], allowedTraits: Set<String> = [], allowedProperties: Set<String> = []
|
|
@@ -21,12 +27,19 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
21
27
|
private let transport: URLSession
|
|
22
28
|
public init(transport: URLSession = .shared) { self.transport = transport }
|
|
23
29
|
|
|
24
|
-
public func configure(key: String, endpoint: String, appId: String, allowedProperties: [String] = [], allowedTraits: [String] = [], verificationId: String? = nil) {
|
|
30
|
+
public func configure(key: String, endpoint: String, appId: String, allowedProperties: [String] = [], allowedTraits: [String] = [], verificationId: String? = nil, collectionMode: String? = nil, propertyId: String? = nil, environment: String? = nil) {
|
|
25
31
|
work.async {
|
|
26
|
-
guard self.key.isEmpty else { return }
|
|
32
|
+
guard self.key.isEmpty || self.destroyed else { return }
|
|
33
|
+
if self.destroyed {
|
|
34
|
+
self.destroyed = false; self.configurationReady = false; self.refused = false
|
|
35
|
+
self.consentState = "not_provided"; self.events = []; self.anonymousId = nil
|
|
36
|
+
self.userId = nil; self.accountId = nil; self.identityToken = nil; self.traits = [:]
|
|
37
|
+
self.sending = false; self.requestStop(false)
|
|
38
|
+
}
|
|
27
39
|
guard key.hasPrefix("fr_pk_"), let url = URL(string: endpoint), url.scheme == "https" || url.host == "localhost" else { self.lastError = "invalid_configuration"; return }
|
|
28
40
|
self.key = key; self.endpoint = endpoint.trimmingCharacters(in: CharacterSet(charactersIn: "/")); self.appId = appId
|
|
29
41
|
self.verificationId = verificationId
|
|
42
|
+
self.propertyId = propertyId; self.environment = environment; self.collectionMode = collectionMode
|
|
30
43
|
self.allowedProperties = Set(allowedProperties); self.allowedTraits = Set(allowedTraits)
|
|
31
44
|
#if canImport(UIKit)
|
|
32
45
|
self.observers.append(NotificationCenter.default.addObserver(forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil) { [weak self] _ in
|
|
@@ -36,27 +49,83 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
36
49
|
self?.work.async { self?.foreground = true; self?.retryAt = .distantPast; self?.deliver() }
|
|
37
50
|
})
|
|
38
51
|
#endif
|
|
52
|
+
if collectionMode != nil && propertyId != nil && environment != nil { self.finishConfiguration() }
|
|
53
|
+
else {
|
|
54
|
+
var components = URLComponents(string: self.endpoint + "/api/analytics/v2/config")!
|
|
55
|
+
components.queryItems = [URLQueryItem(name: "key", value: key)]
|
|
56
|
+
let configurationGeneration = self.generation
|
|
57
|
+
self.transport.dataTask(with: components.url!) { data, response, _ in
|
|
58
|
+
self.work.async {
|
|
59
|
+
guard !self.destroyed, self.generation == configurationGeneration, let response = response as? HTTPURLResponse, response.statusCode == 200,
|
|
60
|
+
let data, let config = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] else { self.lastError = "configuration_unavailable"; return }
|
|
61
|
+
self.propertyId = config["property_id"] as? String; self.environment = config["environment"] as? String
|
|
62
|
+
self.collectionMode = collectionMode ?? config["collection_mode"] as? String
|
|
63
|
+
self.finishConfiguration()
|
|
64
|
+
}
|
|
65
|
+
}.resume()
|
|
66
|
+
}
|
|
39
67
|
}
|
|
40
68
|
}
|
|
69
|
+
private var scope: String { if let propertyId, let environment { return propertyId + "-" + environment }; return String(key.suffix(16)) }
|
|
70
|
+
private var refusalURL: URL? { fileURL?.appendingPathExtension("refusal") }
|
|
71
|
+
private func finishConfiguration() {
|
|
72
|
+
guard let propertyId, propertyId.range(of: "^[a-zA-Z0-9_-]+$", options: .regularExpression) != nil,
|
|
73
|
+
["production", "test"].contains(environment ?? ""), ["automatic", "consent"].contains(collectionMode ?? "") else { lastError = "configuration_unavailable"; return }
|
|
74
|
+
if let refusalURL { refused = refused || FileManager.default.fileExists(atPath: refusalURL.path) }
|
|
75
|
+
if refused { saveRefusal(true) }
|
|
76
|
+
configurationReady = true; reconcileCollection()
|
|
77
|
+
}
|
|
78
|
+
private func saveRefusal(_ value: Bool) {
|
|
79
|
+
refused = value
|
|
80
|
+
do {
|
|
81
|
+
guard let url = refusalURL else { throw CocoaError(.fileNoSuchFile) }
|
|
82
|
+
if value {
|
|
83
|
+
try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
84
|
+
try Data("refused".utf8).write(to: url, options: .atomic)
|
|
85
|
+
} else if FileManager.default.fileExists(atPath: url.path) { try FileManager.default.removeItem(at: url) }
|
|
86
|
+
} catch { lastError = "preference_storage_unavailable" }
|
|
87
|
+
}
|
|
88
|
+
public func optOut() { requestStop(true); work.async { self.consentState = "denied"; self.saveRefusal(true); self.reconcileCollection() } }
|
|
89
|
+
public func optIn() { requestStop(false); work.async { self.saveRefusal(false); if self.consentState == "denied" { self.consentState = "not_provided" }; self.reconcileCollection() } }
|
|
90
|
+
public func setCollectionMode(_ mode: String) { work.async { guard ["automatic", "consent"].contains(mode) else { self.lastError = "invalid_configuration"; return }; self.collectionMode = mode; self.reconcileCollection() } }
|
|
91
|
+
public func destroy() { requestStop(true); work.async { self.destroyed = true; self.consent = false; self.generation += 1; self.task?.cancel(); self.timer?.cancel(); self.timer = nil; self.observers.forEach { NotificationCenter.default.removeObserver($0) }; self.observers = [] } }
|
|
92
|
+
private func reconcileCollection() {
|
|
93
|
+
setCollecting(configurationReady && !destroyed && !stopped && !refused && (collectionMode == "automatic" || (collectionMode == "consent" && consentState == "granted")))
|
|
94
|
+
}
|
|
41
95
|
private var fileURL: URL? {
|
|
96
|
+
guard !key.isEmpty else { return nil }
|
|
97
|
+
return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("founderroute-\(scope).json")
|
|
98
|
+
}
|
|
99
|
+
private var legacyFileURL: URL? {
|
|
42
100
|
guard !key.isEmpty else { return nil }
|
|
43
101
|
return FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask).first?.appendingPathComponent("founderroute-\(key.suffix(16)).json")
|
|
44
102
|
}
|
|
45
103
|
public func setConsent(_ granted: Bool) {
|
|
46
|
-
|
|
47
|
-
|
|
104
|
+
requestStop(!granted)
|
|
105
|
+
work.async { self.consentState = granted ? "granted" : "denied"; self.saveRefusal(!granted); self.reconcileCollection() }
|
|
106
|
+
}
|
|
107
|
+
private func setCollecting(_ requested: Bool) {
|
|
108
|
+
let granted = requested && !stopped
|
|
109
|
+
guard !self.key.isEmpty else { return }
|
|
110
|
+
if !granted && self.refused {
|
|
111
|
+
if let file = self.fileURL { try? FileManager.default.removeItem(at: file) }
|
|
112
|
+
if let file = self.legacyFileURL { try? FileManager.default.removeItem(at: file) }
|
|
113
|
+
}
|
|
114
|
+
guard granted != self.consent else { return }
|
|
48
115
|
self.consent = granted; self.generation += 1
|
|
49
116
|
if !granted {
|
|
50
|
-
self.task?.cancel(); self.timer?.cancel(); self.timer = nil; self.events = []; self.anonymousId = nil
|
|
117
|
+
self.task?.cancel(); self.sending = false; self.timer?.cancel(); self.timer = nil; self.events = []; self.anonymousId = nil
|
|
51
118
|
self.userId = nil; self.accountId = nil; self.identityToken = nil; self.traits = [:]; self.campaign = [:]
|
|
52
119
|
if let file = self.fileURL { try? FileManager.default.removeItem(at: file) }; return
|
|
53
120
|
}
|
|
54
|
-
|
|
121
|
+
let restoreFile = self.fileURL.flatMap { FileManager.default.fileExists(atPath: $0.path) ? $0 : self.legacyFileURL }
|
|
122
|
+
if let file = restoreFile, let data = try? Data(contentsOf: file), let saved = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
|
|
55
123
|
self.dropped = saved["dropped"] as? Int ?? 0
|
|
56
124
|
self.events = saved["events"] as? [[String: Any]] ?? []; self.anonymousId = saved["anonymous_id"] as? String
|
|
57
125
|
}
|
|
58
126
|
self.anonymousId = self.anonymousId ?? UUID().uuidString
|
|
59
127
|
self.prune(); self.persist()
|
|
128
|
+
if let legacy = self.legacyFileURL, legacy != self.fileURL, let current = self.fileURL, FileManager.default.fileExists(atPath: current.path) { try? FileManager.default.removeItem(at: legacy) }
|
|
60
129
|
let timer = DispatchSource.makeTimerSource(queue: self.work)
|
|
61
130
|
timer.schedule(deadline: .now() + 15, repeating: 15)
|
|
62
131
|
timer.setEventHandler { [weak self] in
|
|
@@ -65,24 +134,23 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
65
134
|
self.deliver()
|
|
66
135
|
}
|
|
67
136
|
self.timer = timer; timer.resume(); self.deliver()
|
|
68
|
-
}
|
|
69
137
|
}
|
|
70
138
|
public func identify(_ userId: String, token: String? = nil, traits: [String: Any] = [:]) {
|
|
71
139
|
work.async {
|
|
72
|
-
guard self.consent else { return }
|
|
140
|
+
guard self.consent, !self.stopped else { return }
|
|
73
141
|
if let old = self.userId, old != userId { self.resetIdentity() }
|
|
74
142
|
self.userId = userId; self.identityToken = token; self.traits = traits
|
|
75
143
|
self.enqueue("fr_identify", kind: "identify", properties: [:])
|
|
76
144
|
}
|
|
77
145
|
}
|
|
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() } } }
|
|
146
|
+
public func setAccount(_ id: String?) { work.async { if self.consent && !self.stopped { self.accountId = id } } }
|
|
147
|
+
public func reset() { work.async { if self.consent && !self.stopped { self.resetIdentity(); self.persist() } } }
|
|
80
148
|
private func resetIdentity() { anonymousId = UUID().uuidString; userId = nil; accountId = nil; identityToken = nil; traits = [:]; sessionId = UUID().uuidString; lastActivity = .distantPast }
|
|
81
149
|
public func track(_ name: String, properties: [String: Any] = [:], outcomeId: String? = nil) { work.async { self.enqueue(name, kind: "custom", properties: properties, outcomeId: outcomeId) } }
|
|
82
150
|
public func screen(_ name: String) { work.async { self.enqueue("screen_view", kind: "screen", properties: [:], context: ["screen": String(name.prefix(150))]) } }
|
|
83
151
|
/// Supply an installed-app deep link after consent; this does not infer app-store attribution.
|
|
84
152
|
public func setCampaignContext(_ url: String) { work.async {
|
|
85
|
-
guard self.consent, let items = URLComponents(string:url)?.queryItems else { return }
|
|
153
|
+
guard self.consent, !self.stopped, let items = URLComponents(string:url)?.queryItems else { return }
|
|
86
154
|
var next: [String:Any] = [:]
|
|
87
155
|
for item in items {
|
|
88
156
|
guard let value = item.value else { continue }
|
|
@@ -93,7 +161,7 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
93
161
|
} }
|
|
94
162
|
public func flush() { work.async { self.deliver() } }
|
|
95
163
|
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) }
|
|
164
|
+
work.async { let result: [String: Any] = ["consent": self.consentState == "granted", "collectionMode": self.collectionMode.map { $0 as Any } ?? NSNull(), "consentState": self.consentState, "optedOut": self.refused, "collectionEnabled": self.consent && !self.stopped, "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
165
|
}
|
|
98
166
|
private func sanitize(_ input: [String: Any], allowed: Set<String>) -> [String: Any] {
|
|
99
167
|
var result: [String: Any] = [:]
|
|
@@ -104,13 +172,13 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
104
172
|
return result
|
|
105
173
|
}
|
|
106
174
|
private func enqueue(_ name: String, kind: String, properties: [String: Any], context: [String: Any] = [:], outcomeId: String? = nil) {
|
|
107
|
-
guard consent, let anonymousId else { return }
|
|
175
|
+
guard consent, !stopped, let anonymousId else { return }
|
|
108
176
|
let now = Date(); if now.timeIntervalSince(lastActivity) >= 1800 { sessionId = UUID().uuidString }; lastActivity = now
|
|
109
|
-
var ctx: [String: Any] = ["sdk": "ios", "sdk_version": "
|
|
177
|
+
var ctx: [String: Any] = ["sdk": "ios", "sdk_version": "1.0.0-rc.2", "app_id": appId]
|
|
110
178
|
ctx["verification_id"] = verificationId
|
|
111
179
|
for (key, value) in campaign { ctx[key] = value }
|
|
112
180
|
for (key, value) in context { ctx[key] = value }
|
|
113
|
-
var event: [String: Any] = ["event_id": UUID().uuidString, "protocol":
|
|
181
|
+
var event: [String: Any] = ["event_id": UUID().uuidString, "protocol": 2, "name": name, "kind": kind, "occurred_at": ISO8601DateFormatter().string(from: now), "anonymous_id": anonymousId, "session_id": sessionId, "collection_mode": collectionMode ?? "consent", "consent_state": consentState == "granted" ? "granted" : "not_provided", "properties": sanitize(properties, allowed: allowedProperties), "traits": sanitize(traits, allowed: allowedTraits), "context": ctx]
|
|
114
182
|
event["user_id"] = userId; event["identity_token"] = identityToken; event["account_id"] = accountId; event["outcome_id"] = outcomeId
|
|
115
183
|
guard let bytes = try? JSONSerialization.data(withJSONObject: event), bytes.count <= 8192 else { rejected += 1; lastError = "event_too_large"; return }
|
|
116
184
|
events.append(event); prune(); persist()
|
|
@@ -122,7 +190,7 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
122
190
|
while events.count > 10000 || ((try? JSONSerialization.data(withJSONObject: events).count) ?? 0) > 10 * 1024 * 1024 { events.removeFirst(); dropped += 1 }
|
|
123
191
|
}
|
|
124
192
|
private func persist() {
|
|
125
|
-
guard consent, let file = fileURL else { return }
|
|
193
|
+
guard consent, !stopped, let file = fileURL else { return }
|
|
126
194
|
do {
|
|
127
195
|
try FileManager.default.createDirectory(at: file.deletingLastPathComponent(), withIntermediateDirectories: true)
|
|
128
196
|
let data = try JSONSerialization.data(withJSONObject: ["anonymous_id": anonymousId ?? "", "events": events, "dropped": dropped])
|
|
@@ -131,7 +199,7 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
131
199
|
} catch { lastError = "storage_unavailable" }
|
|
132
200
|
}
|
|
133
201
|
private func deliver() {
|
|
134
|
-
guard consent, !sending, Date() >= retryAt, let url = URL(string: endpoint + "/api/analytics/
|
|
202
|
+
guard consent, !stopped, !sending, Date() >= retryAt, let url = URL(string: endpoint + "/api/analytics/v2/collect") else { return }
|
|
135
203
|
prune(); var batch: [[String: Any]] = []
|
|
136
204
|
for event in events.prefix(50) {
|
|
137
205
|
guard let data = try? JSONSerialization.data(withJSONObject: ["key": key, "events": batch + [event]]), data.count <= 65536 else { break }; batch.append(event)
|
|
@@ -142,7 +210,7 @@ public final class FounderRouteAnalytics: @unchecked Sendable {
|
|
|
142
210
|
task = transport.dataTask(with: request) { [weak self] data, response, error in
|
|
143
211
|
guard let self else { return }
|
|
144
212
|
self.work.async {
|
|
145
|
-
self.sending = false; guard self.consent, self.
|
|
213
|
+
guard self.generation == currentGeneration else { return }; self.sending = false; guard self.consent, !self.stopped else { return }
|
|
146
214
|
guard error == nil, let response = response as? HTTPURLResponse else { self.retry("network_unavailable"); return }
|
|
147
215
|
let status = response.statusCode
|
|
148
216
|
if status == 429 || status >= 500 { self.retry("delivery_\(status)"); return }
|
package/package.json
CHANGED
|
@@ -1,15 +1,35 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@founderroute/analytics-react-native",
|
|
3
|
-
"version": "
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.0.0-rc.2",
|
|
4
|
+
"description": "FounderRoute analytics for React Native",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "index.js",
|
|
8
8
|
"types": "index.d.ts",
|
|
9
|
-
"files": [
|
|
10
|
-
|
|
11
|
-
|
|
9
|
+
"files": [
|
|
10
|
+
"*.js",
|
|
11
|
+
"*.cjs",
|
|
12
|
+
"*.d.ts",
|
|
13
|
+
"*.podspec",
|
|
14
|
+
"android",
|
|
15
|
+
"ios",
|
|
16
|
+
"LICENSE"
|
|
17
|
+
],
|
|
18
|
+
"peerDependencies": {
|
|
19
|
+
"react": ">=18 <20",
|
|
20
|
+
"react-native": ">=0.76 <1"
|
|
21
|
+
},
|
|
22
|
+
"repository": {
|
|
23
|
+
"type": "git",
|
|
24
|
+
"url": "git+https://github.com/itsreed/founderroute-analytics.git",
|
|
25
|
+
"directory": "packages/react-native"
|
|
26
|
+
},
|
|
12
27
|
"homepage": "https://github.com/itsreed/founderroute-analytics#readme",
|
|
13
|
-
"bugs": {
|
|
14
|
-
|
|
28
|
+
"bugs": {
|
|
29
|
+
"url": "https://github.com/itsreed/founderroute-analytics/issues"
|
|
30
|
+
},
|
|
31
|
+
"publishConfig": {
|
|
32
|
+
"access": "public",
|
|
33
|
+
"provenance": true
|
|
34
|
+
}
|
|
15
35
|
}
|