@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.
@@ -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 consent-first FounderRoute Analytics for React 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 file=File(context.noBackupFilesDir,"founderroute-${key.takeLast(16)}.json")
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{it.setConsent(true)}
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 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
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@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" }
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","0.1.0-beta.1").put("app_id",appId);extra.keys().forEach { ctx.put(it,extra.get(it)) }
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",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)
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("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) }
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/v1/collect"));activeConnection=connection;connection.requestMethod="POST";connection.setRequestProperty("Content-Type","application/json");connection.doOutput=true;connection.connectTimeout=10000;connection.readTimeout=15000
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}
@@ -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 Options { endpoint: string; verificationId?:string; ios: {key:string;appId:string}; android: {key:string;appId:string}; allowedProperties?:string[]; allowedTraits?:string[] }
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
- client ??= createBridge(NativeModules.FounderRouteAnalyticsModule, Platform.OS, options);
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
- work.async {
47
- guard granted != self.consent, !self.key.isEmpty else { return }
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
- if let file = self.fileURL, let data = try? Data(contentsOf: file), let saved = (try? JSONSerialization.jsonObject(with: data)) as? [String: Any] {
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": "0.1.0-beta.1", "app_id": appId]
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": 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]
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/v1/collect") else { return }
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.generation == currentGeneration else { return }
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": "0.1.0-beta.1",
4
- "description": "Consent-first FounderRoute analytics for React Native",
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": ["*.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" },
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": { "url": "https://github.com/itsreed/founderroute-analytics/issues" },
14
- "publishConfig": { "access": "public", "provenance": true }
28
+ "bugs": {
29
+ "url": "https://github.com/itsreed/founderroute-analytics/issues"
30
+ },
31
+ "publishConfig": {
32
+ "access": "public",
33
+ "provenance": true
34
+ }
15
35
  }