Skip to main content

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(...) to disconnect().
  • 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.

info

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:

EventCarries
SessionBeginEvent / SessionEndEventsessionId
ConnectionBeginEvent / ConnectionEndEventsessionId, connectionId
AttemptBeginEventsessionId, connectionId, attemptId
AttemptEndEventattemptId

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.

warning

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 to initializeAsync has to include that category or nothing is recorded.
  • awaitSdkManager() and the tracking calls all suspend, so this runs in a coroutine — while KapeVpnManager.connectionAnalytics is read synchronously when a tunnel starts. A connect() 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.