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 KapeVPN_PacketTunnel

struct MyAnalytics: VpnConnectionAnalytics {
func sessionDidBegin(_ event: VpnAnalyticsSessionBeginEvent) { /* ... */ }
func sessionDidEnd(_ event: VpnAnalyticsSessionEndEvent) { /* ... */ }
func connectionDidBegin(_ event: VpnAnalyticsConnectionBeginEvent) { /* ... */ }
func connectionDidEnd(_ event: VpnAnalyticsConnectionEndEvent) { /* ... */ }
func attemptDidBegin(_ event: VpnAnalyticsAttemptBeginEvent) { /* ... */ }
func attemptDidEnd(_ event: VpnAnalyticsAttemptEndEvent) { /* ... */ }
}

The protocol is Sendable, and it lives in KapeVPN-PacketTunnel — so it is also available to a target that imports only that module.

Injecting​

The events originate in the tunnel, so the reporter lives in your Network Extension, not the app process — see Network Extension for that target's setup. Override connectionAnalytics() on your KapePacketTunnelProvider subclass:

import KapeVPN

class PacketTunnelProvider: KapePacketTunnelProvider, @unchecked Sendable {
override func connectionAnalytics() async -> VpnConnectionAnalytics {
MyAnalytics()
}
}

Defaults to a no-op (NoopVpnConnectionAnalytics) — safe to leave unoverridden.

The hook is called once per startTunnel, and it is async on purpose: by the time it runs the SDK manager is ready, so an override can await its way to whatever it needs through the provider's sdkHandle without racing SDK initialization. Consumers that supply their own NEPacketTunnelProvider instead pass the reporter to SessionControllerFactory.make(analytics:).

info

Callbacks arrive on a private serial queue, off the run loop's actor, in the order the events actually occurred. A slow implementation only delays its own later events — it never blocks the tunnel.

warning

An extension has no UI and a ~10 MB memory limit. Keep the reporter light, and persist anything it needs from the app side (a consent flag, a user ID) in your App Group container rather than trying to ask for it here.

Correlating events​

Every event carries UUIDs so you can nest attempts under connections under sessions:

EventCarries
VpnAnalyticsSessionBeginEvent / VpnAnalyticsSessionEndEventsessionId
VpnAnalyticsConnectionBeginEvent / VpnAnalyticsConnectionEndEventsessionId, connectionId
VpnAnalyticsAttemptBeginEventsessionId, connectionId, attemptId
VpnAnalyticsAttemptEndEventattemptId

The reason fields explain why an event happened, and are split by span so connection-end handling is not forced to switch over session-only cases:

  • VpnAnalyticsConnectionBeginEvent.reason — PacketTunnelConnectReason: .initial, .networkLoss, .pathChange, .resumed.
  • VpnAnalyticsSessionEndEvent.reason — PacketTunnelSessionDisconnectReason: .userInitiated, .noEndpointsAvailable.
  • VpnAnalyticsConnectionEndEvent.reason — PacketTunnelConnectionDisconnectReason: those two plus .connectionError, .cancelled, .paused.
  • VpnAnalyticsAttemptEndEvent.result — PacketTunnelAttemptResult: .connected, .failed(PacketTunnelError), .cancelled. It also carries elapsedMs and, when the attempt succeeded, the endpoint it connected to.

Attempt reporting is owned by each connection controller, not the run loop, so the attempt span matches the controller's own connect work. WireGuard and OpenVPN report one attempt per connect(). Lightway races every candidate endpoint inside the vendored library with no live per-candidate visibility, so it reports after the fact: the winning endpoint only on success, every candidate as .failed on total timeout or .cancelled if cancelled mid-connect, and nothing on any other connect error — the connection-end event covers that path.

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? — so you never have to join back to the session-begin event for that context.

VpnAnalyticsConnectionEndEvent additionally carries effectiveProtocol: String? and effectiveObfuscation: String? — of whichever configuration actually connected during that connection's span, or nil if it never reached .connected. Useful under selectedProtocol = "automatic", where the protocol that succeeds may differ from the preference.

VpnAnalyticsAttemptBeginEvent.configuration is the real any VpnConfiguration that attempt is trying, not a pre-stringified description — read vpnProtocolName, obfuscationDescription, cipherDescription or isProxied off it directly, or cast to the concrete type (e.g. KapeWireGuardConfig) for protocol-specific detail.

Reference implementation: KapeClientSDK tracking​

KapeClientVpnConnectionAnalytics (in KapeVPN) is a ready-to-use VpnConnectionAnalytics that reports through a KapeClientSDK TrackingProtocol 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 KapeCore
import KapeVPN
import KapeVPN_PacketTunnel

class PacketTunnelProvider: KapePacketTunnelProvider, @unchecked Sendable {
private static let mixpanelToken = "YOUR_MIXPANEL_PROJECT_TOKEN"

override func connectionAnalytics() async -> VpnConnectionAnalytics {
let logger = KapeLogger(subsystem: "com.example.app", category: "Analytics")
guard let handle = sdkHandle else { return NoopVpnConnectionAnalytics() }

// 1. Get the tracking instance off the SDK manager.
let tracking: any TrackingProtocol
do {
tracking = try await handle.awaitSdkManager().tracking()
} catch {
logger.warning("Tracking unavailable, analytics disabled: \(error)")
return NoopVpnConnectionAnalytics()
}

// 2. Configure it with your own project token and the category the reporter
// tags its events with.
await tracking.initializeAsync(
config: MixpanelConfiguration(
projectToken: Self.mixpanelToken,
optOutTrackingByDefault: false,
categories: [.qualityMetrics]
)
)

// 3. Opt in — tracking is opted OUT by default. Gate this on the consent the
// app persisted to the shared container.
if userConsentedToAnalytics { await tracking.optInTrackingAsync() }

// 4. Inject.
return KapeClientVpnConnectionAnalytics(tracking: tracking, logger: logger)
}
}

Three 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.qualityMetrics, so the configuration you pass to initializeAsync has to include that category or nothing is recorded.
  • The consent decision has to reach the extension. It runs in its own process, so read a flag your app wrote to the App Group container; there is nobody to prompt here.

The reporter bridges the six synchronous callbacks to the tracking API's async methods through an AsyncStream drained by a single background task, so ordering is preserved and flush/reset only run after everything queued ahead of them has been sent. It logs every outgoing event as [tracking] <event> <propertiesJson> at debug level before sending — visible in the SDK log independent of the analytics backend's own dashboard. Call reset() on account/user switch to start a new distinct_id (not on every VPN stop).

info

The SDK logs the same event trail as [analytics] ... at debug level regardless of which reporter is injected — including the default no-op. That is the quickest way to confirm the events you expect are firing before wiring up a backend.