Connection Analytics
VpnConnectionAnalytics reports VPN connection reliability events — session, connection,
and attempt begin/end, with reasons and errors — for your own telemetry.
Vocabulary
- Session — one user intent to be connected, from
connect(...)todisconnect(). - Connection — one "try to (re)establish and stay up" span within a session. A session can have several connections (e.g. a mid-session network drop and reconnect).
- Attempt — one configuration trial within a connection (a specific protocol/endpoint combination). A connection can have several attempts before it succeeds.
Implementing
import com.kape.platformsdk.vpn.service.analytics.VpnConnectionAnalytics
import com.kape.platformsdk.vpn.service.analytics.AttemptBeginEvent
import com.kape.platformsdk.vpn.service.analytics.AttemptEndEvent
import com.kape.platformsdk.vpn.service.analytics.ConnectionBeginEvent
import com.kape.platformsdk.vpn.service.analytics.ConnectionEndEvent
import com.kape.platformsdk.vpn.service.analytics.SessionBeginEvent
import com.kape.platformsdk.vpn.service.analytics.SessionEndEvent
class MyAnalytics : VpnConnectionAnalytics {
override fun sessionDidBegin(event: SessionBeginEvent) { /* ... */ }
override fun sessionDidEnd(event: SessionEndEvent) { /* ... */ }
override fun connectionDidBegin(event: ConnectionBeginEvent) { /* ... */ }
override fun connectionDidEnd(event: ConnectionEndEvent) { /* ... */ }
override fun attemptDidBegin(event: AttemptBeginEvent) { /* ... */ }
override fun attemptDidEnd(event: AttemptEndEvent) { /* ... */ }
}
Set it on KapeVpnManager before calling connect(...):
vpn.connectionAnalytics = MyAnalytics()
Defaults to a no-op (NoOpVpnConnectionAnalytics) — safe to leave unset.
Callbacks arrive off the VPN service's own coroutine, on a private dispatcher, in the order the events actually occurred. A slow implementation only delays its own later events — it never blocks the tunnel.
Correlating events
Every event carries UUIDs so you can nest attempts under connections under sessions:
| Event | Carries |
|---|---|
SessionBeginEvent / SessionEndEvent | sessionId |
ConnectionBeginEvent / ConnectionEndEvent | sessionId, connectionId |
AttemptBeginEvent | sessionId, connectionId, attemptId |
AttemptEndEvent | attemptId |
ConnectionBeginEvent.reason (a ConnectReason) and SessionEndEvent/
ConnectionEndEvent.reason (a DisconnectReason) explain why the event happened —
e.g. ConnectReason.NetworkLoss for a mid-session reconnect, DisconnectReason.Revoked
when the system or another VPN app revoked permission.
ConnectReason.PathChange/.Resumed and DisconnectReason.Paused exist for parity with
the iOS SDK but can't fire yet — Android has no path-reconnector or pause/resume feature
today.
Attempt reporting is owned by each ConnectionController (WireGuard/OpenVPN/Lightway),
not the run loop — mirrors the iOS SDK's ConnectionAttemptReporting. WireGuard/OpenVPN
report one attempt per connect() call. Lightway races every candidate endpoint with no
live per-candidate visibility, so it reports after the fact: winning endpoint only as
Connected on success, every candidate as Failed on timeout or Cancelled on
cancellation, nothing on any other error. AttemptEndEvent still has no endpoint
(host/port) field — VpnConfiguration only exposes protocol/obfuscation/cipher.
Context carried on every event
Every event also carries selectedProtocol: String (the user's protocol preference —
"automatic"/"wireguard"/"lightway"/"openvpn", not which protocol ends up connected)
and selectedLocationDescription: String? (the location ID passed to connect(...), or
null) — so you never have to join back to the session-begin event for that context.
ConnectionEndEvent additionally carries effectiveProtocol: String? and
effectiveObfuscation: String? — the vpnProtocolName/obfuscationDescription of
whichever configuration actually connected during that connection's span, or null if it
never reached Connected. Useful under selectedProtocol = "automatic", where the
protocol that succeeds may differ from the preference.
AttemptBeginEvent.configuration is the real VpnConfiguration that attempt is trying
(e.g. a WireGuardVpnConfiguration), not a pre-stringified description — read
configuration.vpnProtocolName/obfuscationDescription/cipherDescription/isProxied
off it directly, or cast to the concrete type for protocol-specific detail.
Reference implementation: KapeClientSDK tracking
KapeClientVpnConnectionAnalytics (in the KapeVPN module) is a ready-to-use
VpnConnectionAnalytics that reports through a KapeClientSDK TrackingInterface you
supply. It only sends events — creating the tracking instance, configuring it with your
own Mixpanel project token and opting the user in are yours, because consent is your app's
model to own.
Creating and injecting the Mixpanel instance
import com.kape.client.sdk.tracking.EventCategory
import com.kape.client.sdk.tracking.MixpanelConfiguration
import com.kape.platformsdk.core.KapeLogger
import com.kape.platformsdk.vpn.KapeClientVpnConnectionAnalytics
import com.kape.platformsdk.vpn.asVpnServiceLogger
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.launch
/** Call once at app start — e.g. from `Application.onCreate()`. */
private fun startAnalytics(projectToken: String) {
val logger = KapeLogger("com.example.app", "Analytics").asVpnServiceLogger()
CoroutineScope(SupervisorJob() + Dispatchers.IO).launch {
// 1. Get the tracking instance off the SDK manager.
val tracking =
try {
sdkHandle.awaitSdkManager().tracking()
} catch (e: Exception) {
logger.warning("Tracking unavailable, analytics disabled: $e")
return@launch
}
// 2. Configure it with your own project token and the category the reporter tags
// its events with.
tracking.initializeAsync(
MixpanelConfiguration(projectToken, false, listOf(EventCategory.QUALITY_METRICS)),
)
// 3. Opt in — tracking is opted OUT by default. Gate this on the user's consent.
if (userConsentedToAnalytics) tracking.optInTrackingAsync()
// 4. Inject. Read at tunnel start, so assign before the first connect().
vpnManager.connectionAnalytics = KapeClientVpnConnectionAnalytics(tracking, logger)
}
}
The logger argument is optional (it defaults to a no-op) and only drives the debug trail
described below — the reporter works without it.
Four things that will otherwise cost you an afternoon:
- Tracking is opted out by default at the SDK level. An instance that never had
optInTrackingAsync()called on it silently drops every event, with no error. - Events are sent with
EventCategory.QUALITY_METRICS, so the configuration you pass toinitializeAsynchas to include that category or nothing is recorded. awaitSdkManager()and the tracking calls all suspend, so this runs in a coroutine — whileKapeVpnManager.connectionAnalyticsis read synchronously when a tunnel starts. Aconnect()issued before step 4 lands reports nothing for that session.- One instance, not one per connect. Construct it once and keep it; each instance owns
a coroutine and (by default) a single-thread executor, released by
shutdown().
The reporter bridges the six sync callbacks to the suspending tracking API on a private
single-threaded dispatcher (same ordering guarantee as any other VpnConnectionAnalytics
implementation), and logs every outgoing event as [tracking] <event> <propertiesJson> at
debug level before sending — visible via adb logcat independent of the analytics
backend's own dashboard. Call its reset() method on account/user switch to start a new
distinct_id (not on every VPN stop), and shutdown() when you are done with it.