Skip to content

Session.started fires on background-only cold launches, inflating session counts #281

@ramikay

Description

@ramikay

Summary

With Config.sendNewSessionBeganSignal set to true, TelemetryDeck.Session.started fires on every process launch, including background-only cold launches triggered by BGAppRefreshTask, HKObserverQuery background delivery, content-available push notifications, significant-location-change wakes, etc.

The SDK does not distinguish a user-initiated foreground launch from a system-scheduled background launch that the user never sees. As a result, session counts on the TelemetryDeck dashboard are inflated by launches the user never initiated, and any metric derived from sessions (shares-per-session, screens-per-session, average session duration, retention) is skewed.

SDK Version

  • Affected: SwiftSDK 2.12.0 (or previous)
  • Files: Sources/TelemetryDeck/TelemetryClient.swift, Sources/TelemetryDeck/Helpers/SessionManager.swift
  • Platform: iOS (most impactful), also affects tvOS and watchOS by the same mechanism

Reproduction

  1. Integrate TelemetryDeck's SwiftSDK 2.12.0 with default config (Config.sendNewSessionBeganSignal == true) into an iOS app target.
  2. Register a background entry point. Easiest reproductions:
    • HKObserverQuery + HKHealthStore.enableBackgroundDelivery(...) for any HealthKit type, frequency .hourly
    • BGAppRefreshTaskRequest registered via BGTaskScheduler
    • Content-available push notification
  3. Background the app and let iOS terminate the process (force from app switcher or wait for memory pressure).
  4. Wait 5 minutes (SDK threshold for registering a new session)
  5. Trigger the background entry point — wait for the HealthKit observer to fire, or use Xcode → Debug → Simulate Background Fetch, or send a push.
  6. Observe TelemetryDeck.Session.started on the dashboard for that wake-up. The user never opened the app.

Expected behavior

Session signals should correspond to user-initiated sessions — cold foreground launches and returns from background after the configured idle threshold. Background-only process launches should not emit Session.started by default.

This matches what the property name suggests (sendNewSessionBeganSignal) and what consumers reasonably expect from a "session" metric.

Actual behavior

Session.started fires unconditionally 0.5s after TelemetryManager.init (which runs on every process launch via @main App.init()). For an app with hourly HKObserverQuery delivery across 5 HealthKit types plus a 5-minute BGAppRefreshTask, this can produce 20–30+ spurious "sessions" per device per day on top of the user's actual visits.

Root cause

Sources/TelemetryDeck/TelemetryClient.swift:438-442:

private func startSessionAndObserveAppForegrounding() {
    // initially start a new session upon app start (delayed so that `didSet` triggers)
    DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
        TelemetryDeck.generateNewSession()
    }
    ...
}

The call to TelemetryDeck.generateNewSession() is unconditional. There is no check against UIApplication.shared.applicationState, scene phase, or any other foreground/background discriminator.

This behavior is unsuitable for any app that does meaningful work in the background, which is most non-trivial iOS apps today (HealthKit, location, audio, BGTaskScheduler, push, etc.).

Impact

  • Session counts inflated in proportion to how aggressively iOS schedules background work for the app.
  • Funnel / retention / engagement metrics distorted. Any per-session metric is undercounted in proportion to inflated denominators.
  • Average session duration skewed downward. SessionManager's 1-second timer typically only ticks once or twice before iOS suspends a background launch, dragging the average toward zero.
  • Particularly painful for health, fitness, location, and notification-driven apps — exactly the categories where TelemetryDeck is most attractive.

Screenshots

TelemetryDeck's Dashboard shows inflated session counts for users of an iOS app that does background wake-ups.

TelemetryDeck AppStore Connect
TelemetryDeck Dashboard AppStore Connect Analytics

Note

While AppStore analytics are opt-in only, it is safe to assume that 25-30% of users choose to enable sharing analytics with 3rd party app developers.

Suggested fix

Ideally, there should be a configuration property to enable this behavior, with background sessions being discounted by default:

public final class TelemetryManagerConfiguration: @unchecked Sendable {
...
    /// If `true` the TelemetryDeck SDK will count system-scheduled background launches as user sesssions.
    public var registerBackgroundSessions: Bool = false
...
}    

The minimal change, in startSessionAndObserveAppForegrounding():

private func startSessionAndObserveAppForegrounding() {
      // initially start a new session upon app start (delayed so that `didSet` triggers)
      DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) {
          #if os(iOS) || os(tvOS) || os(visionOS)
          guard TelemetryManager.shared.configuration.registerBackgroundSessions
              || TelemetryEnvironment.isAppExtension
              || UIApplication.shared.applicationState != .background else {
              return
          }
          #endif
          TelemetryDeck.generateNewSession()
      }

      // ... existing notification observers unchanged ...
  }

Notes on this fix:

  • At App.init(), UIApplication.shared.applicationState is .inactive for foreground cold launches and .background for background-only ones. The 0.5s delay doesn't flip this — a background launch has no scene to activate, so it remains .background for the lifetime of the wake-up.
  • The existing willEnterForeground observer (TelemetryClient.swift:482-488) is unchanged. If the user later opens the app from a background-launched process, a session fires there after the 5-minute idle check — no behavior lost.
  • registerBackgroundSessions = false is the new default and is a behavior change; consumers who prefer the old count-every-launch semantics can opt back in with a single flag. App extensions keep current behavior regardless (short-circuited via TelemetryEnvironment.isAppExtension, since UIApplication.shared is unavailable there).

Temporary Workaround

Until the fix lands, consumers can:

  1. Set config.sendNewSessionBeganSignal = false and config.sessionStatsEnabled = false in their TelemetryDeck.Config.
  2. Track "last backgrounded date" in their own persisted state (so the idle check survives process death).
  3. From their SwiftUI scene-phase .active handler, check the idle threshold; if exceeded, briefly flip the two flags back to true, call TelemetryDeck.generateNewSession(), then flip them back to false. The SDK's sessionID.didSet reads the flags synchronously in the same call, so the SDK emits its native TelemetryDeck.Session.started.

This works because TelemetryDeck.Config is a public final class and TelemetryManager retains the reference rather than copying property values, so post-initialize mutations are visible to the SDK on subsequent reads.

The workaround correctly produces zero Session.started signals from background-only launches because SwiftUI scene phase never reaches .active during background tasks.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions