# Events & screen tracking

> Track custom events and screen views in your iOS app with the Grovs SDK.

Source: https://docs.grovs.io/docs/sdk/ios/analytics

## Tracking custom events

`track` records an event with optional properties and tags:

```swift
Grovs.track("purchase", properties: ["item_id": "sku-42", "price": 19.99])
Grovs.track("level_complete", properties: ["level": 5, "score": 1200])
Grovs.track("button_tap")
Grovs.track("signup", tags: ["organic", "campaign-A"])
```

How events are sent:

- Written to disk and sent in batches about every 30 seconds. Failed batches retry with increasing delays.
- Up to 1,000 events are kept on the device. Events older than seven days are dropped.
- Events tracked before authentication completes are written to disk the same way and sent after it.
- Events are also held during the device lookup for attribution, so they credit the right link. Lookups run after authentication, on every foreground and when resolving an incoming link. A stalled lookup releases them after 10 seconds.

Every custom event carries a `screen_name` property with the most recently viewed screen, for segmenting by screen. A `screen_name` you pass is never overwritten.

### Event name rules

Names must be non-empty and can't be a reserved system name:

`view`, `open`, `install`, `reinstall`, `app_open`, `time_spent`, `reactivation`, `user_referred`, `custom`, `screen_view`

Since 3.1, push, in-app and journey event names are refused too: `push_open`, `push_delivered`, `push_held`, `in_app_viewed`, `in_app_clicked`, `in_app_dismissed`, `in_app_answered`, `in_app_commented`, `in_app_held`, `journey_entered`, `journey_held`, `journey_path`.

Use [`trackScreenView`](#tracking-screen-views) for screen views, not `track("screen_view")`.

### Property values

Values can be strings, numbers, booleans, `NSNull`, or nested arrays and dictionaries of those. The SDK sanitizes each value:

- `Date`, `URL` and `UUID` become strings. Dates use `yyyy-MM-dd'T'HH:mm:ss.SSSZ`, for example `2026-09-14T10:21:33.000+0000`.
- Values JSON can't represent (`NaN`, `Infinity`, unsupported objects) are dropped **for that key only**. Other properties still send.
- Over **8 KB** of encoded properties drops all properties. The event is still recorded.
- The backend stores at most **64 property keys** per event. Keys used for filtering should be 256 bytes or less with no control characters.

### Tags

Up to 20 tags per event, each up to 255 characters (backend limit). Global tags attach to every later event:

```swift
Grovs.setGlobalTags(["beta", "experiment-A"])

Grovs.setGlobalTags(nil)  // clear
```

Global and per-event tags are merged. Over 20 combined, per-event tags are kept first and global tags fill the remaining slots.

## Tracking screen views

```swift
Grovs.trackScreenView("Checkout", properties: ["section": "payment"])
Grovs.trackScreenView("Product Detail", properties: ["product_id": "abc-123"])
```

`screen_name` is added to properties automatically. The same screen name sent twice within one second is dropped, so tab switches and rotations don't flood the dashboard.

## Automatic screen tracking

`autoTrackScreenViews` defaults to `true`. The SDK hooks `UIViewController.viewDidAppear` to track screens. It filters out system controllers (navigation, tab bar, alerts and similar), screens the OS supplies (share sheets, Safari, mail) and children embedded in your own containers, including inside a modal. The screen already visible when tracking starts is tracked too, and an alert on top doesn't hide the screen under it.

To turn it off, pass `false`:

```swift
Grovs.configure(
    APIKey: "your-api-key",
    useTestEnvironment: false,
    autoTrackScreenViews: false,
    delegate: self
)
```

`Grovs.setSDK(enabled: false)` stops automatic screen tracking along with all other collection.

### How a screen name is chosen

The first rule that applies wins:

1. [`Grovs.screenNameProvider`](#custom-resolver), if set. `.name(_:)` and `.suppress` are final; `.automatic` falls through.
2. A `GrovsScreenTracking` conformance on the view controller. Final: a name is used, `nil` skips it.
3. Children embedded in your own containers are filtered out. Only screen-level controllers continue.
4. SwiftUI hosting controllers use the view type: `CheckoutView` becomes `"Checkout"`.
5. Otherwise the cleaned class name: `CheckoutViewController` becomes `"Checkout"`.

SwiftUI screens without an unambiguous name (an `AnyView` root, for example) are dropped, not reported under a meaningless name. Annotate them with `.grovsScreen()`.

### SwiftUI

`.grovsScreen()` names a screen and suppresses automatic tracking for its hosting controller, so it's never counted twice:

```swift
struct CheckoutView: View {
    var body: some View {
        VStack {
            // ...
        }
        .grovsScreen("Checkout")
    }
}
```

With no name, it uses the **file name**: `CheckoutView.swift` becomes `"Checkout"`:

```swift
.grovsScreen()
```

> **Warning:** The name comes from the file, not the view type. If several views share a file, or the file name doesn't match the screen, pass a name.

Automatic tracking can't see a custom router that swaps screens inside one hosting controller. Add `.grovsScreen()` to each screen.

### UIKit

Conform to `GrovsScreenTracking` to name a controller, or return `nil` to skip it:

```swift
extension CheckoutViewController: GrovsScreenTracking {
    var grovsScreenName: String? { "Checkout" }
}
```

### Custom resolver

`Grovs.screenNameProvider` runs before every other rule and covers all view controllers:

```swift
Grovs.screenNameProvider = { viewController in
    switch viewController {
    case is LegalViewController:
        return .suppress
    case let vc as ProductViewController:
        return .name("Product \(vc.categoryName)")
    default:
        return .automatic
    }
}
```

Return `.name(_:)` to override, `.suppress` to skip, or `.automatic` to fall through to the SDK. The closure runs on the main thread. Keep it fast and don't retain the view controller.

Visibility filtering runs first. Controllers that aren't the frontmost screen, or use `.grovsScreen()`, never reach the resolver.

## Screen name aliases

Map class names to friendlier dashboard names:

```swift
Grovs.setScreenAliases([
    "CheckoutViewController": "Checkout Page",
    "PDPViewController": "Product Detail"
])
```

Aliases sync after authentication. A failed sync retries at the next foreground.

## Next steps

- [Track revenue and purchases →](https://docs.grovs.io/docs/sdk/ios/revenue)
- [Full API reference →](https://docs.grovs.io/docs/sdk/ios/api-reference)
