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 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:).
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.
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:
| Event | Carries |
|---|---|
VpnAnalyticsSessionBeginEvent / VpnAnalyticsSessionEndEvent | sessionId |
VpnAnalyticsConnectionBeginEvent / VpnAnalyticsConnectionEndEvent | sessionId, connectionId |
VpnAnalyticsAttemptBeginEvent | sessionId, connectionId, attemptId |
VpnAnalyticsAttemptEndEvent | attemptId |
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 carrieselapsedMsand, when the attempt succeeded, theendpointit 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 toinitializeAsynchas 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).
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.