Skip to content

Latest commit

Β 

History

38 Commits

Folders and files

NameName
Last commit message
Last commit date
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 
Β 

Repository files navigation

Ahoy iOS

Simple visit-attribution and analytics library for Apple Platforms for integration with your Rails Ahoy backend.

πŸŒ– User visit tracking

πŸ“₯ Visit attribution through UTM & referrer parameters

πŸ“† Simple, straightforward, in-house event tracking

Actions Status

Installation

The Ahoy library can be easily installed using Swift Package Manager. See the Apple docs for instructions on adding a package to your project.

Requires Swift 6 (Xcode 16 or later). The package builds in the Swift 6 language mode. Ahoy is an actor, so one instance can be shared freely across actors and tasks.

Coming from 0.5.0 or earlier? See Migrating from 0.5.0.

Usage

To get started you need to initialize an instance of an Ahoy client. The initializer takes a configuration object, which requires you to provide a baseUrl as well as an ApplicationEnvironment object.

UIDevice is main-actor isolated, so build the client there β€” from your App initializer, for example. Ahoy is an actor, so the instance can be shared anywhere once it exists.

import Ahoy
import UIKit

@MainActor
func makeAhoyClient() -> Ahoy {
    .init(
        configuration: .init(
            environment: .init(
                platform: UIDevice.current.systemName,
                appVersion: "1.0.2",
                osVersion: UIDevice.current.systemVersion
            ),
            baseUrl: URL(string: "https://your-server.com")!
        )
    )
}

Configuration

The configuation object has intelligent defaults (listed below in parens), but allows you to a to provide overrides for a series of values:

  • visitDuration (4 hours)
  • automaticVisitRenewal (true)
  • visitParams (none)
  • urlRequestHandler (URLSession.shared.data(for:))
  • Routing
    • ahoyPath ("ahoy")
    • visitsPath ("visits")
    • eventsPath ("events")

Beyond configuration, you can also provide your own AhoyTokenManager and RequestInterceptors at initialization (replaceable later via setRequestInterceptors(_:)) for custom token management and pre-flight Ahoy request modifications, respectively.

Visits

A visit is valid for visitDuration, measured from the moment it is created β€” the same fixed window used by ahoy.js and the Ahoy.visit_duration setting in the Ahoy gem. You do not need to track time between events yourself.

By default Ahoy keeps the visit current for you. It registers a new visit whenever the previous one has lapsed, at the two moments the web library would:

  • when your application becomes active (the analogue of a web page load)
  • lazily, when you track an event

So for most applications, tracking events is all you need to do β€” a visit will be created on demand:

ahoy.track("ride_details.update_driver_rating", properties: ["driver_id": 4])

If your visits carry attribution data such as utm parameters or a referrer, supply it via visitParams so the visits Ahoy creates on your behalf include it:

let ahoy: Ahoy = .init(
    configuration: .init(
        environment: environment,
        baseUrl: URL(string: "https://your-server.com")!,
        visitParams: { ["utm_source": currentAttribution.utmSource] }
    )
)

Tracking a visit manually

To track a visit yourself β€” at application launch, or to attach params to one specific visit β€” call trackVisit. Calling it while a visit is still current re-registers the same visit token, which your Ahoy server ignores.

let visit = try await ahoy.trackVisit(additionalParams: ["utm_source": "some-place"])

Callers that arrive while a visit request is already in flight await that same request rather than starting another, so concurrent tracking cannot scatter a batch of events across several visits.

To take over visit management entirely, set automaticVisitRenewal to false. Ahoy will then never create a visit on its own, and track(events:) throws AhoyError.noVisit until you have registered one. ensureCurrentVisit() is available if you want the renew-if-expired behavior at a moment of your choosing.

Tracking events

Events are sent against the current visit, renewing it first when necessary.

/// For bulk-tracking, use the `track(events:)` function
let pendingEvents: [Event] = [
    Event(name: "ride_details.update_driver_rating", properties: ["driver_id": 4]),
    Event(name: "ride_details.increase_tip", properties: ["driver_id": 4])
]

try await ahoy.track(events: pendingEvents)

