@apex-inc/capacitor-plugin 0.3.9 → 2.2.0
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/README.md +5 -5
- package/android/build.gradle +4 -2
- package/android/src/main/AndroidManifest.xml +35 -3
- package/android/src/main/java/inc/apex/capacitor/ApexCapacitorPlugin.kt +404 -7
- package/android/src/main/java/inc/apex/capacitor/ApexEvents.kt +122 -0
- package/android/src/main/java/inc/apex/capacitor/ApexFirebaseMessagingService.kt +99 -0
- package/android/src/main/java/inc/apex/capacitor/NativeBatchSender.kt +260 -0
- package/dist/batch-sender.d.ts +16 -4
- package/dist/batch-sender.d.ts.map +1 -1
- package/dist/batch-sender.js +29 -4
- package/dist/batch-sender.js.map +1 -1
- package/dist/cart-helpers.d.ts +63 -0
- package/dist/cart-helpers.d.ts.map +1 -0
- package/dist/cart-helpers.js +50 -0
- package/dist/cart-helpers.js.map +1 -0
- package/dist/definitions.d.ts +150 -5
- package/dist/definitions.d.ts.map +1 -1
- package/dist/esm/batch-sender.d.ts +16 -4
- package/dist/esm/batch-sender.d.ts.map +1 -1
- package/dist/esm/batch-sender.js +29 -4
- package/dist/esm/batch-sender.js.map +1 -1
- package/dist/esm/cart-helpers.d.ts +63 -0
- package/dist/esm/cart-helpers.d.ts.map +1 -0
- package/dist/esm/cart-helpers.js +44 -0
- package/dist/esm/cart-helpers.js.map +1 -0
- package/dist/esm/definitions.d.ts +150 -5
- package/dist/esm/definitions.d.ts.map +1 -1
- package/dist/esm/events.d.ts +85 -0
- package/dist/esm/events.d.ts.map +1 -0
- package/dist/esm/events.js +96 -0
- package/dist/esm/events.js.map +1 -0
- package/dist/esm/index.d.ts +5 -6
- package/dist/esm/index.d.ts.map +1 -1
- package/dist/esm/index.js +20 -2
- package/dist/esm/index.js.map +1 -1
- package/dist/esm/offline-queue.d.ts +15 -0
- package/dist/esm/offline-queue.d.ts.map +1 -1
- package/dist/esm/offline-queue.js +35 -0
- package/dist/esm/offline-queue.js.map +1 -1
- package/dist/esm/screen-view.d.ts +18 -0
- package/dist/esm/screen-view.d.ts.map +1 -0
- package/dist/esm/screen-view.js +28 -0
- package/dist/esm/screen-view.js.map +1 -0
- package/dist/esm/web.d.ts +29 -1
- package/dist/esm/web.d.ts.map +1 -1
- package/dist/esm/web.js +167 -2
- package/dist/esm/web.js.map +1 -1
- package/dist/events.d.ts +85 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +99 -0
- package/dist/events.js.map +1 -0
- package/dist/index.d.ts +5 -6
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +28 -3
- package/dist/index.js.map +1 -1
- package/dist/offline-queue.d.ts +15 -0
- package/dist/offline-queue.d.ts.map +1 -1
- package/dist/offline-queue.js +35 -0
- package/dist/offline-queue.js.map +1 -1
- package/dist/screen-view.d.ts +18 -0
- package/dist/screen-view.d.ts.map +1 -0
- package/dist/screen-view.js +31 -0
- package/dist/screen-view.js.map +1 -0
- package/dist/web.d.ts +29 -1
- package/dist/web.d.ts.map +1 -1
- package/dist/web.js +167 -2
- package/dist/web.js.map +1 -1
- package/ios/Sources/ApexCapacitorPlugin/ApexEvents.swift +124 -0
- package/ios/Sources/ApexCapacitorPlugin/BatchSender.swift +24 -6
- package/ios/Sources/ApexCapacitorPlugin/OfflineQueue.swift +19 -0
- package/ios/Sources/ApexCapacitorPlugin/PushNotificationManager.swift +47 -0
- package/ios/Sources/ApexCapacitorPluginBridge/ApexCapacitorPlugin.swift +288 -28
- package/package.json +1 -1
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Apex Spec — canonical event name constants for the Android SDK.
|
|
3
|
+
*
|
|
4
|
+
* Hand-authored from `app/src/lib/core/apex-spec/events.ts`. Kept in sync
|
|
5
|
+
* via the parity test at
|
|
6
|
+
* `app/src/lib/core/apex-spec/__tests__/sdk-parity.test.ts` — CI fails if a
|
|
7
|
+
* canonical name is added/renamed/removed in the registry without a matching
|
|
8
|
+
* update here.
|
|
9
|
+
*
|
|
10
|
+
* Usage from Kotlin:
|
|
11
|
+
*
|
|
12
|
+
* plugin.track(type = ApexEvents.ADD_TO_CART, data = mapOf("product_id" to "sku_123"))
|
|
13
|
+
*
|
|
14
|
+
* Each constant is the unversioned canonical name as the developer writes it
|
|
15
|
+
* in `track()` calls. Versions are an internal storage primitive; the SDK
|
|
16
|
+
* never carries a version field.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
package inc.apex.capacitor
|
|
20
|
+
|
|
21
|
+
object ApexEvents {
|
|
22
|
+
// ─── Commerce ───────────────────────────────────────────────────────
|
|
23
|
+
const val ADD_TO_CART = "add_to_cart"
|
|
24
|
+
const val REMOVE_FROM_CART = "remove_from_cart"
|
|
25
|
+
const val PRODUCT_VIEW = "product_view"
|
|
26
|
+
const val ADD_TO_WISHLIST = "add_to_wishlist"
|
|
27
|
+
const val CHECKOUT_STARTED = "checkout_started"
|
|
28
|
+
const val IN_APP_PURCHASE = "in_app_purchase"
|
|
29
|
+
const val PURCHASE_REFUNDED = "purchase_refunded"
|
|
30
|
+
const val SUBSCRIPTION_EVENT = "subscription_event"
|
|
31
|
+
const val ORDER_PLACED = "order_placed"
|
|
32
|
+
const val FIRST_SALE_COMPLETED = "first_sale_completed"
|
|
33
|
+
|
|
34
|
+
// ─── Marketing (merchant-fired triggers) ────────────────────────────
|
|
35
|
+
const val PRICE_DROPPED = "price_dropped"
|
|
36
|
+
const val RESTOCK_DUE = "restock_due"
|
|
37
|
+
const val REVIEW_REQUEST_DUE = "review_request_due"
|
|
38
|
+
|
|
39
|
+
// ─── Identity ───────────────────────────────────────────────────────
|
|
40
|
+
const val USER_SIGNED_UP = "user_signed_up"
|
|
41
|
+
const val USER_SIGNED_IN = "user_signed_in"
|
|
42
|
+
const val USER_SIGNED_OUT = "user_signed_out"
|
|
43
|
+
const val USER_IDENTIFIED = "user_identified"
|
|
44
|
+
|
|
45
|
+
// ─── Lifecycle ──────────────────────────────────────────────────────
|
|
46
|
+
const val APP_INSTALL = "app_install"
|
|
47
|
+
const val APP_OPEN = "app_open"
|
|
48
|
+
const val APP_BACKGROUND = "app_background"
|
|
49
|
+
const val APP_UNINSTALL = "app_uninstall"
|
|
50
|
+
const val APP_REINSTALL = "app_reinstall"
|
|
51
|
+
|
|
52
|
+
// ─── Session ────────────────────────────────────────────────────────
|
|
53
|
+
const val SESSION_START = "session_start"
|
|
54
|
+
const val SESSION_END = "session_end"
|
|
55
|
+
|
|
56
|
+
// ─── Engagement ─────────────────────────────────────────────────────
|
|
57
|
+
const val PAGE_VIEW = "page_view"
|
|
58
|
+
const val PAGEVIEW = "pageview"
|
|
59
|
+
const val SCREEN_VIEW = "screen_view"
|
|
60
|
+
const val SEARCH = "search"
|
|
61
|
+
const val SHARE = "share"
|
|
62
|
+
const val CONTENT_VIEW = "content_view"
|
|
63
|
+
const val FORM_SUBMIT = "form_submit"
|
|
64
|
+
const val CLICK = "click"
|
|
65
|
+
const val ENGAGEMENT = "engagement"
|
|
66
|
+
const val HEARTBEAT = "heartbeat"
|
|
67
|
+
const val RAGE_CLICK = "rage_click"
|
|
68
|
+
const val DEAD_CLICK = "dead_click"
|
|
69
|
+
const val GOAL_CONVERSION = "goal_conversion"
|
|
70
|
+
const val ONBOARDING_STEP_COMPLETED = "onboarding_step_completed"
|
|
71
|
+
|
|
72
|
+
// ─── Product (PLG lifecycle outcomes) ───────────────────────────────
|
|
73
|
+
const val ONBOARDING_COMPLETED = "onboarding_completed"
|
|
74
|
+
const val ACTIVATED = "activated"
|
|
75
|
+
|
|
76
|
+
// ─── Communication ──────────────────────────────────────────────────
|
|
77
|
+
const val PUSH_RECEIVED = "push_received"
|
|
78
|
+
const val PUSH_OPENED = "push_opened"
|
|
79
|
+
const val EMAIL_OPENED = "email_opened"
|
|
80
|
+
const val EMAIL_CLICKED = "email_clicked"
|
|
81
|
+
const val IN_APP_MESSAGE_SEEN = "in_app_message_seen"
|
|
82
|
+
const val IN_APP_MESSAGE_CLICKED = "in_app_message_clicked"
|
|
83
|
+
|
|
84
|
+
// ─── Deep link ──────────────────────────────────────────────────────
|
|
85
|
+
const val DEEP_LINK_OPEN = "deep_link_open"
|
|
86
|
+
|
|
87
|
+
// ─── Attribution ────────────────────────────────────────────────────
|
|
88
|
+
const val REATTRIBUTION = "reattribution"
|
|
89
|
+
const val REENGAGEMENT = "reengagement"
|
|
90
|
+
|
|
91
|
+
// ─── Smart banner ───────────────────────────────────────────────────
|
|
92
|
+
const val SMART_BANNER_IMPRESSION = "smart_banner_impression"
|
|
93
|
+
const val SMART_BANNER_CLICK = "smart_banner_click"
|
|
94
|
+
const val SMART_BANNER_DISMISS = "smart_banner_dismiss"
|
|
95
|
+
|
|
96
|
+
// ─── System ─────────────────────────────────────────────────────────
|
|
97
|
+
const val CUSTOM = "custom"
|
|
98
|
+
const val LIFECYCLE_TRANSITION = "lifecycle_transition"
|
|
99
|
+
const val JS_ERROR = "js_error"
|
|
100
|
+
|
|
101
|
+
/** Apex Spec semver this SDK implements. */
|
|
102
|
+
const val SPEC_VERSION = "1.1.0"
|
|
103
|
+
|
|
104
|
+
/** Complete list of every canonical event name. */
|
|
105
|
+
val ALL: List<String> = listOf(
|
|
106
|
+
ADD_TO_CART, REMOVE_FROM_CART, PRODUCT_VIEW, ADD_TO_WISHLIST, CHECKOUT_STARTED,
|
|
107
|
+
IN_APP_PURCHASE, PURCHASE_REFUNDED, SUBSCRIPTION_EVENT,
|
|
108
|
+
USER_SIGNED_UP, USER_SIGNED_IN, USER_SIGNED_OUT, USER_IDENTIFIED,
|
|
109
|
+
APP_INSTALL, APP_OPEN, APP_BACKGROUND, APP_UNINSTALL, APP_REINSTALL,
|
|
110
|
+
SESSION_START, SESSION_END,
|
|
111
|
+
PAGE_VIEW, PAGEVIEW, SCREEN_VIEW, SEARCH, SHARE, CONTENT_VIEW, FORM_SUBMIT,
|
|
112
|
+
CLICK, ENGAGEMENT, HEARTBEAT, RAGE_CLICK, DEAD_CLICK, GOAL_CONVERSION,
|
|
113
|
+
ONBOARDING_STEP_COMPLETED,
|
|
114
|
+
ONBOARDING_COMPLETED, ACTIVATED,
|
|
115
|
+
PUSH_RECEIVED, PUSH_OPENED, EMAIL_OPENED, EMAIL_CLICKED,
|
|
116
|
+
IN_APP_MESSAGE_SEEN, IN_APP_MESSAGE_CLICKED,
|
|
117
|
+
DEEP_LINK_OPEN,
|
|
118
|
+
REATTRIBUTION, REENGAGEMENT,
|
|
119
|
+
SMART_BANNER_IMPRESSION, SMART_BANNER_CLICK, SMART_BANNER_DISMISS,
|
|
120
|
+
CUSTOM, LIFECYCLE_TRANSITION, JS_ERROR,
|
|
121
|
+
)
|
|
122
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
import android.app.NotificationChannel
|
|
4
|
+
import android.app.NotificationManager
|
|
5
|
+
import android.app.PendingIntent
|
|
6
|
+
import android.content.Context
|
|
7
|
+
import android.content.Intent
|
|
8
|
+
import android.os.Build
|
|
9
|
+
import androidx.core.app.NotificationCompat
|
|
10
|
+
import com.google.firebase.messaging.FirebaseMessagingService
|
|
11
|
+
import com.google.firebase.messaging.RemoteMessage
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* FCM messaging service for Android push.
|
|
15
|
+
*
|
|
16
|
+
* - `onNewToken` — registers the (refreshed) device token with
|
|
17
|
+
* Apex via `ApexCapacitorPlugin.registerTokenFromService`.
|
|
18
|
+
* - `onMessageReceived` — for foreground/data messages, posts a system
|
|
19
|
+
* notification whose tap PendingIntent re-opens
|
|
20
|
+
* the launcher activity carrying the apex push
|
|
21
|
+
* data (`apex_url`, `apex_messageId`,
|
|
22
|
+
* `apex_apexToken`) as extras. The plugin reads
|
|
23
|
+
* those on launch and fires `pushClicked`, which
|
|
24
|
+
* the app uses to report engagement + route.
|
|
25
|
+
*
|
|
26
|
+
* For background "notification" messages the system tray renders the
|
|
27
|
+
* banner directly and FCM copies the `data` bag into the launcher
|
|
28
|
+
* intent extras on tap — same `apex_*` keys, same plugin handling.
|
|
29
|
+
*/
|
|
30
|
+
class ApexFirebaseMessagingService : FirebaseMessagingService() {
|
|
31
|
+
|
|
32
|
+
override fun onNewToken(token: String) {
|
|
33
|
+
super.onNewToken(token)
|
|
34
|
+
try {
|
|
35
|
+
ApexCapacitorPlugin.registerTokenFromService(applicationContext, token)
|
|
36
|
+
} catch (_: Throwable) {
|
|
37
|
+
// best-effort — a registration failure must not crash the service
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
override fun onMessageReceived(message: RemoteMessage) {
|
|
42
|
+
super.onMessageReceived(message)
|
|
43
|
+
val data = message.data
|
|
44
|
+
val title = message.notification?.title ?: data["title"] ?: "Notification"
|
|
45
|
+
val body = message.notification?.body ?: data["body"] ?: ""
|
|
46
|
+
|
|
47
|
+
val launch = packageManager.getLaunchIntentForPackage(packageName)
|
|
48
|
+
if (launch != null) {
|
|
49
|
+
launch.flags =
|
|
50
|
+
Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP
|
|
51
|
+
data["url"]?.let { launch.putExtra("apex_url", it) }
|
|
52
|
+
data["messageId"]?.let { launch.putExtra("apex_messageId", it) }
|
|
53
|
+
data["apexToken"]?.let { launch.putExtra("apex_apexToken", it) }
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
val flags =
|
|
57
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
|
|
58
|
+
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
|
59
|
+
} else {
|
|
60
|
+
PendingIntent.FLAG_UPDATE_CURRENT
|
|
61
|
+
}
|
|
62
|
+
val contentIntent =
|
|
63
|
+
launch?.let {
|
|
64
|
+
PendingIntent.getActivity(this, message.messageId?.hashCode() ?: 0, it, flags)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
ensureChannel()
|
|
68
|
+
val notification = NotificationCompat.Builder(this, CHANNEL_ID)
|
|
69
|
+
.setContentTitle(title)
|
|
70
|
+
.setContentText(body)
|
|
71
|
+
.setSmallIcon(applicationInfo.icon)
|
|
72
|
+
.setAutoCancel(true)
|
|
73
|
+
.setPriority(NotificationCompat.PRIORITY_HIGH)
|
|
74
|
+
.apply { if (contentIntent != null) setContentIntent(contentIntent) }
|
|
75
|
+
.build()
|
|
76
|
+
|
|
77
|
+
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
78
|
+
mgr.notify(message.messageId?.hashCode() ?: System.currentTimeMillis().toInt(), notification)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
private fun ensureChannel() {
|
|
82
|
+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
|
83
|
+
val mgr = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
|
84
|
+
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
|
85
|
+
mgr.createNotificationChannel(
|
|
86
|
+
NotificationChannel(
|
|
87
|
+
CHANNEL_ID,
|
|
88
|
+
"Notifications",
|
|
89
|
+
NotificationManager.IMPORTANCE_HIGH,
|
|
90
|
+
),
|
|
91
|
+
)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
companion object {
|
|
97
|
+
private const val CHANNEL_ID = "apex_default"
|
|
98
|
+
}
|
|
99
|
+
}
|
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
package inc.apex.capacitor
|
|
2
|
+
|
|
3
|
+
import org.json.JSONArray
|
|
4
|
+
import org.json.JSONObject
|
|
5
|
+
import java.net.HttpURLConnection
|
|
6
|
+
import java.net.URL
|
|
7
|
+
import java.util.concurrent.Executor
|
|
8
|
+
import java.util.concurrent.Executors
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* MMP-215 — Drains [NativeOfflineQueue] by POSTing batches of events to
|
|
12
|
+
* `${apiBaseUrl}/api/events`. Mirrors the iOS `BatchSender.swift` so the
|
|
13
|
+
* on-disk Android queue isn't stranded.
|
|
14
|
+
*
|
|
15
|
+
* Before this class shipped, `flushQueue()` on Android was a no-op —
|
|
16
|
+
* `track()` enqueued to the native file-backed queue and nothing ever
|
|
17
|
+
* drained it, so any event captured while the Capacitor webview wasn't
|
|
18
|
+
* mounted was lost forever. iOS shipped its sender in MMP-205; this is
|
|
19
|
+
* the parity port.
|
|
20
|
+
*
|
|
21
|
+
* Concurrency model:
|
|
22
|
+
* - Serial single-threaded executor gates send attempts so two flushes
|
|
23
|
+
* don't race over the same batch (parity with Swift's serial
|
|
24
|
+
* DispatchQueue).
|
|
25
|
+
* - HTTP is synchronous via `HttpURLConnection` (no OkHttp / coroutines
|
|
26
|
+
* dep — keeps APK delta small). The whole drain runs on the executor.
|
|
27
|
+
* - Retry backoff sleeps on the executor thread; since callers fire
|
|
28
|
+
* `flush()` and observe via the completion callback, blocking the
|
|
29
|
+
* serial thread for a retry doesn't surface as a UI freeze.
|
|
30
|
+
*/
|
|
31
|
+
class NativeBatchSender(
|
|
32
|
+
apiBaseUrl: String,
|
|
33
|
+
private val workspaceKey: String,
|
|
34
|
+
private val platformHeader: String = "android",
|
|
35
|
+
private val apiKey: String? = null,
|
|
36
|
+
batchSize: Int = 50,
|
|
37
|
+
maxRetries: Int = 3,
|
|
38
|
+
baseBackoffMs: Long = 1000L,
|
|
39
|
+
private val debug: Boolean = false,
|
|
40
|
+
private val httpClient: HttpClient = DefaultHttpClient(),
|
|
41
|
+
private val executor: Executor = Executors.newSingleThreadExecutor { r ->
|
|
42
|
+
Thread(r, "inc.apex.batch-sender").apply { isDaemon = true }
|
|
43
|
+
},
|
|
44
|
+
private val sleeper: (Long) -> Unit = { ms -> Thread.sleep(ms) },
|
|
45
|
+
) {
|
|
46
|
+
|
|
47
|
+
private val apiBaseUrl: String = apiBaseUrl.trimEnd('/')
|
|
48
|
+
private val batchSize: Int = maxOf(1, batchSize)
|
|
49
|
+
private val maxRetries: Int = maxOf(1, maxRetries)
|
|
50
|
+
private val baseBackoffMs: Long = maxOf(0L, baseBackoffMs)
|
|
51
|
+
|
|
52
|
+
@Volatile
|
|
53
|
+
private var inFlight: Boolean = false
|
|
54
|
+
private val inFlightLock = Any()
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Drains the queue, one batch at a time, with exponential backoff on
|
|
58
|
+
* retryable failures. Safe to call concurrently — only one flush runs
|
|
59
|
+
* at a time; concurrent callers receive `(0, currentSize)` and the
|
|
60
|
+
* in-flight drain keeps going.
|
|
61
|
+
*
|
|
62
|
+
* @param callback fires exactly once with (flushed, remaining).
|
|
63
|
+
*/
|
|
64
|
+
fun flush(queue: NativeOfflineQueue, callback: (flushed: Int, remaining: Int) -> Unit) {
|
|
65
|
+
executor.execute {
|
|
66
|
+
synchronized(inFlightLock) {
|
|
67
|
+
if (inFlight) {
|
|
68
|
+
if (debug) log("flush called while drain in flight — no-op")
|
|
69
|
+
callback(0, queue.size())
|
|
70
|
+
return@execute
|
|
71
|
+
}
|
|
72
|
+
inFlight = true
|
|
73
|
+
}
|
|
74
|
+
val flushed = try {
|
|
75
|
+
drainLoop(queue, accumulated = 0)
|
|
76
|
+
} catch (t: Throwable) {
|
|
77
|
+
if (debug) log("drainLoop unexpected throw: ${t.message}")
|
|
78
|
+
0
|
|
79
|
+
} finally {
|
|
80
|
+
synchronized(inFlightLock) { inFlight = false }
|
|
81
|
+
}
|
|
82
|
+
callback(flushed, queue.size())
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Recursive drain. Returns the running total of successfully flushed
|
|
88
|
+
* events. Stops at the first non-retryable error or exhausted retry.
|
|
89
|
+
*/
|
|
90
|
+
private fun drainLoop(queue: NativeOfflineQueue, accumulated: Int): Int {
|
|
91
|
+
val batch = try {
|
|
92
|
+
queue.peek(batchSize)
|
|
93
|
+
} catch (e: Exception) {
|
|
94
|
+
if (debug) log("peek failed: ${e.message}")
|
|
95
|
+
return accumulated
|
|
96
|
+
}
|
|
97
|
+
if (batch.isEmpty()) return accumulated
|
|
98
|
+
|
|
99
|
+
return when (val outcome = sendBatchWithRetry(batch, attempt = 0)) {
|
|
100
|
+
SendOutcome.Success -> {
|
|
101
|
+
queue.markSent(batch.map { it.id })
|
|
102
|
+
if (debug) log("flushed batch of ${batch.size}")
|
|
103
|
+
drainLoop(queue, accumulated + batch.size)
|
|
104
|
+
}
|
|
105
|
+
is SendOutcome.NonRetryable -> {
|
|
106
|
+
if (debug) log("non-retryable HTTP ${outcome.statusCode}; leaving events queued")
|
|
107
|
+
accumulated
|
|
108
|
+
}
|
|
109
|
+
is SendOutcome.RetryableExhausted -> {
|
|
110
|
+
queue.markFailed(batch.map { it.id })
|
|
111
|
+
if (debug) log("retries exhausted: ${outcome.errorMessage}")
|
|
112
|
+
accumulated
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
private fun sendBatchWithRetry(
|
|
118
|
+
batch: List<NativeQueuedEvent>,
|
|
119
|
+
attempt: Int,
|
|
120
|
+
): SendOutcome {
|
|
121
|
+
if (batch.isEmpty()) return SendOutcome.Success
|
|
122
|
+
|
|
123
|
+
val url = "$apiBaseUrl/api/events"
|
|
124
|
+
val body = buildRequestBody(batch)
|
|
125
|
+
val headers = mutableMapOf(
|
|
126
|
+
"Content-Type" to "application/json",
|
|
127
|
+
"X-Apex-Workspace-Key" to workspaceKey,
|
|
128
|
+
"X-Apex-Platform" to platformHeader,
|
|
129
|
+
)
|
|
130
|
+
apiKey?.let { headers["x-apex-api-key"] = it }
|
|
131
|
+
|
|
132
|
+
if (debug) log("POST $url (${batch.size} events, attempt ${attempt + 1}/$maxRetries)")
|
|
133
|
+
|
|
134
|
+
val response = try {
|
|
135
|
+
httpClient.send(url, headers, body, timeoutMs = 20_000)
|
|
136
|
+
} catch (e: Exception) {
|
|
137
|
+
return retryOrFail(batch, attempt, e.message ?: "network error")
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
return when {
|
|
141
|
+
response.statusCode in 200..299 -> SendOutcome.Success
|
|
142
|
+
response.statusCode in 400..499 ->
|
|
143
|
+
// Client error — don't retry; events stay queued for
|
|
144
|
+
// inspection but we stop hammering the server.
|
|
145
|
+
SendOutcome.NonRetryable(response.statusCode)
|
|
146
|
+
else ->
|
|
147
|
+
retryOrFail(
|
|
148
|
+
batch,
|
|
149
|
+
attempt,
|
|
150
|
+
response.errorMessage ?: "HTTP ${response.statusCode}",
|
|
151
|
+
)
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
private fun retryOrFail(
|
|
156
|
+
batch: List<NativeQueuedEvent>,
|
|
157
|
+
attempt: Int,
|
|
158
|
+
errorMessage: String,
|
|
159
|
+
): SendOutcome {
|
|
160
|
+
if (attempt + 1 >= maxRetries) {
|
|
161
|
+
return SendOutcome.RetryableExhausted(errorMessage)
|
|
162
|
+
}
|
|
163
|
+
val backoffMs = baseBackoffMs shl attempt
|
|
164
|
+
if (debug) log("retry in ${backoffMs}ms ($errorMessage)")
|
|
165
|
+
sleeper(backoffMs)
|
|
166
|
+
return sendBatchWithRetry(batch, attempt + 1)
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
private fun buildRequestBody(batch: List<NativeQueuedEvent>): ByteArray {
|
|
170
|
+
// Server accepts `{ workspaceKey, events: [{ id, ...payload }] }`.
|
|
171
|
+
// Unwrap the queued envelope so `id` lives at the top level of
|
|
172
|
+
// each event (matches /api/events expectations and the JS-side
|
|
173
|
+
// BatchSender shape).
|
|
174
|
+
//
|
|
175
|
+
// MMP-081 — stamp `clientType="native-android"` if the queued
|
|
176
|
+
// payload didn't already carry one (caller-supplied values win,
|
|
177
|
+
// which preserves test fixtures and unusual workflows).
|
|
178
|
+
val eventsArray = JSONArray()
|
|
179
|
+
for (event in batch) {
|
|
180
|
+
val flat = JSONObject(event.payload.toMap())
|
|
181
|
+
flat.put("id", event.id)
|
|
182
|
+
if (!flat.has("clientType") || flat.isNull("clientType")) {
|
|
183
|
+
flat.put("clientType", "native-android")
|
|
184
|
+
}
|
|
185
|
+
eventsArray.put(flat)
|
|
186
|
+
}
|
|
187
|
+
val payload = JSONObject()
|
|
188
|
+
.put("workspaceKey", workspaceKey)
|
|
189
|
+
.put("events", eventsArray)
|
|
190
|
+
return payload.toString().toByteArray(Charsets.UTF_8)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private fun log(msg: String) {
|
|
194
|
+
println("[apex-capacitor] $msg")
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
// ── Result types ──────────────────────────────────────────────────
|
|
198
|
+
|
|
199
|
+
sealed class SendOutcome {
|
|
200
|
+
object Success : SendOutcome()
|
|
201
|
+
data class NonRetryable(val statusCode: Int) : SendOutcome()
|
|
202
|
+
data class RetryableExhausted(val errorMessage: String) : SendOutcome()
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ── HTTP abstraction (test-swappable) ─────────────────────────────
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* Minimal HTTP transport. Tests inject a fake; production uses
|
|
209
|
+
* [DefaultHttpClient]. Synchronous by design — the caller is already
|
|
210
|
+
* on the executor thread.
|
|
211
|
+
*/
|
|
212
|
+
interface HttpClient {
|
|
213
|
+
fun send(
|
|
214
|
+
url: String,
|
|
215
|
+
headers: Map<String, String>,
|
|
216
|
+
body: ByteArray,
|
|
217
|
+
timeoutMs: Int,
|
|
218
|
+
): HttpResponse
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
data class HttpResponse(
|
|
222
|
+
val statusCode: Int,
|
|
223
|
+
val errorMessage: String? = null,
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Production HTTP client. Uses [HttpURLConnection] — same approach
|
|
228
|
+
* as the rest of the plugin (no OkHttp dependency).
|
|
229
|
+
*/
|
|
230
|
+
class DefaultHttpClient : HttpClient {
|
|
231
|
+
override fun send(
|
|
232
|
+
url: String,
|
|
233
|
+
headers: Map<String, String>,
|
|
234
|
+
body: ByteArray,
|
|
235
|
+
timeoutMs: Int,
|
|
236
|
+
): HttpResponse {
|
|
237
|
+
val conn = (URL(url).openConnection() as HttpURLConnection).apply {
|
|
238
|
+
requestMethod = "POST"
|
|
239
|
+
doOutput = true
|
|
240
|
+
connectTimeout = timeoutMs
|
|
241
|
+
readTimeout = timeoutMs
|
|
242
|
+
useCaches = false
|
|
243
|
+
for ((k, v) in headers) setRequestProperty(k, v)
|
|
244
|
+
}
|
|
245
|
+
try {
|
|
246
|
+
conn.outputStream.use { it.write(body) }
|
|
247
|
+
val code = conn.responseCode
|
|
248
|
+
return HttpResponse(statusCode = code)
|
|
249
|
+
} catch (e: Exception) {
|
|
250
|
+
return HttpResponse(statusCode = 0, errorMessage = e.message ?: "io error")
|
|
251
|
+
} finally {
|
|
252
|
+
try {
|
|
253
|
+
conn.disconnect()
|
|
254
|
+
} catch (_: Exception) {
|
|
255
|
+
// best-effort
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
package/dist/batch-sender.d.ts
CHANGED
|
@@ -12,10 +12,16 @@
|
|
|
12
12
|
*/
|
|
13
13
|
import type { OfflineQueue } from "./offline-queue";
|
|
14
14
|
export interface BatchSenderOptions {
|
|
15
|
-
/** API base URL. Default `https://
|
|
15
|
+
/** API base URL. Default `https://app.apex.inc`. */
|
|
16
16
|
apiUrl?: string;
|
|
17
|
-
/**
|
|
18
|
-
|
|
17
|
+
/** Workspace key attached to each request. Required. */
|
|
18
|
+
workspaceKey: string;
|
|
19
|
+
/**
|
|
20
|
+
* Optional workspace-bound SDK key (`apex_uk_*` / `apex_sk_*`).
|
|
21
|
+
* Forwarded as `x-apex-api-key` on every batch so the server can
|
|
22
|
+
* authorize quarantine-mode auto-stitch.
|
|
23
|
+
*/
|
|
24
|
+
apiKey?: string;
|
|
19
25
|
/** Max events sent per batch. Default 50. */
|
|
20
26
|
batchSize?: number;
|
|
21
27
|
/** Max retry attempts before giving up on a batch. Default 3. */
|
|
@@ -39,7 +45,8 @@ export interface FlushResult {
|
|
|
39
45
|
}
|
|
40
46
|
export declare class BatchSender {
|
|
41
47
|
private readonly apiUrl;
|
|
42
|
-
private readonly
|
|
48
|
+
private readonly workspaceKey;
|
|
49
|
+
private readonly apiKey?;
|
|
43
50
|
/** Exposed for callers (e.g. getVariant) that need to address the same server. */
|
|
44
51
|
getApiUrl(): string;
|
|
45
52
|
private readonly batchSize;
|
|
@@ -54,6 +61,11 @@ export declare class BatchSender {
|
|
|
54
61
|
* Flushes as many events from the queue as possible, one batch at a time,
|
|
55
62
|
* with exponential backoff on retryable failures. Stops early on
|
|
56
63
|
* non-retryable errors (400/401/403) and leaves remaining events queued.
|
|
64
|
+
*
|
|
65
|
+
* MOBX-005 (post-mortem #5) — every flush starts with queue hygiene:
|
|
66
|
+
* events older than 7 days or with more than 20 failed delivery
|
|
67
|
+
* attempts are evicted so a poisoned head can never block fresh
|
|
68
|
+
* events indefinitely.
|
|
57
69
|
*/
|
|
58
70
|
flush(queue: OfflineQueue): Promise<FlushResult>;
|
|
59
71
|
private sendBatchWithRetry;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"batch-sender.d.ts","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAe,MAAM,iBAAiB,CAAC;AAEjE,MAAM,WAAW,kBAAkB;IACjC,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,
|
|
1
|
+
{"version":3,"file":"batch-sender.d.ts","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,KAAK,EAAE,YAAY,EAAe,MAAM,iBAAiB,CAAC;AAEjE,MAAM,WAAW,kBAAkB;IACjC,oDAAoD;IACpD,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,wDAAwD;IACxD,YAAY,EAAE,MAAM,CAAC;IACrB;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,6CAA6C;IAC7C,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,iEAAiE;IACjE,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,wDAAwD;IACxD,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,4DAA4D;IAC5D,OAAO,CAAC,EAAE,OAAO,KAAK,CAAC;IACvB,0EAA0E;IAC1E,OAAO,CAAC,EAAE,CAAC,EAAE,EAAE,MAAM,KAAK,OAAO,CAAC,IAAI,CAAC,CAAC;IACxC,sDAAsD;IACtD,cAAc,CAAC,EAAE,KAAK,GAAG,SAAS,GAAG,KAAK,CAAC;IAC3C,2BAA2B;IAC3B,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,MAAM,WAAW,WAAW;IAC1B,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,gBAAgB,EAAE,MAAM,CAAC;IACzB,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED,qBAAa,WAAW;IACtB,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAS;IAChC,OAAO,CAAC,QAAQ,CAAC,YAAY,CAAS;IACtC,OAAO,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAS;IAEjC,kFAAkF;IAClF,SAAS,IAAI,MAAM;IAInB,OAAO,CAAC,QAAQ,CAAC,SAAS,CAAS;IACnC,OAAO,CAAC,QAAQ,CAAC,UAAU,CAAS;IACpC,OAAO,CAAC,QAAQ,CAAC,aAAa,CAAS;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAe;IACvC,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAgC;IACxD,OAAO,CAAC,QAAQ,CAAC,cAAc,CAAC,CAA4B;IAC5D,OAAO,CAAC,QAAQ,CAAC,KAAK,CAAU;gBAEpB,OAAO,EAAE,kBAAkB;IAkBvC;;;;;;;;;OASG;IACG,KAAK,CAAC,KAAK,EAAE,YAAY,GAAG,OAAO,CAAC,WAAW,CAAC;YA8CxC,kBAAkB;YAyClB,SAAS;CA2BxB"}
|
package/dist/batch-sender.js
CHANGED
|
@@ -19,8 +19,9 @@ class BatchSender {
|
|
|
19
19
|
return this.apiUrl;
|
|
20
20
|
}
|
|
21
21
|
constructor(options) {
|
|
22
|
-
this.apiUrl = (options.apiUrl ?? "https://
|
|
23
|
-
this.
|
|
22
|
+
this.apiUrl = (options.apiUrl ?? "https://app.apex.inc").replace(/\/+$/, "");
|
|
23
|
+
this.workspaceKey = options.workspaceKey;
|
|
24
|
+
this.apiKey = options.apiKey;
|
|
24
25
|
this.batchSize = options.batchSize ?? 50;
|
|
25
26
|
this.maxRetries = options.maxRetries ?? 3;
|
|
26
27
|
this.baseBackoffMs = options.baseBackoffMs ?? 1000;
|
|
@@ -37,11 +38,28 @@ class BatchSender {
|
|
|
37
38
|
* Flushes as many events from the queue as possible, one batch at a time,
|
|
38
39
|
* with exponential backoff on retryable failures. Stops early on
|
|
39
40
|
* non-retryable errors (400/401/403) and leaves remaining events queued.
|
|
41
|
+
*
|
|
42
|
+
* MOBX-005 (post-mortem #5) — every flush starts with queue hygiene:
|
|
43
|
+
* events older than 7 days or with more than 20 failed delivery
|
|
44
|
+
* attempts are evicted so a poisoned head can never block fresh
|
|
45
|
+
* events indefinitely.
|
|
40
46
|
*/
|
|
41
47
|
async flush(queue) {
|
|
42
48
|
let flushed = 0;
|
|
43
49
|
let attemptedBatches = 0;
|
|
44
50
|
let lastError;
|
|
51
|
+
try {
|
|
52
|
+
const evicted = await queue.evictStale({
|
|
53
|
+
maxAttempts: 20,
|
|
54
|
+
maxAgeMs: 7 * 24 * 60 * 60 * 1000,
|
|
55
|
+
});
|
|
56
|
+
if (evicted > 0 && this.debug) {
|
|
57
|
+
console.warn(`[apex-capacitor] evicted ${evicted} stale event(s) from the offline queue (TTL 7d / 20 attempts)`);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
/* hygiene is best-effort — never block the flush */
|
|
62
|
+
}
|
|
45
63
|
while (true) {
|
|
46
64
|
const batch = await queue.peek(this.batchSize);
|
|
47
65
|
if (batch.length === 0)
|
|
@@ -98,16 +116,23 @@ class BatchSender {
|
|
|
98
116
|
}
|
|
99
117
|
async postBatch(batch) {
|
|
100
118
|
const body = {
|
|
101
|
-
|
|
119
|
+
workspaceKey: this.workspaceKey,
|
|
102
120
|
events: batch.map((q) => q.event),
|
|
103
121
|
};
|
|
104
122
|
const headers = {
|
|
105
123
|
"Content-Type": "application/json",
|
|
106
|
-
"X-Apex-
|
|
124
|
+
"X-Apex-Workspace-Key": this.workspaceKey,
|
|
107
125
|
};
|
|
108
126
|
if (this.platformHeader) {
|
|
109
127
|
headers["X-Apex-Platform"] = this.platformHeader;
|
|
110
128
|
}
|
|
129
|
+
if (this.apiKey) {
|
|
130
|
+
// Phase 2-quarantine — workspace-bound SDK key. The server uses
|
|
131
|
+
// this to authorize auto-stitch on `user_signed_up` /
|
|
132
|
+
// `user_identified`. Missing key means events still flow but
|
|
133
|
+
// auto-stitch is suppressed (the fail-closed default).
|
|
134
|
+
headers["x-apex-api-key"] = this.apiKey;
|
|
135
|
+
}
|
|
111
136
|
return this.fetchFn(`${this.apiUrl}/api/events`, {
|
|
112
137
|
method: "POST",
|
|
113
138
|
headers,
|
package/dist/batch-sender.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"batch-sender.js","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG;;;
|
|
1
|
+
{"version":3,"file":"batch-sender.js","sourceRoot":"","sources":["../src/batch-sender.ts"],"names":[],"mappings":"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"}
|