# Documentation (/docs) Privacy-first mobile analytics for modern applications. Track user behavior, sessions, and events without compromising user privacy. ## Get Started Choose your platform to begin integrating Phase Analytics: * [**Expo**](/docs/get-started/expo) - Complete setup guide for Expo applications * [**React Native**](/docs/get-started/react-native) - Integration guide for React Native apps * [**Swift**](/docs/get-started/swift) - iOS SDK for SwiftUI and UIKit applications * [**Unity**](/docs/get-started/unity) - Unity SDK for iOS and Android games ## Concepts Learn about Phase Analytics features and workflows: * [**Query**](/docs/concepts/query) - Read-only SQL against events, users, and sessions * [**Team & Billing**](/docs/concepts/team-billing) - Manage team members and subscriptions * [**Publishing**](/docs/concepts/publishing) - App store privacy requirements and disclosures ## Privacy & Legal Understand how Phase handles your data: * [**Stored Data**](/docs/privacy/stored-data) - Data storage, retention, and GDPR rights * [**Privacy Policy**](/docs/privacy/privacy-policy) - Our privacy commitments * [**Terms of Service**](/docs/privacy/terms-of-service) - Terms and conditions ## Need Help? Contact us at [support@phase.sh](mailto:support@phase.sh) for assistance. # Swift (/docs/get-started/swift) import { Step, Steps } from 'fumadocs-ui/components/steps'; import { TypeTable } from 'fumadocs-ui/components/type-table'; import { Tab, Tabs, TabsList, TabsTrigger, TabsContent } from 'fumadocs-ui/components/tabs'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; ## Installation ### Add Swift Package Add Phase Analytics to your project using Swift Package Manager: 1. In Xcode, go to **File → Add Package Dependencies** 2. Enter the repository URL:

[https://github.com/Phase-Analytics/Phase-Swift](https://github.com/Phase-Analytics/Phase-Swift)

3. Select the latest version 4. Add to your target **Or add to Package.swift:** }> ```swift dependencies: [ .package(url: "https://github.com/Phase-Analytics/Phase-Swift", from: "0.1.9") ] ``` **Requirements:** * iOS 15.0+ or macOS 12.0+ * Swift 6.0+
### Get Your API Key 1. Sign in to [Phase Dashboard](https://phase.sh/dashboard) 2. Create a new project or select an existing one 3. Open API Keys tab 4. Copy your API Key (starts with `phase_`)
## Setup SwiftUI UIKit ### SwiftUI Wrap your app with the `Phase` view to initialize the SDK: }> ```swift import SwiftUI import PhaseAnalytics @main struct MyApp: App { var body: some Scene { WindowGroup { Phase(apiKey: "phase_xxx") { ContentView() } } } } ``` ### UIKit Initialize the SDK in your AppDelegate: }> ```swift import UIKit import PhaseAnalytics @main class AppDelegate: UIResponder, UIApplicationDelegate { func application( _ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? ) -> Bool { Task { try await PhaseSDK.shared.initialize(apiKey: "phase_xxx") } return true } } ``` The SDK initialization is asynchronous. You must call `identify()` before tracking events. ## Configuration The `Phase` view and `initialize()` method accept the following parameters: ## Usage ### Identify User **Required:** Call `identify()` before using any other methods. This registers the device and starts a session. }> ```swift import SwiftUI import PhaseAnalytics struct ContentView: View { var body: some View { Text("Hello, World!") .onAppear { Task { // Initialize analytics - no PII collected by default await PhaseSDK.shared.identify() } } } } ``` **Privacy by default:** * No personal data is collected without explicit properties * Device ID is auto-generated and stored locally * Only technical metadata is collected (OS version, platform, locale, app version) **Adding custom properties:** You can optionally attach user properties. **Important:** If you add PII (personally identifiable information), ensure you have proper user consent: }> ```swift // After user login and consent await PhaseSDK.shared.identify([ "user_id": "123", "plan": "premium", "beta_tester": true ]) // ⚠️ If adding PII, get consent first let hasConsent = await customGetUserConsent() if hasConsent { await PhaseSDK.shared.identify([ "email": "user@example.com", "name": "John Doe" ]) } ``` Properties must be primitives: `String`, `Int`, `Double`, `Bool`, or `nil`. ### Track Events Track custom events with optional parameters. **Note:** `identify()` must be called first. }> ```swift // Event without parameters track("app_opened") // Event with parameters track("purchase_completed", [ "amount": 99.99, "currency": "USD", "product_id": "premium_plan" ]) // Using instance method PhaseSDK.shared.track("button_clicked", params: ["button_id": "submit"]) ``` **Event naming rules:** * Alphanumeric characters, underscores (`_`), hyphens (`-`), periods (`.`), forward slashes (`/`), and spaces * 1-256 characters * Examples: `purchase`, `user.signup`, `payment/success`, `Button Clicked` **Event parameters:** * Flat primitive dictionary only * Values must be `String`, `Int`, `Double`, `Bool`, or `nil` * Max 32 keys * Key max 32 characters * String value max 256 characters * Serialized payload max 8 KB * Empty dictionaries are normalized away ### Screen Tracking SwiftUI Manual Use the `.phaseScreen()` modifier to automatically track screen views: }> ```swift import SwiftUI import PhaseAnalytics struct ProfileView: View { let userID: String var body: some View { VStack { Text("Profile") } .phaseScreen("ProfileView", params: ["user_id": userID]) } } ``` **How it works:** Tracks screen view when the view first appears. Screen names are normalized automatically (e.g., `"ProfileView"` → `"/profile-view"`) with CamelCase converted to kebab-case. Supports optional parameters. Manually track screens using `trackScreen()`: }> ```swift // Using global function trackScreen("/profile", ["user_id": "123"]) // Using instance method PhaseSDK.shared.trackScreen("/settings", params: nil) ``` ## Type Reference ### DeviceProperties Custom user/device attributes passed to `identify()`: ### EventParams Event parameters passed to `track()`: ## How It Works ### Offline Support Events are queued locally using `UserDefaults` when offline. The queue automatically syncs when connection is restored. ### Privacy * No personal data is collected by default * Device IDs are generated locally and stored persistently * Geolocation is resolved server-side from IP address (disable with properties) * All data collection is optional via configuration ### Performance * Offline events are batched and sent asynchronously * Network state is monitored automatically * Failed requests retry with exponential backoff * Maximum batch size: 1000 events * Thread-safe with Swift 6 concurrency # React Native (/docs/get-started/react-native) import { Step, Steps } from 'fumadocs-ui/components/steps'; import { TypeTable } from 'fumadocs-ui/components/type-table'; import { Tab, Tabs, TabsList, TabsTrigger, TabsContent } from 'fumadocs-ui/components/tabs'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; ## Installation ### Install the SDK npm npm bun bun yarn yarn pnpm pnpm ```bash npm install phase-analytics ``` ```bash bun add phase-analytics ``` ```bash yarn add phase-analytics ``` ```bash pnpm add phase-analytics ``` ### Install Required Peer Dependencies Phase Analytics requires the following React Native packages to function properly: npm npm bun bun yarn yarn pnpm pnpm ```bash npm install @react-native-async-storage/async-storage @react-native-community/netinfo react-native-device-info react-native-localize @react-navigation/native ``` ```bash bun add @react-native-async-storage/async-storage @react-native-community/netinfo react-native-device-info react-native-localize @react-navigation/native ``` ```bash yarn add @react-native-async-storage/async-storage @react-native-community/netinfo react-native-device-info react-native-localize @react-navigation/native ``` ```bash pnpm add @react-native-async-storage/async-storage @react-native-community/netinfo react-native-device-info react-native-localize @react-navigation/native ``` These dependencies are **required** for the SDK to work correctly. The SDK uses: * `@react-native-async-storage/async-storage` - Local storage for offline event queuing * `@react-native-community/netinfo` - Network state monitoring * `react-native-device-info` - Device information collection * `react-native-localize` - Locale and timezone detection * `@react-navigation/native` - Automatic screen tracking (only needed if using `trackNavigation`) ### Install iOS Dependencies For iOS, install CocoaPods dependencies: ```bash cd ios && pod install ``` ### Get Your API Key 1. Sign in to [Phase Dashboard](https://phase.sh/dashboard) 2. Create a new project or select an existing one 3. Open API Keys tab 4. Copy your API Key (starts with `phase_`) ## Setup Wrap your app with the `PhaseProvider` component to initialize the SDK: }> ```tsx import { PhaseProvider } from 'phase-analytics/react-native'; import { NavigationContainer } from '@react-navigation/native'; export default function App() { return ( ); } ``` The `PhaseProvider` only initializes the SDK. You must call `Phase.identify()` before tracking events. ## Configuration The `PhaseProvider` component accepts the following props: ## Usage ### Identify User **Required:** Call `Phase.identify()` before using any other methods. This registers the device and starts a session. }> ```tsx import { Phase } from 'phase-analytics/react-native'; import { useEffect } from 'react'; export default function App() { useEffect(() => { // Initialize analytics - no PII collected by default Phase.identify(); }, []); return ; } ``` **Privacy by default:** * No personal data is collected without explicit properties * Device ID is auto-generated and stored locally * Only technical metadata is collected (OS version, platform, locale) **Adding custom properties:** You can optionally attach user properties. **Important:** If you add PII (personally identifiable information), ensure you have proper user consent: }> ```tsx // After user login and consent await Phase.identify({ user_id: '123', plan: 'premium', beta_tester: true }); // ⚠️ If adding PII, get consent first const hasConsent = await customGetUserConsent(); if (hasConsent) { await Phase.identify({ email: 'user@example.com', name: 'John Doe' }); } ``` Properties must be primitives: `string`, `number`, `boolean`, or `null`. ### Track Events Track custom events with optional parameters. **Note:** `Phase.identify()` must be called first. }> ```tsx // Event without parameters Phase.track('app_opened'); // Event with parameters Phase.track('purchase_completed', { amount: 99.99, currency: 'USD', product_id: 'premium_plan' }); ``` **Event naming rules:** * Alphanumeric characters, underscores (`_`), hyphens (`-`), periods (`.`), forward slashes (`/`), and spaces * 1-256 characters * Examples: `purchase`, `user.signup`, `payment/success`, `Button Clicked` **Event parameters:** * Flat primitive object only * Values must be `string`, `number`, `boolean`, or `null` * Max 32 keys * Key max 32 characters * String value max 256 characters * Serialized payload max 8 KB * Empty objects are normalized away ### Automatic Screen Tracking Enable automatic screen tracking by setting `trackNavigation` to `true` and passing a `navigationRef`: }> ```tsx import { PhaseProvider } from 'phase-analytics/react-native'; import { NavigationContainer, useNavigationContainerRef } from '@react-navigation/native'; import { createNativeStackNavigator } from '@react-navigation/native-stack'; const Stack = createNativeStackNavigator(); export default function App() { const navigationRef = useNavigationContainerRef(); return ( ); } ``` **How it works:** * Requires `navigationRef` from `useNavigationContainerRef()` hook * Pass the ref to both `NavigationContainer` and `PhaseProvider` * Automatically tracks screen views on navigation state changes * Screen names are converted from route names * Works with nested navigators and dynamic routes **Important:** The `navigationRef` must be passed to both `NavigationContainer` and `PhaseProvider` for automatic tracking to work. ## Type Reference ### DeviceProperties Custom user/device attributes passed to `Phase.identify()`: ### EventParams Event parameters passed to `Phase.track()`: ## How It Works ### Offline Support Events are queued locally using `@react-native-async-storage/async-storage` when offline. The queue automatically syncs when connection is restored. ### Privacy * No personal data is collected by default * Device IDs are generated locally and stored persistently * Geolocation is resolved server-side from IP address (disable with properties) * All data collection is optional via configuration ### Performance * Offline events are batched and sent asynchronously * Network state is monitored via `@react-native-community/netinfo` * Failed requests retry with exponential backoff * Maximum batch size: 1000 events # Unity (/docs/get-started/unity) import { Step, Steps } from 'fumadocs-ui/components/steps'; import { TypeTable } from 'fumadocs-ui/components/type-table'; import { Tab, Tabs, TabsList, TabsTrigger, TabsContent } from 'fumadocs-ui/components/tabs'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; ## Installation Phase Analytics ships as a **Unity Package Manager (UPM)** package from the [Phase monorepo](https://github.com/Phase-Analytics/Phase). There is no `.unitypackage` download. ### Add the UPM package manifest.json Package Manager UI Add to `Packages/manifest.json` (recommended for teams and CI): ```json { "dependencies": { "com.phase.analytics": "https://github.com/Phase-Analytics/Phase.git?path=packages/phase-unity#v0.1.11" } } ``` Return to Unity and let Package Manager resolve the dependency. 1. **Window → Package Manager** 2. **+ → Add package from git URL…** 3. Paste: ``` https://github.com/Phase-Analytics/Phase.git?path=packages/phase-unity#v0.1.11 ``` 4. Select **Add** Pin the release tag: ``` #v0.1.11 ``` ### Install required dependency The package declares `com.unity.nuget.newtonsoft-json` in `package.json`. Unity should install it automatically when you add Phase Analytics. If Package Manager shows a missing dependency warning: 1. Open **Package Manager** 2. Select **Packages: Unity Registry** 3. Search **Newtonsoft Json** 4. Install **com.unity.nuget.newtonsoft-json** (3.2.1+) The SDK uses Newtonsoft.Json for batch payloads and offline queue serialization. `Runtime/link.xml` preserves the assembly for IL2CPP builds. ### Verify the import After resolution, confirm the package is healthy **before** wiring game code: 1. **Package Manager** lists `Phase Analytics` (`com.phase.analytics`) 2. **Console** has **no** warnings like `has no meta file, but it's in an immutable folder` 3. **Console** has **no** `CS8773` (file-scoped namespace) errors 4. Assembly **`Phase.Analytics`** appears under **Project → Assemblies** (or compiles without errors) If any check fails, upgrade to `#v0.1.11` or newer, remove `Library/PackageCache/com.phase.analytics@*`, and re-import. Do not patch files inside `PackageCache` by hand. ### Get Your API Key 1. Sign in to [Phase Dashboard](https://phase.sh/dashboard) 2. Create a new project or select an existing one 3. Open **API Keys** tab 4. Copy your API Key (starts with `phase_`) ## Setup Initialize once at startup, then call `IdentifyAsync` before tracking. Call `InitializeAsync` and `IdentifyAsync` from the Unity main thread (`Start`, `Awake`, or a coroutine started there). With `AutoBootstrap` enabled (default), the SDK spawns a `DontDestroyOnLoad` `PhaseLifecycleHook` that flushes on pause/quit and resumes sessions when the app returns. Bootstrap script Package sample Editor / dev Attach a bootstrap `MonoBehaviour` to a GameObject in your first scene (or a persistent loader scene): }> ```csharp using Phase.Analytics; using Phase.Analytics.Config; using Phase.Analytics.Models; using UnityEngine; public sealed class GameBootstrap : MonoBehaviour { [SerializeField] private string apiKey = "phase_xxx"; private async void Start() { var ok = await PhaseAnalytics.InitializeAsync(new PhaseConfig { ApiKey = apiKey, LogLevel = LogLevel.Info, }); if (!ok) { Debug.LogWarning("[Phase] Initialize failed. Check API key and network."); return; } await PhaseAnalytics.IdentifyAsync(); PhaseAnalytics.Track("app_opened"); } } ``` `InitializeAsync` is idempotent. `IdentifyAsync` must succeed before events are sent. Both must run on the main thread so Unity APIs and the lifecycle hook stay safe after `await`. Import the bundled sample from Package Manager: 1. **Window → Package Manager** 2. Select **Phase Analytics** in the left list 3. Open **Samples** 4. Import **Phase Analytics Sample** 5. Add `PhaseAnalyticsBootstrap` to a scene and set your API key in the Inspector The sample lives under `Samples~/PhaseAnalyticsSample` in the package (not copied into your `Assets/` until imported). For day-to-day Editor work without hitting production: }> ```csharp await PhaseAnalytics.InitializeAsync(new PhaseConfig { ApiKey = "phase_xxx", DisableInEditor = true, DebugData = true, LogLevel = LogLevel.Info, }); ``` **DisableInEditor** skips network I/O in the Unity Editor (calls still queue logic where applicable). **DebugData** sends `x-phase-debug-data: 1` so events are marked as debug in the dashboard. **Always** validate on a **physical device** with an IL2CPP **Release** build before shipping. ## Configuration `PhaseConfig` is passed to `PhaseAnalytics.InitializeAsync()`: **Platform:** on iOS/Android player builds, identify sends `platform: "ios"` or `"android"`. In the Editor it is `null`. ### Threading and HTTP Call `InitializeAsync` and `IdentifyAsync` from the Unity main thread (`Start`, `Awake`, or a coroutine started there). The SDK uses `ConfigureAwait(false)` internally so continuations often run on the thread pool. Unity-only APIs (`GameObject`, `Application`, `UnityWebRequest`) are gated to the main thread: lifecycle hook creation, device info snapshot, network reachability polling, and optional `UnityWebRequest` transport. Default HTTP is **`System.Net.Http`** (`SystemNetHttpTransport`), which is safe from pool threads. Set `UseUnityWebRequestTransport = true` only if you need `UnityWebRequest`; those calls are queued to `PhaseLifecycleHook.Update`. ## Usage ### Identify User **Required:** Call `IdentifyAsync()` before `Track()`. This registers the device and starts a session. }> ```csharp // No PII by default await PhaseAnalytics.IdentifyAsync(); // Optional properties after login await PhaseAnalytics.IdentifyAsync(new DeviceProperties { ["user_id"] = "123", ["plan"] = "premium", ["beta_tester"] = true, }); ``` **Privacy by default:** * No personal data is collected without explicit properties * Device ID is generated locally (ULID) and stored under `Application.persistentDataPath` * Only technical metadata is collected when enabled (OS, model, app version, locale) **Adding custom properties:** You can attach user/device properties. **Important:** If you add PII (personally identifiable information), ensure you have proper user consent: }> ```csharp // After user login and consent await PhaseAnalytics.IdentifyAsync(new DeviceProperties { ["user_id"] = accountId, ["plan"] = "premium", }); // If adding PII, get consent first if (userHasConsentedToAnalytics) { await PhaseAnalytics.IdentifyAsync(new DeviceProperties { ["email"] = user.Email, ["display_name"] = user.DisplayName, }); } ``` Properties must be flat primitives: `string`, `number`, `bool`, or `null`. ### Track Events Track custom events with optional parameters. **Note:** `IdentifyAsync()` must complete successfully first. }> ```csharp // Event without parameters PhaseAnalytics.Track("app_opened"); // Event with parameters PhaseAnalytics.Track("purchase_completed", new EventParams { ["amount"] = 99.99, ["currency"] = "USD", ["product_id"] = "premium_plan", }); // Level / gameplay PhaseAnalytics.Track("level_complete", new EventParams { ["level"] = 5, ["score"] = 1200, ["duration_sec"] = 42.5, }); ``` `Track` is **non-blocking** (fire-and-forget). Failures are logged when `LogLevel` is enabled and events are queued for retry when appropriate. **Event naming rules:** * Alphanumeric characters, underscores (`_`), hyphens (`-`), periods (`.`), forward slashes (`/`), and spaces * 1–256 characters * Examples: `purchase`, `user.signup`, `payment/success`, `Button Clicked` **Event parameters:** * Flat primitive object only * Values must be `string`, `number`, `bool`, or `null` * Max 32 keys * Key max 32 characters * String value max 256 characters * Serialized payload max 8 KB * Empty objects are normalized away ### Scenes and levels (manual events) Model scenes and levels with `Track`: }> ```csharp using Phase.Analytics; using Phase.Analytics.Models; using UnityEngine; using UnityEngine.SceneManagement; public sealed class SceneAnalytics : MonoBehaviour { private void OnEnable() => SceneManager.sceneLoaded += OnSceneLoaded; private void OnDisable() => SceneManager.sceneLoaded -= OnSceneLoaded; private void OnSceneLoaded(Scene scene, LoadSceneMode mode) { PhaseAnalytics.Track("scene_loaded", new EventParams { ["scene"] = scene.name, ["mode"] = mode.ToString(), }); } } ``` ### Clear local data Wipes persisted SDK storage on device (GDPR-style). Does **not** delete server-side data. }> ```csharp await PhaseAnalytics.ClearLocalDataAsync(); // Re-initialize before tracking again await PhaseAnalytics.InitializeAsync(new PhaseConfig { ApiKey = "phase_xxx" }); await PhaseAnalytics.IdentifyAsync(); ``` ### API reference | Member | Description | | ---------------------------------- | -------------------------------------------------------------------------------------------------------- | | `InitializeAsync(PhaseConfig)` | Sets up storage, HTTP, managers, optional lifecycle hook. Returns `false` on invalid config. Idempotent. | | `IdentifyAsync(DeviceProperties?)` | Registers device and starts session. Required before reliable tracking. | | `Track(string, EventParams?)` | Queues a custom event. Requires prior identify. | | `ClearLocalDataAsync()` | Deletes local Phase storage directory. | | `IsInitialized` | `true` after successful `InitializeAsync`. | | `IsIdentified` | `true` after successful `IdentifyAsync`. | ## Type Reference ### DeviceProperties Custom user/device attributes passed to `IdentifyAsync()`: ### EventParams Event parameters passed to `Track()`: ## How It Works ### Lifecycle and sessions When `AutoBootstrap` is `true`, `PhaseLifecycleHook` is created at the start of `InitializeAsync` (before async I/O): * Listens for `OnApplicationPause` / focus changes * Flushes the offline queue when the app backgrounds or quits * Resumes session handling when the app returns * Polls network reachability on the main thread (`Update`) Session pings run on a timer while identified. Events sent without a valid session are rejected client-side until `IdentifyAsync` succeeds. ### Offline Support Events are stored under: `Application.persistentDataPath/phase-analytics-data/` When offline or when requests fail, items stay in the queue. The SDK flushes after `IdentifyAsync` when the network is available and on lifecycle boundaries. Large backlogs use gzip batch upload (100+ queued items). ### Privacy * No personal data is collected by default * Device IDs are generated locally and stored persistently * Geolocation is resolved server-side from IP when `UserLocale` is enabled * No IDFA or advertising identifiers in v1 * All metadata collection is optional via `DeviceInfo` / `UserLocale` ### Performance * `Track` does not block the game thread * Rate limit: 15 events per second * 50 ms deduplication window for identical event name + parameters * Offline queue capped at 1000 items; batches up to 1000 items per request * Failed requests retry with exponential backoff (HTTP layer) ## Production builds (IL2CPP) Editor Play Mode is **not** enough to validate analytics. Ship only after a device smoke test. ### Project settings * **Scripting backend:** IL2CPP (recommended for iOS; typical for Android release) * **Target:** iOS 12+ / Android API 21+ * Do not strip `Phase.Analytics` or Newtonsoft — the package includes `Runtime/link.xml` ### Build and smoke test 1. Build **Release** to a physical device 2. Cold start → confirm `InitializeAsync` + `IdentifyAsync` in logs (if `LogLevel.Info`) 3. Trigger a few `Track` calls 4. Background the app (home button) to force flush 5. Open [Phase Dashboard](https://phase.sh/dashboard) and confirm events within a few minutes iOS Android Use a **Development** or **Release** IL2CPP build on hardware (Simulator can miss networking edge cases). Ensure App Transport Security allows your `BaseUrl` (default `https://api.phase.sh` is fine). **Offline test:** enable airplane mode, `Track` events, disable airplane mode, background the app, verify flush in the dashboard. Use an IL2CPP **Release** build on a physical device. Confirm `INTERNET` permission (Unity adds it by default for network builds). **Offline test:** same flow as iOS after reconnecting. ## Troubleshooting ### Package import (git UPM) | Issue | What to check | | --------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | | `has no meta file, but it's in an immutable folder` | Upgrade to `#v0.1.0`+. Git UPM cannot generate `.meta` at import time. | | `CS8773` file-scoped namespace | Upgrade to `#v0.1.2`+ (`Runtime/csc.rsp`). Asmdef `langVersion` alone is insufficient on Unity 6 git UPM. | | `CS0246` `Timer` in `UnityNetworkMonitor` | Upgrade to `#v0.1.3`+ (`using System.Threading`) | | `CS0103` `ValidationConstants` | Upgrade to `#v0.1.4`+ | | `CS0104` ambiguous `Logger` | Upgrade to `#v0.1.4`+ (`PhaseLifecycleHook`) | | `CS0246` / `Phase` not found | `Phase.Analytics` assembly did not compile. Fix import/compile errors first; do not enable game `asmdef` references until the package builds. | | Package stuck on old version | Remove `Library/PackageCache/com.phase.analytics@*`, bump hash/tag in `manifest.json`, restart Unity. | ### Runtime | Issue | What to check | | ------------------------------------------------------ | ------------------------------------------------------------------------------------------------------ | | `Internal_CreateGameObject` / main thread | Fixed in `#v0.1.7+`. Call `InitializeAsync` from main thread (`Start` / coroutine). | | `Create can only be called from the main thread` (UWR) | Upgrade to `#v0.1.9+` (default `System.Net.Http`). | | `Session not found` / batch dropped after init | Failed session/API from UWR off-thread before `#v0.1.9`. Upgrade to `#v0.1.9`. | | `InitializeAsync` returns `false` | API key must start with `phase_`; `BaseUrl` must be HTTPS unless `AllowInsecureDev` is `true` | | No events in dashboard | Call `IdentifyAsync` before `Track`; test on device, not Editor-only (`DisableInEditor` skips network) | | HTTP 401 | Invalid or revoked API key | | Queue not flushing | Device online, identified, and app backgrounded or session active; check `LogLevel.Info` | | Duplicate events | 50 ms dedupe collapses identical name + params; intentional for burst clicks | ### IL2CPP / release | Issue | What to check | | ------------------------------ | ---------------------------------------------------------------------------------- | | Stripping / missing types | Keep package `link.xml`; do not strip `Phase.Analytics` or Newtonsoft | | Newtonsoft errors at runtime | Ensure `com.unity.nuget.newtonsoft-json` is installed | | Works in Editor, not on device | Run IL2CPP Release on hardware; verify ATS (iOS) and network permissions (Android) | ## Differences from other SDKs | Feature | Expo / React Native | Unity | | ---------------- | --------------------------------- | --------------------------------------------- | | Screen tracking | Automatic (optional, Expo Router) | Manual `Track` events | | `platform` field | Sent (`ios` / `android`) | Sent on mobile player builds | | Distribution | npm `phase-analytics` | Git UPM `#v0.1.11` | | Init pattern | Provider / hook | `InitializeAsync` + `MonoBehaviour` bootstrap | For the same event schema and dashboard, keep event names and parameter keys aligned across your mobile clients. # Expo (/docs/get-started/expo) import { Step, Steps } from 'fumadocs-ui/components/steps'; import { TypeTable } from 'fumadocs-ui/components/type-table'; import { Tab, Tabs, TabsList, TabsTrigger, TabsContent } from 'fumadocs-ui/components/tabs'; import { CodeBlock, Pre } from 'fumadocs-ui/components/codeblock'; ## Installation ### Install the SDK npm npm bun bun yarn yarn pnpm pnpm ```bash npm install phase-analytics ``` ```bash bun add phase-analytics ``` ```bash yarn add phase-analytics ``` ```bash pnpm add phase-analytics ``` ### Install Required Peer Dependencies Phase Analytics requires the following Expo packages to function properly: npm npm bun bun yarn yarn pnpm pnpm ```bash npx expo install @react-native-async-storage/async-storage @react-native-community/netinfo expo-application expo-device expo-localization expo-router ``` ```bash bunx expo install @react-native-async-storage/async-storage @react-native-community/netinfo expo-application expo-device expo-localization expo-router ``` ```bash yarn dlx expo install @react-native-async-storage/async-storage @react-native-community/netinfo expo-application expo-device expo-localization expo-router ``` ```bash pnpm dlx expo install @react-native-async-storage/async-storage @react-native-community/netinfo expo-application expo-device expo-localization expo-router ``` These dependencies are **required** for the SDK to work correctly. The SDK uses: * `@react-native-async-storage/async-storage` - Local storage for offline event queuing * `@react-native-community/netinfo` - Network state monitoring * `expo-application` - App version collection (`app_version`) * `expo-device` - Device information collection * `expo-localization` - Locale and timezone detection * `expo-router` - Automatic screen tracking ### Get Your API Key 1. Sign in to [Phase Dashboard](https://phase.sh/dashboard) 2. Create a new project or select an existing one 3. Open API Keys tab 4. Copy your API Key (starts with `phase_`) ## Setup Wrap your app with the `PhaseProvider` component in your root layout to initialize the SDK: }> ```tsx import { PhaseProvider } from 'phase-analytics/expo'; export default function RootLayout() { return ( ); } ``` The `PhaseProvider` only initializes the SDK. You must call `Phase.identify()` before tracking events. ## Configuration The `PhaseProvider` component accepts the following props: ## Usage ### Identify User **Required:** Call `Phase.identify()` before using any other methods. This registers the device and starts a session. }> ```tsx import { Phase } from 'phase-analytics/expo'; import { useEffect } from 'react'; export default function App() { useEffect(() => { // Initialize analytics - no PII collected by default Phase.identify(); }, []); return ; } ``` **Privacy by default:** * No personal data is collected without explicit properties * Device ID is auto-generated and stored locally * Only technical metadata is collected (OS version, platform, locale) **Adding custom properties:** You can optionally attach user properties. **Important:** If you add PII (personally identifiable information), ensure you have proper user consent: }> ```tsx // After user login and consent await Phase.identify({ user_id: '123', plan: 'premium', beta_tester: true }); // ⚠️ If adding PII, get consent first const hasConsent = await customGetUserConsent(); if (hasConsent) { await Phase.identify({ email: 'user@example.com', name: 'John Doe' }); } ``` Properties must be primitives: `string`, `number`, `boolean`, or `null`. ### Track Events Track custom events with optional parameters. **Note:** `Phase.identify()` must be called first. }> ```tsx // Event without parameters Phase.track('app_opened'); // Event with parameters Phase.track('purchase_completed', { amount: 99.99, currency: 'USD', product_id: 'premium_plan' }); ``` **Event naming rules:** * Alphanumeric characters, underscores (`_`), hyphens (`-`), periods (`.`), forward slashes (`/`), and spaces * 1-256 characters * Examples: `purchase`, `user.signup`, `payment/success`, `Button Clicked` **Event parameters:** * Flat primitive object only * Values must be `string`, `number`, `boolean`, or `null` * Max 32 keys * Key max 32 characters * String value max 256 characters * Serialized payload max 8 KB * Empty objects are normalized away ### Automatic Screen Tracking Enable automatic screen tracking by setting `trackNavigation` to `true` in your root layout. The SDK uses Expo Router's navigation hooks to track screen changes automatically: }> ```tsx import { PhaseProvider } from 'phase-analytics/expo'; import { Stack } from 'expo-router'; export default function RootLayout() { return ( ); } ``` **How it works:** * Wraps any Expo Router component (`Stack`, `Tabs`, `Drawer`, etc.) * Automatically tracks screen views on route changes * Screen names are derived from pathname * Works with nested routes and dynamic segments No additional setup required, just wrap your navigation structure with `PhaseProvider`. ## Type Reference ### DeviceProperties Custom user/device attributes passed to `Phase.identify()`: ### EventParams Event parameters passed to `Phase.track()`: ## How It Works ### Offline Support Events are queued locally using `@react-native-async-storage/async-storage` when offline. The queue automatically syncs when connection is restored. ### Privacy * No personal data is collected by default * Device IDs are generated locally and stored persistently * Geolocation is resolved server-side from IP address (disable with properties) * All data collection is optional via configuration ### Performance * Offline events are batched and sent asynchronously * Network state is monitored via `@react-native-community/netinfo` * Failed requests retry with exponential backoff * Maximum batch size: 1000 events # Usage (/docs/public-api/usage) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Tab, Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs'; Use the Phase API to read analytics reports from your own scripts, dashboards, and backend services. API keys are app-scoped and read-only. ## Get your API key 1. Sign in to [Phase Dashboard](https://phase.sh/dashboard). 2. Create a new project or select an existing one. 3. Open **Application → API Keys**. 4. Copy your API key. Keys for this API start with `phase_public_`. 5. Copy the **App ID** from the same screen. You need it in every request path. ## Base URL ```txt https://api.phase.sh/public-api/v1 ``` ## First request cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//reports/events/overview" \ -H "Authorization: Bearer phase_public_your_key" ``` ```ts const response = await fetch( 'https://api.phase.sh/public-api/v1/apps//reports/events/overview', { headers: { Authorization: 'Bearer phase_public_your_key', }, } ); if (!response.ok) { throw new Error(`Request failed with ${response.status}`); } const data = await response.json(); console.log(data); ``` ## Example response ```json { "totalEvents": 216, "events24h": 0, "totalEventsChange24h": 0, "events24hChange": 0, "meta": { "generatedAt": "2026-04-06T15:02:52.814Z", "consistency": "eventual", "identityModel": "user" } } ``` ## Request format ### Path parameter * `appId` * Type: `string` * Required: yes * Value: the App ID shown in **Application → API Keys** ### Header * `Authorization` * Type: `string` * Required: yes * Format: `Bearer phase_public_your_key` * Value: your API key ### Query parameters * `startDate` * Type: `string` * Required: no * Format: ISO 8601 timestamp * Example: `2026-01-15T00:00:00.000Z` * Used by: timeseries endpoints, date-filtered breakdown endpoints, and funnel endpoints * `endDate` * Type: `string` * Required: no * Format: ISO 8601 timestamp * Example: `2026-02-15T23:59:59.999Z` * Used by: timeseries endpoints, date-filtered breakdown endpoints, and funnel endpoints * `metric` * Type: `string` * Required: no * Used by: timeseries endpoints * Allowed values depend on the endpoint: * Sessions: `sessionCount`, `avgSessionDuration`, `bounceRate` * Users: `activeUsers`, `totalUsers`, `newUsers` * `dimension` * Type: `string` * Required: no * Used by: breakdown endpoints * Allowed values depend on the endpoint: * Events: `eventName`, `screenName` * Sessions: `platform`, `country` * Users: `platform`, `country` * `limit` * Type: `number` * Required: no * Used by: breakdown endpoints * Range: `1` to `50` * Default: `10` The maximum supported date range is 365 days. ## Events Use event reports for headline counts, daily trends, and top events or screens. ### Endpoints * `GET /apps/:appId/reports/events/overview` * Returns total events, 24h events, and change values. * `GET /apps/:appId/reports/events/timeseries` * Returns daily event counts. * Supports `startDate` and `endDate`. * `GET /apps/:appId/reports/events/breakdown` * Returns event counts grouped by `eventName` or `screenName`. * Supports `dimension`, `startDate`, `endDate`, and `limit`. ### Example: event breakdown cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//reports/events/breakdown?dimension=eventName&startDate=2026-01-15T00:00:00.000Z&endDate=2026-02-15T23:59:59.999Z&limit=10" \ -H "Authorization: Bearer phase_public_your_key" ``` ```ts const params = new URLSearchParams({ dimension: 'eventName', startDate: '2026-01-15T00:00:00.000Z', endDate: '2026-02-15T23:59:59.999Z', limit: '10', }); const response = await fetch( `https://api.phase.sh/public-api/v1/apps//reports/events/breakdown?${params}`, { headers: { Authorization: 'Bearer phase_public_your_key', }, } ); const data = await response.json(); ``` ## Sessions Use session reports for totals, duration, bounce rate, and session distribution by platform or location. ### Endpoints * `GET /apps/:appId/reports/sessions/overview` * Returns total sessions, active sessions, average session duration, and bounce rate. * `GET /apps/:appId/reports/sessions/timeseries` * Returns daily session metrics. * Supported `metric` values: `sessionCount`, `avgSessionDuration`, `bounceRate`. * Supports `startDate`, `endDate`, and `metric`. * `GET /apps/:appId/reports/sessions/breakdown` * Returns session counts grouped by `platform` or `country`. * Supports `dimension`, `startDate`, `endDate`, and `limit`. ### Example: session timeseries cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//reports/sessions/timeseries?metric=sessionCount&startDate=2026-01-15T00:00:00.000Z&endDate=2026-02-15T23:59:59.999Z" \ -H "Authorization: Bearer phase_public_your_key" ``` ```ts const params = new URLSearchParams({ metric: 'sessionCount', startDate: '2026-01-15T00:00:00.000Z', endDate: '2026-02-15T23:59:59.999Z', }); const response = await fetch( `https://api.phase.sh/public-api/v1/apps//reports/sessions/timeseries?${params}`, { headers: { Authorization: 'Bearer phase_public_your_key', }, } ); const data = await response.json(); ``` ## Users Use user reports for totals, active users, new users, and user distribution by platform or location. ### Endpoints * `GET /apps/:appId/reports/users/overview` * Returns total users, active users, new users, and summary breakdowns. * `GET /apps/:appId/reports/users/timeseries` * Returns daily user metrics. * Supported `metric` values: `activeUsers`, `totalUsers`, `newUsers`. * Supports `startDate`, `endDate`, and `metric`. * `GET /apps/:appId/reports/users/breakdown` * Returns user counts grouped by `platform` or `country`. * Supports `dimension` and `limit`. ### Example: user overview cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//reports/users/overview" \ -H "Authorization: Bearer phase_public_your_key" ``` ```ts const response = await fetch( 'https://api.phase.sh/public-api/v1/apps//reports/users/overview', { headers: { Authorization: 'Bearer phase_public_your_key', }, } ); const data = await response.json(); ``` ## Funnels Use funnel reports for activation conversion and saved custom funnels from the dashboard. ### Endpoints * `GET /apps/:appId/reports/funnels/activation` * Returns the built-in activation funnel. * Steps: First open → Total session ≥10m → Returned day 1 → Returned day 3. * Supports `startDate` and `endDate` for the acquisition cohort. * `GET /apps/:appId/reports/funnels` * Lists saved custom funnels for the app. * Returns funnel id, name, steps, and window hours. * `GET /apps/:appId/reports/funnels/:funnelId` * Runs a saved custom funnel and returns step conversion. * Supports `startDate` and `endDate`. * `POST /apps/:appId/reports/funnels/run` * Runs an ad-hoc funnel without saving it. * Body: `steps`, optional `windowHours` (default `168`), optional `startDate` / `endDate`. * Step kinds: `first_seen`, `session`, `session_30s`, `session_10m`, `engaged_10m`, `return_d1`, `return_d3`, `event`. * Event steps require `name`. ### Example: activation funnel cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//reports/funnels/activation?startDate=2026-01-15T00:00:00.000Z&endDate=2026-02-15T23:59:59.999Z" \ -H "Authorization: Bearer phase_public_your_key" ``` ```ts const params = new URLSearchParams({ startDate: '2026-01-15T00:00:00.000Z', endDate: '2026-02-15T23:59:59.999Z', }); const response = await fetch( `https://api.phase.sh/public-api/v1/apps//reports/funnels/activation?${params}`, { headers: { Authorization: 'Bearer phase_public_your_key', }, } ); const data = await response.json(); console.log(data.overallConversion, data.steps); ``` ### Example: run a custom funnel cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//reports/funnels/run" \ -H "Authorization: Bearer phase_public_your_key" \ -H "Content-Type: application/json" \ -d '{ "windowHours": 168, "steps": [ { "kind": "first_seen" }, { "kind": "session" }, { "kind": "event", "name": "purchase_completed" } ] }' ``` ```ts const response = await fetch( 'https://api.phase.sh/public-api/v1/apps//reports/funnels/run', { method: 'POST', headers: { Authorization: 'Bearer phase_public_your_key', 'Content-Type': 'application/json', }, body: JSON.stringify({ windowHours: 168, steps: [ { kind: 'first_seen' }, { kind: 'session' }, { kind: 'event', name: 'purchase_completed' }, ], }), } ); const data = await response.json(); ``` ## Query Run the same read-only SQL engine as **Dashboard → Analytics → Query** from your backend. * `POST /apps/:appId/query` * Body: JSON with `sql` and optional `page` (default `1`) * Auth: `Authorization: Bearer phase_public_your_key` `LIMIT` in SQL sets page size (default **100** if omitted, max **50** on the API). Do not use `OFFSET` in SQL. Pass `page` to fetch the next slice. The response `meta` object includes `hasNextPage`, `hasPreviousPage`, `offset`, and `pageSize`. Full table reference, time-range defaults, pagination, and examples are documented in [Query](/docs/concepts/query). ### Example: run a query cURL fetch ```bash curl "https://api.phase.sh/public-api/v1/apps//query" \ -H "Authorization: Bearer phase_public_your_key" \ -H "Content-Type: application/json" \ -d '{ "sql": "SELECT name, count(*) AS events FROM events GROUP BY name ORDER BY events DESC LIMIT 20", "page": 1 }' ``` ```ts const response = await fetch( 'https://api.phase.sh/public-api/v1/apps//query', { method: 'POST', headers: { Authorization: 'Bearer phase_public_your_key', 'Content-Type': 'application/json', }, body: JSON.stringify({ sql: `SELECT name, count(*) AS events FROM events GROUP BY name ORDER BY events DESC LIMIT 20`, page: 1, }), } ); const data = await response.json(); console.log(data.result.columns, data.result.rows); console.log(data.meta.hasNextPage); ``` ### Example: paginate ```ts let page = 1; let hasNextPage = true; while (hasNextPage) { const response = await fetch( 'https://api.phase.sh/public-api/v1/apps//query', { method: 'POST', headers: { Authorization: 'Bearer phase_public_your_key', 'Content-Type': 'application/json', }, body: JSON.stringify({ sql, page }), } ); const data = await response.json(); // process data.result.rows hasNextPage = data.meta.hasNextPage; page += 1; } ``` ## Response metadata Every report response includes a `meta` object. * `generatedAt` is the server-side timestamp for the response. * `consistency` is currently `eventual`. * `identityModel` is currently `user`. ## Security guidance Do not use API keys in mobile apps, browser bundles, or any other untrusted client. Use them from your own backend, jobs, or internal tooling. # Errors and Limits (/docs/public-api/errors-and-limits) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; ## Error response format Phase returns structured error responses. ```json { "code": "VALIDATION_ERROR", "detail": "Date range cannot exceed 365 days" } ``` ## Status codes * `400 Bad Request` * Invalid query parameters * Unsupported query shapes * Date range larger than 365 days * `401 Unauthorized` * Missing `Authorization` header * Malformed bearer key * Invalid, expired, revoked, or unauthorized key * `403 Forbidden` * The key is valid, but it cannot access the requested app or resource * `422 Unprocessable Entity` * Route-level enum or query validation failed before the request reached the handler * `429 Too Many Requests` * The rate limit was exceeded * `500 Internal Server Error` * An unexpected server error occurred ## Rate limit headers When rate limiting applies, responses may include these headers: * `X-RateLimit-Limit` * `X-RateLimit-Remaining` * `X-RateLimit-Reset` * `Retry-After` ## Request limits Current limits: * Maximum report range: **365 days** * Maximum breakdown size: **50 rows** * Default breakdown size: **10 rows** * Maximum query page size: **50 rows** (`LIMIT` in SQL, API only) * Maximum dashboard query page size: **1000 rows** * Maximum skipped rows per query: **100,000** (pagination cap) Build your integration to handle validation and rate-limit responses. If a request exceeds a guardrail, retry with a smaller date range or a smaller breakdown limit. ## Consistency Phase analytics responses are eventually consistent. * Recently ingested data may take a short time to appear in reports. * Every report response includes a `meta` object. * `meta.generatedAt` tells you when the response was created. * `meta.identityModel` is currently `user`. # Team & Billing (/docs/concepts/team-billing) import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; ## Team Management Collaborate with your team by inviting members to your Phase project. Team members can view analytics and reports while the project owner maintains full control. ### Roles & Permissions **Owner** * Full control over the project * Rotate API keys * Delete or rename the project * Add and remove team members * Manage all project settings * Only the owner is billed for the project **Team Member** (Read-Only) * View analytics and reports * Access all data insights * Cannot modify project settings * Cannot manage API keys or team members * Does not affect billing ### Adding Team Members 1. Navigate to **[Dashboard](https://phase.sh/dashboard) → Application → Team** 2. Click **Add Member** 3. Enter the email address of the team member 4. They will receive an invitation to join the project **No Limits:** You can add unlimited team members to your project at no additional cost. ### Removing Team Members 1. Navigate to **[Dashboard](https://phase.sh/dashboard) → Application → Team** 2. Find the team member you want to remove 3. Click **Remove** 4. Confirm the action The removed member will immediately lose access to the project. ### Transfer Ownership Currently, ownership transfer is not supported. If you need to transfer a project to another user, please contact support. ## Billing Billing is managed through [Polar](https://polar.sh) and is tied to the project owner. ### How Billing Works * **Only the project owner is billed** for the project * Team members do not affect billing costs * Adding team members is free (unlimited) * Billing is based on your project's usage and plan ### Managing Your Subscription **For Project Owners:** The project owner can manage their subscription from the profile sidebar menu by selecting **Billing**. **For Team Members:** Team members can also access the Billing section from their profile, but this shows their personal Phase account billing, not the project's billing. Project billing is only visible to and managed by the project owner. ### Billing FAQ Team members are free and unlimited. Only the project owner is billed. Team members can access their own personal Phase account billing, but cannot view or manage the project's billing. Only the project owner can manage project billing. Your project will be downgraded to the free tier with limited features. Your data remains accessible. Billing is tied to the project owner. To transfer billing, you would need to transfer project ownership (contact support). ## Best Practices * **Invite trusted team members:** Only invite people who need access to your analytics data * **Regular audits:** Periodically review your team members and remove those who no longer need access * **API key security:** Only the owner can rotate API keys - ensure your owner account is secure * **Billing alerts:** Set up payment method backups to avoid service interruptions # Publishing (/docs/concepts/publishing) import { Accordion, Accordions } from 'fumadocs-ui/components/accordion'; import { Callout } from 'fumadocs-ui/components/callout'; When submitting your app to the App Store or Google Play, you'll need to disclose how Phase Analytics collects and uses data. This guide helps you accurately complete the required privacy forms. Phase is privacy-first and collects no personally identifiable information (PII) by default. All data collection is anonymous unless you explicitly identify users. ## iOS (Apple App Store) When submitting your app to the App Store, you'll need to complete Apple's App Privacy questionnaire. **Does your app collect data?** Select **Yes** - Phase collects anonymous analytics data. **Do you or your third-party partners collect data from this app?** Select **Yes, we collect data from this app** - Phase SDK collects analytics data. Select the following data types: **Product Interaction** * How users interact with your app (events, screen views, session duration) **Device ID** * Anonymous device identifiers for analytics purposes For each data type, select: * **Analytics** - To understand app usage and improve performance **Is this data linked to the user's identity?** Select **No** - Phase uses anonymous device IDs by default. Only select "Yes" if you explicitly call `Phase.identify()` with user-specific data like email or user ID. If you call `Phase.identify()` with personally identifiable information (email, name, user ID), you must select "Yes" for linking data to user identity and disclose which data types you're collecting. **Do you or your third-party partners use data from this app for tracking purposes?** Select **No** - Phase does not track users across apps and websites owned by other companies for targeted advertising or ad measurement purposes. ## Android (Google Play Store) When submitting your app to Google Play, you'll need to complete the Data Safety form. **Does your app collect or share any of the required user data types?** Select **Yes** - Phase collects device and app interaction data. **Is all of the user data collected by your app encrypted in transit?** Select **Yes** - All data sent to Phase is encrypted using HTTPS. Select the following categories: **Device or other IDs** * Anonymous device identifiers for analytics **App interactions** * How users interact with your app (events, screen views, session duration) For each data type, specify: * **Analytics** - To measure app usage and performance * **Data is collected but not shared** with third parties **Can users request that data be deleted?** Select **Yes** - Users can contact support to request data deletion. Phase automatically anonymizes data and does not collect PII by default. If you implement custom user identification with PII, update your disclosures accordingly. ## Best Practices * **Review before each release** - Privacy requirements may change with app store policy updates * **Match your implementation** - Only disclose data types you actually collect through Phase * **Document custom properties** - If you track custom user properties with PII, update your privacy policy * **Test before submission** - Review your app's data collection behavior in production builds * **Keep privacy policy updated** - Ensure your app's privacy policy accurately reflects Phase usage # Query (/docs/concepts/query) import { Callout } from 'fumadocs-ui/components/callout'; import { CodeBlock } from 'fumadocs-ui/components/codeblock'; import { Tab, Tabs, TabsContent, TabsList, TabsTrigger } from 'fumadocs-ui/components/tabs'; Query lets you answer product questions with read-only SQL. The dashboard editor and the [Public API](/docs/public-api/usage#query) run the same engine against your app data. ## Where to find it Open **[Dashboard](https://phase.sh/dashboard) → Analytics → Query**. Use **Instructions** in the editor for a copyable SQL reference. Save queries as **presets** for your team. ## Virtual tables Query exposes three virtual tables. App scoping is injected automatically. You never filter by `app_id` yourself. ### `events` | Column | Type | Notes | | ----------- | ----------- | ----------------------------------------- | | `timestamp` | timestamptz | Event time | | `user_id` | text | Anonymous user identifier | | `name` | text | Event name (e.g. `purchase`, `level_win`) | | `params` | text | JSON string of event properties | `events` queries run on QuestDB when used alone. Use QuestDB JSON syntax: ```sql json_extract(params, '$.duration_seconds') cast(json_extract(params, '$.level_number') as long) ``` Modulo is supported: `expr % 5 = 0` ### `users` | Column | Type | Notes | | ------------ | ----------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------- | | `user_id` | text | | | `platform` | text | `ios`, `android`, or `unknown` | | `country` | text | ISO country code | | `locale` | text | | | `model` | text | Device model. Apple hardware IDs (e.g. `iPhone17,1`) are resolved to marketing names (e.g. `iPhone 16 Pro`). Android and already-friendly names pass through. | | `os_version` | text | | | `first_seen` | timestamptz | First seen time | | `properties` | jsonb | Custom user properties — use `properties->>'key'` | The virtual table is named `users`. Do not use `devices` or `device_id` in SQL. Those names are rejected. ### `sessions` | Column | Type | Notes | | ------------------ | ----------- | ----------------------- | | `session_id` | text | | | `user_id` | text | | | `started_at` | timestamptz | | | `last_activity_at` | timestamptz | | | `duration_seconds` | number | Computed session length | Each query uses **one** virtual table. `events` runs on QuestDB. `users` and `sessions` run on Postgres. ## Time range If your SQL does not filter on `timestamp`, `started_at`, `last_activity_at`, or `first_seen`, **events** and **sessions** default to the **last 30 days**. Add your own predicates to control the window: ```sql WHERE timestamp >= '2026-01-01' ``` ## Pagination Pagination is **page-based**, not `OFFSET` in SQL. | Surface | Page size | Max page size | Pagination | | ---------- | ---------------------------- | ------------- | ---------------------- | | Dashboard | `LIMIT` in SQL (default 100) | 1000 | UI page controls | | Public API | `LIMIT` in SQL (default 100) | 50 | `page` in request body | Rules: * `LIMIT` sets how many rows each page returns. * Do **not** write `OFFSET` in SQL. It is rejected. * Use page controls in the dashboard, or send `page` in the API request. * Deep pagination is capped at **100,000** skipped rows. * Export downloads the current page only. API example with pagination: ```json { "sql": "SELECT name, count(*) AS events FROM events GROUP BY name ORDER BY events DESC LIMIT 50", "page": 2 } ``` The response includes `meta.page`, `meta.pageSize`, `meta.offset`, `meta.hasNextPage`, and `meta.hasPreviousPage` so you can loop programmatically. ## Rules * **SELECT only.** No `INSERT`, `UPDATE`, `DELETE`, or DDL. * **Single statement.** No semicolons. * **Read-only sandbox.** Queries cannot mutate data. * **Debug events excluded** automatically. * **One table per query.** No `JOIN` and no multi-table `FROM`. * **Legacy names rejected:** `devices`, `device_id`. ## Examples Recent events: ```sql SELECT timestamp, user_id, name AS event_name FROM events ORDER BY timestamp DESC LIMIT 100 ``` Top events: ```sql SELECT name AS event_name, count(*) AS events FROM events GROUP BY name ORDER BY events DESC LIMIT 100 ``` Users by platform: ```sql SELECT platform, count(*) AS users FROM users GROUP BY platform ORDER BY users DESC LIMIT 50 ``` Average level duration (every 5th level): ```sql SELECT count(*) AS level_wins, avg(cast(json_extract(params, '$.duration_seconds') AS double)) AS avg_duration_seconds FROM events WHERE name = 'level_win' AND cast(json_extract(params, '$.level_number') AS long) % 5 = 0 ``` ## Presets Save the current SQL as a preset from the Query page. Presets are per app and shared with your team. Loading a preset replaces the editor contents. ## Automation Use the same queries from your backend with a [public API key](/docs/public-api/usage). See [Query via API](/docs/public-api/usage#query) for the endpoint and request shape. Full SQL, pagination, and limit details live on this page.