/// If you prefer to fire events individually, use the fire-and-forget convenience method. It is
/// callable from anywhere β€” synchronous or asynchronous β€” and never throws.
ahoy.track("ride_details.update_driver_rating", properties: ["driver_id": 4])

/// If your event does not require properties, they can be omitted
ahoy.track("ride_details.update_driver_rating")

Attaching a user

attach(userId:) adds a user_id root key to subsequent event payloads. The attachment persists across visit renewals until you call detachUser(). (Note: this does not authenticate the user on your server.)

await ahoy.attach(userId: "12345")

Other goodies

To access the current visit directly, use your Ahoy client's currentVisit property. Additionally, you can use the headers property to add Ahoy-Visitor and Ahoy-Visit tokens to your own requests as needed.

var request = URLRequest(url: url)

for (field, value) in await ahoy.headers {
    request.setValue(value, forHTTPHeaderField: field)
}

To observe visits as they are registered, iterate visits. Each call returns an independent sequence beginning with the current visit, so several observers can iterate at once.

for await visit in ahoy.visits {
    print(visit)
}

Migrating from 0.5.0

0.6.0 replaced Combine with async/await and made Ahoy an actor. Every call site changes, but the changes are mechanical. Nothing about Event, Visit, AhoyTokenManager or RequestInterceptor changed.

Tracking

trackVisit, track(events:) and ensureCurrentVisit are async throwing functions.

// 0.5.0
ahoy.trackVisit()
    .sink(receiveCompletion: { _ in }, receiveValue: { visit in print(visit) })
    .store(in: &cancellables)

// 0.6
let visit = try await ahoy.trackVisit()
// 0.5.0
ahoy.track(events: pendingEvents)
    .sink(receiveCompletion: { _ in }, receiveValue: { pendingEvents.removeAll() })
    .store(in: &cancellables)

// 0.6
try await ahoy.track(events: pendingEvents)
pendingEvents.removeAll()

The fire-and-forget track(_:properties:) is unchanged, and still needs no await or surrounding Task:

ahoy.track("ride_details.update_driver_rating", properties: ["driver_id": 4])

Observing visits

currentVisitPublisher becomes visits. Each call returns an independent sequence that begins with the current visit, which is the replay a new subscriber used to get.

// 0.5.0
ahoy.currentVisitPublisher
    .sink(receiveCompletion: { _ in }, receiveValue: { visit in print(visit) })
    .store(in: &cancellables)

// 0.6
for await visit in ahoy.visits {
    print(visit)
}

Actor-isolated members

currentVisit, headers, attach(userId:) and detachUser() keep their shapes but now need await. requestInterceptors becomes read-only β€” Swift does not permit cross-actor property writes β€” so assign through setRequestInterceptors(_:).

// 0.5.0
let visit = ahoy.currentVisit
request.allHTTPHeaderFields = ahoy.headers
ahoy.attach(userId: "12345")
ahoy.requestInterceptors = [interceptor]

// 0.6
let visit = await ahoy.currentVisit
request.allHTTPHeaderFields = await ahoy.headers
await ahoy.attach(userId: "12345")
await ahoy.setRequestInterceptors([interceptor])

Custom request handlers

Configuration.URLRequestPublisher is removed, and urlRequestHandler is async.

// 0.5.0
let handler: Configuration.URLRequestHandler = { request in
    URLSession.shared.dataTaskPublisher(for: request).eraseToAnyPublisher()
}

// 0.6
let handler: Configuration.URLRequestHandler = { request in
    try await URLSession.shared.data(for: request)
}

Where you build the client

From 0.6.1, Ahoy reads the vendor identifier when the client is initialized, because UIDevice only surrenders it on the main actor while visitor tokens are minted elsewhere. Build the client on the main actor, as shown at the top of this README. A client built off the main actor falls back to a randomly generated visitor token, which is then persisted exactly as the vendor identifier would have been β€” so existing installs are unaffected, but fresh ones would not share the vendor identifier.

About

Analytics and attribution library for Apple platforms built on top of Ahoy for Ruby on Rails.

Resources

Stars

33 stars

Watchers

1 watching

Forks

Releases

Contributors

Languages