# Introduction

> How AnyAnalytics is organized: organizations, projects, write keys, events and people.

AnyAnalytics collects events from your websites, apps, games and servers, and turns them into reports: who your players and visitors are, what they do, where they come from, what they buy and whether they come back. It's hosted for you, so there's nothing to run.

## The building blocks

| Concept | What it is |
| --- | --- |
| Organization | Your team's account. It holds your projects, members and plan. Every account gets one when signing up. |
| Project | One website, app, game or backend. Each project has its own dashboard, settings and write keys. |
| Write key | The `vxw_…` key your code sends events with. It can only send data, never read it, so it's safe to ship inside a website or a game build. |
| Event | Something that happened, like a pageview, `level_completed` or `purchase_completed`, with optional properties. |
| Person | A player or visitor. Events are tied to an anonymous id until you identify the person with your own user id. |
| Session | One continuous visit or play session. It ends after 30 minutes of inactivity. |

## How it works

- You add a small SDK or script tag to your project, or call the [HTTP API](https://anyanalytics.org/docs/http-api.md) directly.
- The SDK records events, batches them, keeps them while offline and sends them in the background, so tracking never slows down your site or game.
- Events show up in **Live events** within a second, and in your reports right after.
- Bots, crawlers and your own excluded traffic are filtered out before anything is stored or counted.

## What you get

- **An overview** per project: visitors or users, sessions, pageviews or screens, bounce rate, session time, revenue, and breakdowns by page, source, country and device. See [The dashboard](https://anyanalytics.org/docs/dashboard.md).
- **Insights**: trends, funnels, retention and paths, saved to dashboards. See [Insights and dashboards](https://anyanalytics.org/docs/insights.md).
- **A game report** for levels, sessions and crashes. See [Game events](https://anyanalytics.org/docs/game-events.md).
- **Realtime**: who's online right now, on a globe, with events streaming in.

> **Ready to start?**
>
> The [Quickstart](https://anyanalytics.org/docs/quickstart.md) takes you from sign-up to your first event in about five minutes.

---

Web version: https://anyanalytics.org/docs/introduction. All docs: https://anyanalytics.org/docs.md

# Quickstart

> From sign-up to your first event in about five minutes.

1. **Create your account**

   [Sign up](https://anyanalytics.org/sign-up) with your name, email and a password. Your organization is created for you, with a free trial that needs no card.

2. **Add a project**

   Click **Add site**, then pick what you're tracking (website, web app, game, mobile app, desktop app or backend) and how you'll send events. You can add more projects later; they all share your plan's allowance.

3. **Install the snippet**

   The setup page shows a ready-to-paste snippet with your project's write key already filled in. Pick the guide for your platform:

   | You're tracking | Use |
   | --- | --- |
   | A website, store or blog | [Script tag](https://anyanalytics.org/docs/websites.md) |
   | A React, Next.js, Vue or Svelte app, React Native, Electron or Node | [JavaScript SDK](https://anyanalytics.org/docs/javascript.md) |
   | A Unity or Godot game | [Unity](https://anyanalytics.org/docs/unity.md) or [Godot](https://anyanalytics.org/docs/godot.md) |
   | An iOS or Android app | [Swift](https://anyanalytics.org/docs/ios.md) or [Kotlin](https://anyanalytics.org/docs/android.md) |
   | Anything else (Unreal, Flutter, Python, Go…) | [HTTP API](https://anyanalytics.org/docs/http-api.md) |

4. **See your first event**

   Open your site or start your game. The setup page waits for the first event and tells you the moment it arrives, usually within a second. You'll also see it in **Live events**.

5. **Send your own events**

   Pageviews and sessions are recorded for you. Track what matters to your product with `track("event_name", { …properties })`, and tie events to your users with `identify(userId)`. See [Events and people](https://anyanalytics.org/docs/events.md).

## Nothing showing up?

Check these, in order:

| Check | Why |
| --- | --- |
| The write key | It must be the `vxw_…` key from this project's setup page, and not revoked. |
| Your own visits are excluded | If you opened your site with `?anyanalytics_ignore=true` before, your browser is excluded. Visit any page with `?anyanalytics_ignore=false` to undo it. |
| An ad blocker | Some block analytics. Try another browser, or [serve the tag from your own domain](https://anyanalytics.org/docs/ad-blockers.md). |
| Debug logging | Add `data-debug` to the script tag, or `debug: true` to the SDK options, to see what the SDK sends in the browser console. |

> **Note**
>
> Events sent while the setup page is open also appear in **Live events**, including the ones that were rejected and why. It's the fastest way to debug an integration.

---

Web version: https://anyanalytics.org/docs/quickstart. All docs: https://anyanalytics.org/docs.md

# Websites

> Track any website with one script tag: pageviews, outbound clicks and custom events.

One line of HTML tracks any website: pageviews (including client-side navigation in single-page apps), where visitors come from, and clicks on links to other sites. It's about 5 KB and loads without blocking the page.

## Install the tag

Paste this into the `<head>` of every page. Your project's setup page shows it with your write key filled in.

`index.html`:

```html
<!-- AnyAnalytics: pageviews are tracked automatically -->
<script defer src="https://api.anyanalytics.org/script.js" data-key="vxw_YOUR_WRITE_KEY"></script>
```

### Site builders and CMSs

| Platform | Where the tag goes |
| --- | --- |
| WordPress | A header-scripts plugin, or your theme's header template |
| Shopify | Online Store → Themes → Edit code → theme.liquid, before </head> |
| Webflow | Site settings → Custom code → Head code |
| Wix | Settings → Custom code → Add code to the head of all pages |
| Squarespace | Settings → Advanced → Code injection → Header |
| Framer | Site settings → General → Custom code → Start of <head> tag |
| Ghost | Settings → Code injection → Site header |

## Custom events

The tag exposes `window.anyanalytics`. Track anything visitors do, and identify them once they sign in:

`anywhere.js`:

```ts
// Anywhere in your page scripts, after the tag has loaded
window.anyanalytics?.track("signup", { plan: "pro", source: "pricing_page" });
```

`after-login.js`:

```ts
// After someone logs in: link their visits to a user id
window.anyanalytics?.identify("user_123", { email: "ada@example.com", plan: "pro" });
```

> **Tip**
>
> Use the optional chaining (`anyanalytics?.track`) shown above: if the tag is blocked or hasn't loaded yet, your own code keeps working.

## What's tracked automatically

- `$pageview` on load and on every client-side navigation, with the path, title, referrer and UTM tags.
- `$outbound_click` when a visitor clicks a link to another site, with the URL and domain.
- Browser, operating system, device type and approximate location.
- Ad click ids (`gclid`, `msclkid`…) are recorded by name only, so paid traffic is recognized without storing the id.

URL fragments and query parameters other than `utm_*` and `ref` are removed from recorded URLs, since they often contain tokens or personal data.

## Subdomains

Put the same tag on `example.com`, `shop.example.com` and `blog.example.com` and a person moving between them stays one visitor with one session. The tag keeps their anonymous id and session in a first-party cookie on your parent domain, which it finds on its own (the widest domain the browser accepts, so `example.co.uk` for `shop.example.co.uk`). Visitors who were tracked before keep their id.

- `aa_` followed by the start of your write key holds the random anonymous id for a year. Your user ids are never put in cookies: once a visitor signs in on any subdomain, their anonymous id is linked to them.
- The same name ending in `_s` holds the current session and expires after 30 minutes without activity.

To pin the domain, set `data-cookie-domain="example.com"`. To keep each subdomain separate, set `data-cookie-domain="false"`. Cookieless mode sets no cookies.

## Options

| Attribute | What it does |
| --- | --- |
| `data-key` | Your project's write key. Required. |
| `data-cookieless` | Store nothing in the visitor's browser. See [Cookieless mode](https://anyanalytics.org/docs/privacy.md#cookieless). |
| `data-cookie-domain` | The parent domain that shares the visitor across subdomains, or `false` to keep each subdomain separate. Detected when left out. See [Subdomains](https://anyanalytics.org/docs/websites.md#subdomains). |
| `data-api` | Send events to another endpoint, such as a path on your own domain. See [Ad blockers and proxies](https://anyanalytics.org/docs/ad-blockers.md). |
| `data-host` | The address events are sent to, when it isn't the one the script was loaded from. |
| `data-debug` | Log everything the tag does to the browser console. |

## Exclude your own visits

Open any page of your site with `?anyanalytics_ignore=true` once: that browser stops being tracked, on every subdomain of your site. Use `?anyanalytics_ignore=false` to undo it. To exclude a whole office or team, add its IP addresses in the project's **Settings → Traffic filters**. Automated browsers are never tracked.

## Prefer npm?

For apps with a build step you can install the same tracker from npm instead and control it from your code. See the [JavaScript SDK](https://anyanalytics.org/docs/javascript.md).

---

Web version: https://anyanalytics.org/docs/websites. All docs: https://anyanalytics.org/docs.md

# JavaScript SDK

> The npm package for web apps, React Native, Electron and Node servers.

`@anyanalytics/analytics` is one package for every JavaScript runtime: browsers, React Native, Electron and Node servers. It batches events, keeps them while the device is offline, retries with backoff, and a retried event is never counted twice. It ships with TypeScript types.

`terminal`:

```sh
npm install @anyanalytics/analytics
```

## Web apps

For React, Next.js, Vue, Svelte or any app with a build step. Import it once on the client: main.tsx (Vite/React), app/layout.tsx in a client component (Next.js), or main.ts (Vue, Svelte).

`analytics.ts`:

```ts
import { analytics } from "@anyanalytics/analytics";

analytics.init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  host: "https://api.anyanalytics.org",
  capturePageviews: true, // page loads + client-side navigations
});
```

With `capturePageviews` on, the SDK records a `$pageview` on load and on every client-side navigation. Add `captureOutboundClicks: true` to record clicks on links to other sites as `$outbound_click`. Both are off by default in the npm package (the [script tag](https://anyanalytics.org/docs/websites.md) turns them on).

> **Note**
>
> Recorded page URLs drop the fragment and every query parameter except `utm_*` and `ref`, since they often hold tokens or personal data. List any others you want to keep in `urlQueryParams`.

### Custom routers

Pageview capture listens to `history.pushState` and the back button, which covers the common routers. If yours works differently, leave `capturePageviews` off and call `analytics.page()` after each navigation.

### Across subdomains

In browsers the visitor's anonymous id and session are kept in a first-party cookie on your parent domain, so `shop.example.com` and `blog.example.com` count one visitor with one session. The domain is detected; pass `cookieDomain: "example.com"` to pin it, or `cookieDomain: false` to keep ids per subdomain in `storage`. Unsent events always stay in `storage`.

### Without storing anything

Pass `cookieless: true` and the SDK writes nothing to the visitor's browser. Visitors are then counted per day, and events queued while offline are lost on reload. See [Cookieless mode](https://anyanalytics.org/docs/privacy.md#cookieless).

## Track and identify

Import the shared `analytics` instance anywhere and track what people do:

`anywhere.ts`:

```ts
import { analytics } from "@anyanalytics/analytics";

analytics.track("signup", { plan: "pro", source: "pricing_page" });
```

Once someone signs in, `identify` ties their events to your user id. Their earlier anonymous events are linked to the same person. Traits are saved on their profile.

`auth.ts`:

```ts
// After login: future events belong to this user
analytics.identify("user_123", { email: "ada@example.com", plan: "pro" });

// On logout: start a fresh anonymous visitor
analytics.reset();
```

## React Native

React Native has no `localStorage`, so pass AsyncStorage as the `storage` option. It keeps the visitor id, the session and unsent events between launches.

`terminal`:

```sh
npm install @anyanalytics/analytics @react-native-async-storage/async-storage
```

`analytics.ts`:

```ts
import AsyncStorage from "@react-native-async-storage/async-storage";
import { analytics } from "@anyanalytics/analytics";

analytics.init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  host: "https://api.anyanalytics.org",
  // Persist the visitor id and offline queue between launches
  storage: AsyncStorage,
  context: { platform: "react-native", app_version: "1.0.0" },
});

analytics.screen("Home");
```

Import this file once from App.tsx (or index.js) so it runs on startup.

- Call `analytics.screen(name)` from your navigation listener to record screen views as `$screen` events.
- Events are sent every 10 seconds, or as soon as 20 are queued. To send right away when the app goes to the background, call `analytics.flush()` from an `AppState` listener.

Any object with `getItem`, `setItem` and `removeItem` works as storage. They may return values or promises.

## Electron and desktop apps

In Electron or Tauri, initialize the SDK in the renderer, where it stores its state in `localStorage`. Import it from your renderer entry (Electron) or main frontend file (Tauri) so it runs once.

`analytics.ts`:

```ts
import { analytics } from "@anyanalytics/analytics";

analytics.init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  host: "https://api.anyanalytics.org",
  context: { platform: "desktop", app_version: "1.0.0" },
});

analytics.screen("Main window");
```

Events are queued while offline and sent when the connection comes back.

## Node servers

On a server, one process handles many users at once. Create one instance and pass the user on every call.

`analytics.ts`:

```ts
import { Analytics, memoryStorage } from "@anyanalytics/analytics";

export const analytics = new Analytics().init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  host: "https://api.anyanalytics.org",
  storage: memoryStorage(),
  context: { platform: "server", app_version: process.env.npm_package_version },
});

// Send whatever is still queued before the process exits
process.on("SIGTERM", () => void analytics.shutdown());

// On a server, call analytics.capture(event, { distinctId }) with the user of
// each request. Never set a process-wide identity: requests run concurrently.
```

`capture(event, options)` takes the identity per call, so concurrent requests can't mix up users:

`handler.ts`:

```ts
import { analytics } from "./analytics";

// distinctId: the user (or anonymous visitor id) this request is for
analytics.capture("order_completed", {
  distinctId: user.id,
  properties: { order_id: "ord_123", total: 49.9 },
});
```

| Option | What it is |
| --- | --- |
| `distinctId` | Required. Your user id, or an anonymous id you keep. |
| `properties` | Event properties. |
| `sessionId` | Optional. Groups events into a session you manage. capture() never starts sessions itself. |
| `timestamp` | Optional. A Date or epoch milliseconds. Defaults to now. |

To set traits on a user from the server, capture a `$identify` event with `$set`:

`handler.ts`:

```ts
// Set traits on the user making the request (no shared identity state)
analytics.capture("$identify", {
  distinctId: user.id,
  properties: { $set: { email: user.email, plan: user.plan } },
});
```

> **Heads up**
>
> Don't call `identify()` or `track()` on a server. They keep one identity for the whole process, so with concurrent requests one user's events would be attributed to another.

- Events are sent in the background. In short-lived scripts and serverless functions, `await analytics.flush()` before returning.
- Server events with `platform` set to `"server"` or `"node"` don't get a location from the request, since it comes from your data center, not the user.

## API

| Method | What it does |
| --- | --- |
| `init(options)` | Starts the SDK. Call it once; later calls are ignored. |
| `identify(userId, traits?)` | Ties this device's events to a user id and saves the traits on their profile. |
| `track(event, properties?)` | Records a custom event. |
| `page(properties?)` | Records a $pageview for the current URL (browsers). |
| `screen(name, properties?)` | Records a $screen event (apps and games). |
| `capture(event, options)` | Records an event for the user you pass. Use it on servers. |
| `setContext(context)` | Merges fields into the context sent with every event, such as app_version once it's known. |
| `reset()` | Forgets the user (on logout): new anonymous id, new session. |
| `flush()` | Sends everything queued now. Returns a promise. |
| `shutdown({ timeoutMs? })` | Stops timers and sends what it can (waits up to 5 s by default). Resolves to { pending }, the number of events left unsent. |
| `distinctId` | The current user id, or the anonymous id before identify(). |
| `sessionId` | The current session's id. Pass it with `distinctId` to server-side flows, such as checkout metadata, so payments are attributed to the visit. See [Revenue](https://anyanalytics.org/docs/revenue.md). |

Sessions are handled for you: a new one starts after 30 minutes without events, and a `$session_start` event is recorded each time. Call `init()` before anything else.

Need more than one project in the same app? Create separate instances with `new Analytics().init({ … })`.

## Options

| Option | Default | What it does |
| --- | --- | --- |
| `writeKey` |  | Your project's write key. Required. |
| `host` |  | The ingestion address, `https://api.anyanalytics.org`. Required unless you set `endpoint`. |
| `endpoint` | `{host}/v1/batch` | The full URL events are posted to, such as a path on your own domain. Relative URLs resolve against the page. See [Ad blockers and proxies](https://anyanalytics.org/docs/ad-blockers.md). |
| `capturePageviews` | false | Browsers: record $pageview on load and on client-side navigation. |
| `captureOutboundClicks` | false | Browsers: record clicks on links to other sites as $outbound_click. |
| `urlQueryParams` | [] | Query parameters to keep in recorded URLs, besides utm_* and ref. |
| `cookieless` | false | Store nothing on the device. Visitors are counted per day. |
| `cookieDomain` | detected | Browsers: the parent domain whose subdomains share the visitor and session, or false to keep them per subdomain. |
| `storage` | localStorage, or memory | Where the visitor id, session and unsent events are kept. |
| `context` |  | Fields sent with every event: platform, app_version, app_build, os, device and so on. |
| `flushAt` | 20 | Send as soon as this many events are queued. |
| `flushIntervalMs` | 10000 | Send at least this often. |
| `maxBatchSize` | 100 | Events per request (at most 500). |
| `maxQueueSize` | 1000 | Unsent events kept. The oldest are dropped beyond this. |
| `sessionTimeoutMs` | 1800000 | Inactivity after which a new session starts (30 minutes). |
| `flushOnHide` | true | Browsers: send queued events when the tab is hidden or closed. |
| `fetch` | global fetch | Replace fetch, for tests or custom runtimes. |
| `debug` | false | Log what the SDK does to the console. |

## Delivery

- Unsent events survive reloads and restarts when `storage` persists (localStorage in browsers, AsyncStorage in React Native). On a server they're kept in memory, so call `shutdown()` before the process exits.
- Failed requests are retried with backoff, honoring the server's `Retry-After`. Every event has its own id, so a retried event is never counted twice.
- Events the server rejects are logged to the console with the reason. You'll also see them in **Live events**.

---

Web version: https://anyanalytics.org/docs/javascript. All docs: https://anyanalytics.org/docs.md

# Unity

> Add AnyAnalytics to a Unity game with one C# file.

One C# file adds AnyAnalytics to any Unity game. It has no dependencies, keeps events on disk while the player is offline, and sends them in batches in the background.

## Install

1. **Add the file**

   Create `AnyAnalytics.cs` with the code below. Your project's setup page shows it with your write key filled in.

2. **Add the component**

   Save it under Assets/Scripts, then add the component to a GameObject in your first scene. It survives scene loads.

3. **Press Play**

   A `$session_start` event is sent when the component wakes up. It shows up in **Live events** within about 10 seconds.

`AnyAnalytics.cs`:

```csharp
// AnyAnalytics.cs: queues events on disk and posts them to AnyAnalytics.
// Every event gets a uuid when tracked, so a retried batch is never counted twice.
using System;
using System.Collections;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.Text;
using UnityEngine;
using UnityEngine.Networking;

public class AnyAnalytics : MonoBehaviour
{
    const string Host = "https://api.anyanalytics.org";
    const string WriteKey = "vxw_YOUR_WRITE_KEY";
    const float FlushInterval = 10f;
    const int MaxQueue = 1000; // oldest events are dropped beyond this
    const int MaxBatch = 100;

    static AnyAnalytics instance;
    static string distinctId;
    static readonly string sessionId = Guid.NewGuid().ToString();
    readonly List<string> queue = new List<string>(); // one JSON event per entry
    List<string> inFlight = new List<string>();
    string queuePath;
    bool flushing, warnedFull;
    int batchSize = MaxBatch;
    float backoff, retryAt;

    void Awake()
    {
        if (instance != null) { Destroy(gameObject); return; }
        instance = this;
        DontDestroyOnLoad(gameObject);
        distinctId = PlayerPrefs.GetString("anyanalytics_id", "");
        if (distinctId == "")
        {
            distinctId = Guid.NewGuid().ToString();
            PlayerPrefs.SetString("anyanalytics_id", distinctId);
        }
        // Events that weren't delivered last time (one JSON object per line)
        queuePath = Path.Combine(Application.persistentDataPath, "anyanalytics_queue.jsonl");
        if (File.Exists(queuePath))
            foreach (var line in File.ReadAllLines(queuePath)) if (line.Length > 0) queue.Add(line);
        Track("$session_start");
        StartCoroutine(FlushLoop());
    }

    public static void Track(string eventName, Dictionary<string, object> properties = null)
    {
        if (instance == null) { Debug.LogWarning("[AnyAnalytics] Add the component to a GameObject first."); return; }
        var context = new Dictionary<string, object>
        {
            { "platform", Application.platform.ToString() },
            { "app_version", Application.version },
            { "os", SystemInfo.operatingSystem },
            { "device", SystemInfo.deviceModel },
            { "locale", CultureInfo.CurrentCulture.Name },
        };
        var payload = new Dictionary<string, object>
        {
            { "uuid", Guid.NewGuid().ToString() }, // kept across retries for deduplication
            { "event", eventName },
            { "distinct_id", distinctId },
            { "session_id", sessionId },
            { "timestamp", Now() },
            { "properties", properties ?? new Dictionary<string, object>() },
            { "context", context },
        };
        instance.Enqueue(Json(payload));
    }

    public static void Identify(string userId, Dictionary<string, object> traits = null)
    {
        var anonymousId = distinctId;
        distinctId = userId;
        PlayerPrefs.SetString("anyanalytics_id", userId);
        Track("$identify", new Dictionary<string, object>
        {
            { "$anon_distinct_id", anonymousId },
            { "$set", traits ?? new Dictionary<string, object>() },
        });
    }

    void Enqueue(string json)
    {
        queue.Add(json);
        if (queue.Count > MaxQueue)
        {
            queue.RemoveRange(0, queue.Count - MaxQueue);
            if (!warnedFull) { warnedFull = true; Debug.LogWarning("[AnyAnalytics] Queue full, dropping the oldest events."); }
        }
        if (queue.Count >= 20) StartCoroutine(Flush());
    }

    IEnumerator FlushLoop()
    {
        while (true)
        {
            yield return new WaitForSecondsRealtime(FlushInterval);
            Save();
            yield return Flush();
        }
    }

    // One request at a time; events leave the queue only once the server accepts them.
    IEnumerator Flush()
    {
        if (flushing) yield break;
        flushing = true;
        while (queue.Count > 0 && Time.realtimeSinceStartup >= retryAt)
        {
            inFlight = queue.GetRange(0, Math.Min(queue.Count, batchSize));
            queue.RemoveRange(0, inFlight.Count);
            var body = "{\"sent_at\":" + Json(Now()) + ",\"batch\":[" + string.Join(",", inFlight) + "]}";
            long code;
            string retryAfter, error;
            using (var request = new UnityWebRequest(Host + "/v1/batch", "POST"))
            {
                request.uploadHandler = new UploadHandlerRaw(Encoding.UTF8.GetBytes(body));
                request.downloadHandler = new DownloadHandlerBuffer();
                request.SetRequestHeader("Content-Type", "application/json");
                request.SetRequestHeader("Authorization", "Bearer " + WriteKey);
                yield return request.SendWebRequest();
                code = request.responseCode; // 0 = network error
                retryAfter = request.GetResponseHeader("Retry-After");
                error = request.downloadHandler.text;
            }
            if (code >= 200 && code < 300) { backoff = 0; batchSize = MaxBatch; }
            else if (code == 413 && inFlight.Count > 1)
            {
                // Body too large: retry the same events in smaller batches
                batchSize = Math.Max(1, inFlight.Count / 2);
                queue.InsertRange(0, inFlight);
            }
            else if (code == 0 || code == 408 || code == 429 || code >= 500)
            {
                // Offline, timeout, rate limited or server busy: keep the events, back off
                queue.InsertRange(0, inFlight);
                RetryLater(retryAfter);
            }
            else
            {
                // Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
                Debug.LogWarning("[AnyAnalytics] Dropped " + inFlight.Count + " event(s): HTTP " + code + " " + error);
            }
            inFlight.Clear();
            Save();
        }
        flushing = false;
    }

    // Exponential backoff with jitter (1s doubling to 60s); Retry-After wins when sent.
    void RetryLater(string retryAfter)
    {
        backoff = backoff == 0 ? 1f : Math.Min(backoff * 2, 60f);
        retryAt = Time.realtimeSinceStartup + (float.TryParse(retryAfter, NumberStyles.Float, CultureInfo.InvariantCulture, out var seconds)
            ? seconds
            : backoff * UnityEngine.Random.Range(0.5f, 1f));
    }

    void Save()
    {
        var all = new List<string>(inFlight);
        all.AddRange(queue);
        try { File.WriteAllLines(queuePath, all); }
        catch (Exception e) { Debug.LogWarning("[AnyAnalytics] Couldn't save the queue: " + e.Message); }
    }

    void OnApplicationPause(bool paused)
    {
        if (!paused) return;
        Save();
        StartCoroutine(Flush());
    }

    void OnApplicationQuit() => Save(); // sent on next launch, same uuids

    static string Now() => DateTime.UtcNow.ToString("yyyy-MM-dd'T'HH:mm:ss.fff'Z'", CultureInfo.InvariantCulture);

    static string Json(object value)
    {
        switch (value)
        {
            case null: return "null";
            case string s:
                return "\"" + s.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r").Replace("\t", "\\t") + "\"";
            case bool b: return b ? "true" : "false";
            case IDictionary<string, object> dict:
                var fields = new List<string>();
                foreach (var pair in dict) fields.Add(Json(pair.Key) + ":" + Json(pair.Value));
                return "{" + string.Join(",", fields) + "}";
            case IEnumerable list:
                var items = new List<string>();
                foreach (var item in list) items.Add(Json(item));
                return "[" + string.Join(",", items) + "]";
            case IFormattable number: return number.ToString(null, CultureInfo.InvariantCulture);
            default: return Json(value.ToString());
        }
    }
}
```

- Needs Unity 2020.2 or newer.
- Undelivered events are saved to Application.persistentDataPath and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.

## Track events

`AnyAnalytics.Track` is static, so you can call it from any script once the component has woken up:

`LevelManager.cs`:

```csharp
using System.Collections.Generic;

AnyAnalytics.Track("level_completed", new Dictionary<string, object>
{
    { "level", 5 },
    { "score", 1200 },
    { "duration", 82 },
});
```

Property values can be strings, numbers, booleans, lists and nested dictionaries. Events are sent every 10 seconds, as soon as 20 are queued, and when the app is paused. Anything left when the game quits is sent on the next launch.

## Identify players

Each install gets a random player id, saved in `PlayerPrefs`. When the player signs in to your backend or platform account, switch to your own id:

`Login.cs`:

```csharp
// After the player signs in to your backend / platform account
AnyAnalytics.Identify(player.Id, new Dictionary<string, object>
{
    { "name", player.DisplayName },
    { "platform", "steam" },
});
```

Their earlier anonymous events are linked to the same player, and the traits are saved on their profile. The new id is remembered across launches.

## Levels and the Game report

The Game report builds level funnels from events named `level_start`, `level_complete` and `level_fail`, each with a `level` property:

`LevelManager.cs`:

```csharp
using System.Collections.Generic;

// When the player starts a level
AnyAnalytics.Track("level_start", new Dictionary<string, object> { { "level", 3 } });

// When they finish it, or fail it
AnyAnalytics.Track("level_complete", new Dictionary<string, object>
{
    { "level", 3 },
    { "score", 1200 },
});
AnyAnalytics.Track("level_fail", new Dictionary<string, object> { { "level", 3 } });
```

See [Game events](https://anyanalytics.org/docs/game-events.md) for every event and property the report reads, including errors and crashes.

## Sessions and context

Each launch of the game is one session. Every event carries the platform, the app version from Player Settings, the operating system, the device model and the locale, so you can break any report down by them.

> **Tip**
>
> Calls made before the component's `Awake` log a warning and are dropped. Put the component in your first scene so it's ready before any of your own scripts track.

---

Web version: https://anyanalytics.org/docs/unity. All docs: https://anyanalytics.org/docs.md

# Godot

> Add AnyAnalytics to a Godot 4 game with a GDScript autoload.

One GDScript file, added as an autoload, tracks any Godot 4 game. It keeps events on disk while the player is offline and sends them in batches in the background.

## Install

1. **Add the script**

   Create `any_analytics.gd` with the code below. Your project's setup page shows it with your write key filled in.

2. **Register the autoload**

   Add it as an Autoload named AnyAnalytics: Project → Project Settings → Globals → Autoload.

3. **Run the game**

   A `$session_start` event is sent on startup. It shows up in **Live events** within about 10 seconds.

`any_analytics.gd`:

```gdscript
# any_analytics.gd: queues events on disk and posts them to AnyAnalytics (Godot 4).
# Every event gets a uuid when tracked, so a retried batch is never counted twice.
extends Node

const HOST := "https://api.anyanalytics.org"
const WRITE_KEY := "vxw_YOUR_WRITE_KEY"
const FLUSH_INTERVAL := 10.0
const MAX_QUEUE := 1000 # oldest events are dropped beyond this
const MAX_BATCH := 100
const ID_PATH := "user://anyanalytics_id"
const QUEUE_PATH := "user://anyanalytics_queue.json"

var distinct_id := ""
var session_id := ""
var _queue: Array = []
var _in_flight: Array = [] # the one batch being sent
var _batch_size := MAX_BATCH
var _backoff := 0.0
var _retry_at := 0.0 # seconds since startup
var _warned_full := false
var _crypto := Crypto.new()
var _http := HTTPRequest.new()

func _ready() -> void:
	add_child(_http)
	_http.request_completed.connect(_on_request_completed)
	distinct_id = _load_id()
	session_id = _uuid()
	_queue = _load_queue()
	var timer := Timer.new()
	timer.wait_time = FLUSH_INTERVAL
	timer.autostart = true
	timer.timeout.connect(_on_timer)
	add_child(timer)
	track("$session_start")

func _on_timer() -> void:
	_save_queue()
	flush()

func track(event: String, properties: Dictionary = {}) -> void:
	_queue.append({
		"uuid": _uuid(), # kept across retries for deduplication
		"event": event,
		"distinct_id": distinct_id,
		"session_id": session_id,
		"timestamp": _now_iso(),
		"properties": properties,
		"context": {
			"platform": OS.get_name(),
			"app_version": str(ProjectSettings.get_setting("application/config/version", "1.0.0")),
			"os": OS.get_name(),
			"os_version": OS.get_version(),
			"device": OS.get_model_name(),
			"locale": OS.get_locale(),
		},
	})
	if _queue.size() > MAX_QUEUE:
		_queue = _queue.slice(_queue.size() - MAX_QUEUE)
		if not _warned_full:
			_warned_full = true
			push_warning("[AnyAnalytics] Queue full, dropping the oldest events.")
	if _queue.size() >= 20:
		flush()

func identify(user_id: String, traits: Dictionary = {}) -> void:
	var anonymous_id := distinct_id
	distinct_id = user_id
	_save_id(user_id)
	track("$identify", {"$anon_distinct_id": anonymous_id, "$set": traits})

# One request at a time; events leave the queue only once the server accepts them.
func flush() -> void:
	if not _in_flight.is_empty() or _queue.is_empty() or _now() < _retry_at:
		return
	_in_flight = _queue.slice(0, _batch_size)
	_queue = _queue.slice(_batch_size)
	var headers := PackedStringArray(["Content-Type: application/json", "Authorization: Bearer " + WRITE_KEY])
	var body := JSON.stringify({"sent_at": _now_iso(), "batch": _in_flight})
	if _http.request(HOST + "/v1/batch", headers, HTTPClient.METHOD_POST, body) != OK:
		_queue = _in_flight + _queue
		_in_flight = []
		_retry_later("")

func _on_request_completed(result: int, code: int, headers: PackedStringArray, body: PackedByteArray) -> void:
	var batch := _in_flight
	_in_flight = []
	var ok := result == HTTPRequest.RESULT_SUCCESS
	if ok and code >= 200 and code < 300:
		_backoff = 0.0
		_batch_size = MAX_BATCH
	elif ok and code == 413 and batch.size() > 1:
		# Body too large: retry the same events in smaller batches
		_batch_size = maxi(1, int(batch.size() / 2.0))
		_queue = batch + _queue
	elif not ok or code == 408 or code == 429 or code >= 500:
		# Offline, timeout, rate limited or server busy: keep the events, back off
		_queue = batch + _queue
		_retry_later(_header(headers, "retry-after"))
	else:
		# Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
		push_warning("[AnyAnalytics] Dropped %d event(s): HTTP %d %s" % [batch.size(), code, body.get_string_from_utf8()])
	_save_queue()
	if ok and (code < 300 or code == 413):
		flush.call_deferred() # keep draining

# Exponential backoff with jitter (1s doubling to 60s); Retry-After wins when sent.
func _retry_later(retry_after: String) -> void:
	_backoff = 1.0 if _backoff == 0.0 else minf(_backoff * 2.0, 60.0)
	var delay: float = retry_after.to_float() if retry_after.is_valid_float() else _backoff * randf_range(0.5, 1.0)
	_retry_at = _now() + delay

func _header(headers: PackedStringArray, header_name: String) -> String:
	for line in headers:
		if line.to_lower().begins_with(header_name + ":"):
			return line.substr(header_name.length() + 1).strip_edges()
	return ""

func _notification(what: int) -> void:
	if what == NOTIFICATION_APPLICATION_PAUSED or what == NOTIFICATION_WM_CLOSE_REQUEST:
		_save_queue() # anything unsent goes out on the next launch, same uuids
		flush()

func _save_queue() -> void:
	var file := FileAccess.open(QUEUE_PATH, FileAccess.WRITE)
	if file:
		file.store_string(JSON.stringify(_in_flight + _queue))

func _load_queue() -> Array:
	if not FileAccess.file_exists(QUEUE_PATH):
		return []
	var saved = JSON.parse_string(FileAccess.get_file_as_string(QUEUE_PATH))
	return saved if saved is Array else []

func _load_id() -> String:
	if FileAccess.file_exists(ID_PATH):
		return FileAccess.get_file_as_string(ID_PATH)
	var id := _uuid()
	_save_id(id)
	return id

func _save_id(id: String) -> void:
	var file := FileAccess.open(ID_PATH, FileAccess.WRITE)
	if file:
		file.store_string(id)

func _now() -> float:
	return Time.get_ticks_msec() / 1000.0

func _now_iso() -> String:
	return Time.get_datetime_string_from_system(true) + "Z"

# Random (v4) UUID
func _uuid() -> String:
	var b := _crypto.generate_random_bytes(16)
	b[6] = (b[6] & 0x0f) | 0x40 # version 4
	b[8] = (b[8] & 0x3f) | 0x80 # RFC 4122 variant
	var h := b.hex_encode()
	return "%s-%s-%s-%s-%s" % [h.substr(0, 8), h.substr(8, 4), h.substr(12, 4), h.substr(16, 4), h.substr(20, 12)]
```

- Undelivered events are saved under user:// and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.
- Exporting to the web? Godot's HTTPRequest works there too, the ingestion API allows CORS.

> **Note**
>
> The script is indented with tabs, as Godot expects. If your editor converts them to spaces when you paste, switch the file back to tabs.

## Track events

The autoload is available everywhere as `AnyAnalytics`:

`level.gd`:

```gdscript
AnyAnalytics.track("level_completed", {"level": 5, "score": 1200, "duration": 82})
```

Properties are a plain `Dictionary` of strings, numbers, booleans, arrays and nested dictionaries. Events are sent every 10 seconds, as soon as 20 are queued, and when the game is paused or closed. Anything that couldn't be sent goes out on the next launch.

## Identify players

Each install gets a random player id, saved under `user://`. When the player signs in, switch to your own id:

`login.gd`:

```gdscript
# After the player signs in
AnyAnalytics.identify(player_id, {"name": player_name, "platform": "steam"})
```

Their earlier anonymous events are linked to the same player, and the traits are saved on their profile. The new id is remembered across launches.

## Levels and the Game report

The Game report builds level funnels from events named `level_start`, `level_complete` and `level_fail`, each with a `level` property:

`level.gd`:

```gdscript
# When the player starts a level
AnyAnalytics.track("level_start", {"level": 3})

# When they finish it, or fail it
AnyAnalytics.track("level_complete", {"level": 3, "score": 1200})
AnyAnalytics.track("level_fail", {"level": 3})
```

See [Game events](https://anyanalytics.org/docs/game-events.md) for every event and property the report reads, including errors and crashes.

## Sessions and context

Each launch of the game is one session. Every event carries the platform, your project's version (**Project Settings → Application → Config → Version**), the operating system and its version, the device model and the locale.

---

Web version: https://anyanalytics.org/docs/godot. All docs: https://anyanalytics.org/docs.md

# iOS (Swift)

> Track iOS and iPadOS apps with one Swift file.

One Swift file tracks an iOS or iPadOS app. It has no dependencies, keeps events on disk while the device is offline, and sends them in batches in the background.

## Install

1. **Add the file**

   Add `AnyAnalytics.swift` to your app target with the code below. Your project's setup page shows it with your write key filled in.

2. **Start it at launch**

   Touch `AnyAnalytics.shared` once at launch: in your `App`'s `init`, or in `application(_:didFinishLaunchingWithOptions:)`.

3. **Run the app**

   A `$session_start` event is sent when `AnyAnalytics.shared` is first used. It shows up in **Live events** within about 10 seconds.

`AnyAnalytics.swift`:

```swift
// AnyAnalytics.swift: queues events on disk and posts them to AnyAnalytics.
// Every event gets a uuid when tracked, so a retried batch is never counted twice.
import Foundation
#if canImport(UIKit)
import UIKit
#endif

final class AnyAnalytics {
    static let shared = AnyAnalytics()

    private let endpoint = URL(string: "https://api.anyanalytics.org/v1/batch")!
    private let writeKey = "vxw_YOUR_WRITE_KEY"
    private let maxQueue = 1000 // oldest events are dropped beyond this
    private let maxBatch = 100
    private let sessionId = UUID().uuidString.lowercased()
    private let worker = DispatchQueue(label: "anyanalytics") // owns all mutable state below
    private let queueFile = FileManager.default.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
        .appendingPathComponent("anyanalytics_queue.json")
    private var queue: [[String: Any]] = []
    private var inFlight: [[String: Any]] = [] // the one batch being sent
    private var batchSize = 100
    private var backoff: TimeInterval = 0
    private var retryAt = Date.distantPast
    private var warnedFull = false
    private var distinctId: String
    private let context: [String: Any]

    private init() {
        let defaults = UserDefaults.standard
        if let saved = defaults.string(forKey: "anyanalytics_id") {
            distinctId = saved
        } else {
            distinctId = UUID().uuidString.lowercased()
            defaults.set(distinctId, forKey: "anyanalytics_id")
        }
        var system = utsname()
        uname(&system)
        let model = withUnsafeBytes(of: &system.machine) {
            String(decoding: $0.prefix(while: { $0 != 0 }), as: UTF8.self)
        }
        let os = ProcessInfo.processInfo.operatingSystemVersion
        context = [
            "platform": "ios",
            "app_version": Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "",
            "os": "iOS",
            "os_version": "\(os.majorVersion).\(os.minorVersion).\(os.patchVersion)",
            "device": model,
            "locale": Locale.current.identifier,
        ]
        // Events that weren't delivered last time
        if let data = try? Data(contentsOf: queueFile),
           let saved = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]] {
            queue = saved
        }
        Timer.scheduledTimer(withTimeInterval: 10, repeats: true) { [weak self] _ in self?.flush() }
        #if canImport(UIKit)
        NotificationCenter.default.addObserver(
            forName: UIApplication.didEnterBackgroundNotification, object: nil, queue: nil
        ) { [weak self] _ in self?.flush() }
        #endif
        track("$session_start")
    }

    func track(_ event: String, properties: [String: Any] = [:]) {
        guard JSONSerialization.isValidJSONObject(properties) else {
            return print("[AnyAnalytics] \(event): properties must be JSON types")
        }
        let uuid = UUID().uuidString.lowercased() // kept across retries for deduplication
        let timestamp = Self.now()
        worker.async {
            self.queue.append([
                "uuid": uuid,
                "event": event,
                "distinct_id": self.distinctId,
                "session_id": self.sessionId,
                "timestamp": timestamp,
                "properties": properties,
                "context": self.context,
            ])
            if self.queue.count > self.maxQueue {
                self.queue.removeFirst(self.queue.count - self.maxQueue)
                if !self.warnedFull {
                    self.warnedFull = true
                    print("[AnyAnalytics] Queue full, dropping the oldest events.")
                }
            }
            if self.queue.count >= 20 { self.send() }
        }
    }

    func identify(_ userId: String, traits: [String: Any] = [:]) {
        worker.async {
            let anonymousId = self.distinctId
            self.distinctId = userId
            UserDefaults.standard.set(userId, forKey: "anyanalytics_id")
            self.track("$identify", properties: ["$anon_distinct_id": anonymousId, "$set": traits])
        }
    }

    func flush() {
        worker.async {
            self.save()
            self.send()
        }
    }

    // One request at a time; events leave the queue only once the server accepts them.
    private func send() {
        guard inFlight.isEmpty, !queue.isEmpty, Date() >= retryAt else { return }
        inFlight = Array(queue.prefix(batchSize))
        queue.removeFirst(inFlight.count)
        var request = URLRequest(url: endpoint)
        request.httpMethod = "POST"
        request.setValue("application/json", forHTTPHeaderField: "Content-Type")
        request.setValue("Bearer \(writeKey)", forHTTPHeaderField: "Authorization")
        request.httpBody = try? JSONSerialization.data(withJSONObject: ["sent_at": Self.now(), "batch": inFlight])
        URLSession.shared.dataTask(with: request) { data, response, error in
            let http = response as? HTTPURLResponse
            let status = error == nil ? (http?.statusCode ?? 0) : 0 // 0 = network error
            let retryAfter = http?.value(forHTTPHeaderField: "Retry-After")
            self.worker.async { self.handle(status: status, retryAfter: retryAfter, body: data) }
        }.resume()
    }

    private func handle(status: Int, retryAfter: String?, body: Data?) {
        let batch = inFlight
        inFlight = []
        switch status {
        case 200..<300:
            backoff = 0
            batchSize = maxBatch
        case 413 where batch.count > 1:
            // Body too large: retry the same events in smaller batches
            batchSize = max(1, batch.count / 2)
            queue.insert(contentsOf: batch, at: 0)
        case 0, 408, 429, 500...:
            // Offline, timeout, rate limited or server busy: keep the events, back off
            // (1s doubling to 60s with jitter; Retry-After wins when sent)
            queue.insert(contentsOf: batch, at: 0)
            backoff = backoff == 0 ? 1 : min(backoff * 2, 60)
            let delay = retryAfter.flatMap { TimeInterval($0) } ?? backoff * .random(in: 0.5...1)
            retryAt = Date().addingTimeInterval(delay)
        default:
            // Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
            let reason = body.map { String(decoding: $0, as: UTF8.self) } ?? ""
            print("[AnyAnalytics] Dropped \(batch.count) event(s): HTTP \(status) \(reason)")
        }
        save()
        if (200..<300).contains(status) || status == 413 { send() } // keep draining
    }

    // Unsent events survive restarts (and are resent with the same uuids)
    private func save() {
        guard let data = try? JSONSerialization.data(withJSONObject: inFlight + queue) else { return }
        try? FileManager.default.createDirectory(
            at: queueFile.deletingLastPathComponent(), withIntermediateDirectories: true)
        try? data.write(to: queueFile, options: .atomic)
    }

    private static func now() -> String {
        ISO8601DateFormatter().string(from: Date())
    }
}
```

- Properties must be JSON types: String, Int, Double, Bool, arrays and dictionaries.
- Undelivered events are saved in Application Support and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.

## Track events

Track from anywhere in your app. The call is thread-safe and returns immediately.

`Checkout.swift`:

```swift
AnyAnalytics.shared.track("purchase_completed", properties: ["product": "premium", "price": 4.99])
```

Events are sent every 10 seconds, as soon as 20 are queued, and when the app moves to the background. Anything that couldn't be sent is kept for the next launch.

## Identify users

Each install gets a random id, saved in `UserDefaults`. After sign-in, switch to your own user id:

`Login.swift`:

```swift
// After sign-in
AnyAnalytics.shared.identify(user.id, traits: ["email": user.email, "plan": "pro"])
```

Their earlier anonymous events are linked to the same person, and the traits are saved on their profile. The new id is remembered across launches.

## Screen views

Screen views are `$screen` events with a `$screen_name` property, the same events the JavaScript SDK sends:

`SettingsView.swift`:

```swift
// SwiftUI: record a screen view when it appears
.onAppear { AnyAnalytics.shared.track("$screen", properties: ["$screen_name": "Settings"]) }
```

## Sessions and context

A new session starts each time the app's process starts. Every event carries the app version (`CFBundleShortVersionString`), the iOS version, the device model and the locale. See [Events and people](https://anyanalytics.org/docs/events.md) for how events, sessions and people fit together.

---

Web version: https://anyanalytics.org/docs/ios. All docs: https://anyanalytics.org/docs.md

# Android (Kotlin)

> Track Android apps with one Kotlin file.

One Kotlin file tracks an Android app. It uses only the Android SDK, keeps events on disk while the device is offline, and sends them in batches on a background thread.

## Install

1. **Add the file**

   Add `AnyAnalytics.kt` to your app module with the code below, and change the `package` line to yours. Your project's setup page shows it with your write key filled in.

2. **Start it in your Application**

   Call `AnyAnalytics.init(this)` in your `Application` class's `onCreate()`.

3. **Run the app**

   A `$session_start` event is sent on startup. It shows up in **Live events** within about 10 seconds.

`AnyAnalytics.kt`:

```kotlin
// AnyAnalytics.kt: queues events on disk and posts them to AnyAnalytics.
// Every event gets a uuid when tracked, so a retried batch is never counted twice.
package com.example.analytics // ← your package

import android.content.ComponentCallbacks2
import android.content.Context
import android.content.SharedPreferences
import android.content.res.Configuration
import android.os.Build
import android.util.Log
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
import java.text.SimpleDateFormat
import java.util.Date
import java.util.Locale
import java.util.TimeZone
import java.util.UUID
import java.util.concurrent.Executors
import java.util.concurrent.TimeUnit
import kotlin.math.min
import kotlin.random.Random

object AnyAnalytics {
    private const val HOST = "https://api.anyanalytics.org"
    private const val WRITE_KEY = "vxw_YOUR_WRITE_KEY"
    private const val MAX_QUEUE = 1000 // oldest events are dropped beyond this
    private const val MAX_BATCH = 100
    private const val TAG = "AnyAnalytics"

    // A single thread owns the queue, so only one request is ever in flight
    private val executor = Executors.newSingleThreadScheduledExecutor()
    private val queue = ArrayList<JSONObject>()
    private val sessionId = UUID.randomUUID().toString()
    private lateinit var prefs: SharedPreferences
    private lateinit var context: JSONObject
    private lateinit var queueFile: File
    @Volatile private var distinctId = ""
    private var batchSize = MAX_BATCH
    private var backoffMs = 0L
    private var retryAt = 0L
    private var warnedFull = false

    fun init(app: Context) {
        prefs = app.getSharedPreferences("anyanalytics", Context.MODE_PRIVATE)
        distinctId = prefs.getString("distinct_id", null)
            ?: UUID.randomUUID().toString().also { prefs.edit().putString("distinct_id", it).apply() }
        val version = app.packageManager.getPackageInfo(app.packageName, 0).versionName ?: ""
        context = JSONObject()
            .put("platform", "android")
            .put("app_version", version)
            .put("os", "Android")
            .put("os_version", Build.VERSION.RELEASE)
            .put("device", Build.MANUFACTURER + " " + Build.MODEL)
            .put("locale", Locale.getDefault().toLanguageTag())
        queueFile = File(app.filesDir, "anyanalytics_queue.json")
        executor.execute { load() }
        executor.scheduleWithFixedDelay({ save(); send() }, 10, 10, TimeUnit.SECONDS)
        // App moved to the background: save and send what's queued
        app.registerComponentCallbacks(object : ComponentCallbacks2 {
            override fun onTrimMemory(level: Int) {
                if (level >= ComponentCallbacks2.TRIM_MEMORY_UI_HIDDEN) flush()
            }
            override fun onConfigurationChanged(newConfig: Configuration) {}
            @Deprecated("Deprecated in Java") override fun onLowMemory() {}
        })
        track("\$session_start")
    }

    fun track(event: String, properties: Map<String, Any?> = emptyMap()) {
        val payload = JSONObject()
            .put("uuid", UUID.randomUUID().toString()) // kept across retries for deduplication
            .put("event", event)
            .put("distinct_id", distinctId)
            .put("session_id", sessionId)
            .put("timestamp", now())
            .put("properties", JSONObject(properties))
            .put("context", context)
        executor.execute {
            queue.add(payload)
            if (queue.size > MAX_QUEUE) {
                queue.subList(0, queue.size - MAX_QUEUE).clear()
                if (!warnedFull) { warnedFull = true; Log.w(TAG, "Queue full, dropping the oldest events") }
            }
            if (queue.size >= 20) send()
        }
    }

    fun identify(userId: String, traits: Map<String, Any?> = emptyMap()) {
        val anonymousId = distinctId
        distinctId = userId
        prefs.edit().putString("distinct_id", userId).apply()
        track("\$identify", mapOf("\$anon_distinct_id" to anonymousId, "\$set" to traits))
    }

    fun flush() = executor.execute { save(); send() }

    // Runs on the executor; events leave the queue only once the server accepts them.
    private fun send() {
        while (queue.isNotEmpty() && System.currentTimeMillis() >= retryAt) {
            val batch = queue.take(batchSize)
            var status = 0 // 0 = network error
            var retryAfter: String? = null
            try {
                val connection = (URL("$HOST/v1/batch").openConnection() as HttpURLConnection).apply {
                    requestMethod = "POST"
                    doOutput = true
                    connectTimeout = 10_000
                    readTimeout = 10_000
                    setRequestProperty("Content-Type", "application/json")
                    setRequestProperty("Authorization", "Bearer $WRITE_KEY")
                }
                val body = JSONObject().put("sent_at", now()).put("batch", JSONArray(batch)).toString()
                connection.outputStream.use { it.write(body.toByteArray()) }
                status = connection.responseCode
                retryAfter = connection.getHeaderField("Retry-After")
                connection.disconnect()
            } catch (e: Exception) {
                Log.w(TAG, "Send failed: " + e)
            }
            when {
                status in 200..299 -> {
                    queue.subList(0, batch.size).clear()
                    backoffMs = 0
                    batchSize = MAX_BATCH
                }
                // Body too large: retry the same events in smaller batches
                status == 413 && batch.size > 1 -> batchSize = maxOf(1, batch.size / 2)
                status == 0 || status == 408 || status == 429 || status >= 500 -> {
                    // Offline, timeout, rate limited or server busy: keep the events, back off
                    // (1s doubling to 60s with jitter; Retry-After wins when sent)
                    backoffMs = if (backoffMs == 0L) 1_000L else min(backoffMs * 2, 60_000L)
                    val delayMs = retryAfter?.trim()?.toLongOrNull()?.times(1000)
                        ?: (backoffMs * Random.nextDouble(0.5, 1.0)).toLong()
                    retryAt = System.currentTimeMillis() + delayMs
                }
                else -> {
                    // Other 4xx (bad key, invalid payload) or one oversized event: never succeeds
                    Log.w(TAG, "Dropped " + batch.size + " event(s): HTTP " + status)
                    queue.subList(0, batch.size).clear()
                }
            }
            save()
        }
    }

    // Unsent events survive restarts (and are resent with the same uuids)
    private fun save() {
        try {
            queueFile.writeText(JSONArray(queue).toString())
        } catch (e: Exception) {
            Log.w(TAG, "Couldn't save the queue: " + e)
        }
    }

    private fun load() {
        try {
            if (!queueFile.exists()) return
            val saved = JSONArray(queueFile.readText())
            for (i in 0 until saved.length()) queue.add(saved.getJSONObject(i))
        } catch (e: Exception) {
            Log.w(TAG, "Couldn't read the saved queue: " + e)
        }
    }

    private fun now(): String =
        SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
            .apply { timeZone = TimeZone.getTimeZone("UTC") }
            .format(Date())
}
```

- Needs <uses-permission android:name="android.permission.INTERNET" /> in AndroidManifest.xml.
- Undelivered events are saved in the app's files directory and retried with backoff (offline, 429, 5xx). Each event keeps its uuid, so retries are never counted twice.

If your app doesn't have an `Application` class yet, add one and register it with `android:name=".App"` on the `<application>` tag of your manifest:

`App.kt`:

```kotlin
import android.app.Application

class App : Application() {
    override fun onCreate() {
        super.onCreate()
        AnyAnalytics.init(this)
    }
}
```

> **Heads up**
>
> Call `AnyAnalytics.init` before anything else uses it. Tracking before init fails, which is why `Application.onCreate()` is the place for it.

## Track events

Track from any thread. The call returns immediately.

`CheckoutActivity.kt`:

```kotlin
AnyAnalytics.track("purchase_completed", mapOf("product" to "premium", "price" to 4.99))
```

Events are sent every 10 seconds, as soon as 20 are queued, and when the app's UI is hidden. Anything that couldn't be sent is kept for the next launch.

## Identify users

Each install gets a random id, saved in `SharedPreferences`. After sign-in, switch to your own user id:

`LoginActivity.kt`:

```kotlin
// After sign-in
AnyAnalytics.identify(user.id, mapOf("email" to user.email, "plan" to "pro"))
```

Their earlier anonymous events are linked to the same person, and the traits are saved on their profile. The new id is remembered across launches.

## Screen views

Screen views are `$screen` events with a `$screen_name` property, the same events the JavaScript SDK sends. In Kotlin, escape the `$`:

`SettingsActivity.kt`:

```kotlin
// In an Activity's onResume, or when a Compose screen is shown
AnyAnalytics.track("\$screen", mapOf("\$screen_name" to "Settings"))
```

## Sessions and context

A new session starts each time the app's process starts. Every event carries the app's version name, the Android version, the device maker and model, and the locale. See [Events and people](https://anyanalytics.org/docs/events.md) for how events, sessions and people fit together.

---

Web version: https://anyanalytics.org/docs/android. All docs: https://anyanalytics.org/docs.md

# HTTP API

> Send events from any language or engine with one POST request.

Every SDK sends events with the same HTTP request, and so can you. Use it from engines and languages without an SDK (Unreal, Flutter, Python, Go, Rust…), or to import events from your own systems.

`terminal`:

```sh
curl -X POST https://api.anyanalytics.org/v1/batch \
  -H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"batch":[{"uuid":"'"$(uuidgen | tr A-Z a-z)"'","event":"$session_start","distinct_id":"user_123","session_id":"s_1","context":{"platform":"custom","app_version":"1.0.0"}}]}'
```

Run it with your write key and the event appears in **Live events** within a second.

## Endpoint and authentication

`POST https://api.anyanalytics.org/v1/batch`

Send your project's write key in one of these ways. The first one found is used:

| Where | Example |
| --- | --- |
| Authorization header | `Authorization: Bearer vxw_YOUR_WRITE_KEY` |
| X-Write-Key header | `X-Write-Key: vxw_YOUR_WRITE_KEY` |
| Query parameter | `?api_key=vxw_YOUR_WRITE_KEY` |
| Body field | `"api_key": "vxw_YOUR_WRITE_KEY"` |

Write keys only send events; they can't read any data, so it's fine to ship them in apps and games. The body is JSON, whatever the `Content-Type` header says. Browsers can call the endpoint from any origin.

## The request body

`body.json`:

```json
{
  "sent_at": "2026-05-04T10:15:02.120Z",
  "batch": [
    {
      "uuid": "0196a3c2-5b1e-7c4a-9f10-2b6d8e4a1c37",
      "event": "level_completed",
      "distinct_id": "player_8f2c",
      "session_id": "s_20260504_1",
      "timestamp": "2026-05-04T10:14:58.900Z",
      "properties": { "level": 5, "score": 1200 },
      "context": { "platform": "windows", "app_version": "1.4.0" }
    }
  ]
}
```

| Field | What it is |
| --- | --- |
| `batch` | Required. 1 to 500 events. |
| `sent_at` | Optional. Your device's clock when the request was sent. Used to correct event timestamps from devices whose clock is wrong. |
| `api_key` | Optional. Your write key, if you don't send it in a header. |

### Event fields

| Field | Rules |
| --- | --- |
| `event` | Required. The event name, 1 to 200 characters. |
| `distinct_id` | Required. Who did it: your user id, or an anonymous id you keep per device. 1 to 200 characters. |
| `uuid` | Recommended. A UUID you create with the event. A uuid the project has already received is counted as a duplicate, not stored again. |
| `timestamp` | When it happened: ISO 8601 with a time zone (Z or +02:00), or epoch milliseconds. Defaults to when it's received. |
| `session_id` | Groups events into a session. Any string up to 64 characters. |
| `anonymous_id` | The device's anonymous id, when distinct_id is already your user id. Links the two. Up to 200 characters. |
| `properties` | Any JSON object, up to 32 KB. Defaults to {}. |
| `context` | Where it happened: device and app fields (below), up to 8 KB. |

### Context fields

These become filters and breakdowns in every report. Other keys are allowed and kept with the event.

| Field | Example |
| --- | --- |
| `platform` | web, ios, android, windows, server… |
| `app_version` | 1.4.0 |
| `app_build` | 212 |
| `os` | Windows, iOS, Android |
| `os_version` | 17.5 |
| `device` | iPhone15,2 |
| `device_type` | desktop, mobile or tablet |
| `browser` | Chrome |
| `browser_version` | 126 |
| `locale` | en-US |
| `timezone` | Europe/Paris |
| `country` | Two-letter code, such as US |
| `region` | California |
| `city` | San Francisco |

Leave out `country`, `region` and `city` and they're filled in from the request's IP address, except when `platform` is `server` or `node`: a server isn't where your user is. When `platform` is `web` or missing, browser and operating system are read from the request's `User-Agent`.

### Timestamps

- With `sent_at`, every timestamp in the batch is shifted by the difference between your device's clock and ours. Send a fresh `sent_at` on each retry.
- Events older than 60 days are rejected.
- Timestamps more than an hour in the future are replaced by the time received.

## Track and identify

A custom event:

`terminal`:

```sh
curl -X POST https://api.anyanalytics.org/v1/batch \
  -H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"batch":[{"uuid":"'"$(uuidgen | tr A-Z a-z)"'","event":"signup","distinct_id":"user_123","properties":{"plan":"pro","source":"pricing_page"}}]}'
```

To tell us who a user is, send a `$identify` event from their user id. Put the anonymous id they had before in `$anon_distinct_id`, and the earlier events are linked to the same person. Traits in `$set` are saved on their profile.

`terminal`:

```sh
curl -X POST https://api.anyanalytics.org/v1/batch \
  -H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{"batch":[{"uuid":"'"$(uuidgen | tr A-Z a-z)"'","event":"$identify","distinct_id":"user_123","properties":{"$anon_distinct_id":"anon_456","$set":{"email":"ada@example.com"}}}]}'
```

`$set` works on any event, not only `$identify`. Use `$set_once` for traits that should keep their first value, such as a signup date. See [Events and people](https://anyanalytics.org/docs/events.md).

## Limits

| What | Limit |
| --- | --- |
| Events per request | 500 |
| Request body | 1 MiB (after decompression) |
| Event name, distinct_id | 200 characters |
| properties | 32 KB of JSON |
| context | 8 KB of JSON |
| session_id | 64 characters |
| Event age | 60 days |

Requests are also rate limited per project and per sender. When you go over, you get a `429` with a `Retry-After` header.

## Compression

Large batches compress well. Gzip the body and add `Content-Encoding: gzip`:

`terminal`:

```sh
gzip -c events.json | curl -X POST https://api.anyanalytics.org/v1/batch \
  -H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -H "Content-Encoding: gzip" \
  --data-binary @-
```

## The response

A `200` means the batch was processed. Each event is checked on its own, so one bad event never fails the others:

`response.json`:

```json
{
  "accepted": 1,
  "duplicates": 0,
  "rejected": [],
  "filtered": 0
}
```

| Field | What it means |
| --- | --- |
| `accepted` | Events stored. |
| `duplicates` | Events whose uuid was already received. Not stored again. |
| `rejected` | Invalid events, each with its position in the batch (index), its uuid when it had a valid one, and the errors. |
| `filtered` | Valid events dropped on purpose: known bots, referrer spam, or traffic from IP addresses you excluded in the project's settings. Not an error. |

`response.json`:

```json
{
  "accepted": 1,
  "duplicates": 0,
  "rejected": [
    { "index": 1, "uuid": "0196a3c2-5b20-7d11-8a4e-6c3f0b9d2e81", "errors": ["distinct_id: Too small: expected string to have >=1 characters"] }
  ],
  "filtered": 0
}
```

> **Tip**
>
> Rejected events also appear in **Live events** with the reason, which is the quickest way to debug a new integration.

### Status codes

| Status | Meaning | What to do |
| --- | --- | --- |
| 200 | Processed. Check rejected. | Done. |
| 400 | `invalid_json` or `invalid_batch` (with `details`) | Fix the request. Don't retry. |
| 401 | `invalid_write_key` | Check the key. Don't retry. |
| 413 | `payload_too_large` | Split the batch and send the halves. |
| 429 | `rate_limited` | Retry after Retry-After seconds. |
| 503 | Temporarily unavailable | Retry after Retry-After seconds. |

## Retries

Network errors happen, especially on phones and consoles. To never lose or double count:

- Give every event a `uuid` when you create it, and keep it. Without one, a retried event is stored twice.
- Retry network errors, `408`, `429` and `5xx` with the **same** events and uuids. Wait for `Retry-After` when it's sent, otherwise back off exponentially (1 s, 2 s, 4 s… up to a minute or so).
- On `413`, split the batch. Never retry other `4xx` responses: they'll fail the same way again.
- Keep unsent events on disk, so they survive the app being closed.

The [Unity](https://anyanalytics.org/docs/unity.md), [Godot](https://anyanalytics.org/docs/godot.md), [Swift](https://anyanalytics.org/docs/ios.md) and [Kotlin](https://anyanalytics.org/docs/android.md) files are short working examples of all of this, if you want to port one to another engine.

## Examples

### Python

`send_events.py`:

```python
# pip install requests
import time
import uuid
from datetime import datetime, timezone

import requests

HOST = "https://api.anyanalytics.org"
WRITE_KEY = "vxw_YOUR_WRITE_KEY"


def now():
    return datetime.now(timezone.utc).isoformat()


def event(name, distinct_id, properties=None):
    # The uuid is created once, with the event, and reused on every retry
    return {
        "uuid": str(uuid.uuid4()),
        "event": name,
        "distinct_id": distinct_id,
        "timestamp": now(),
        "properties": properties or {},
    }


def send(events, attempts=6):
    for attempt in range(attempts):
        res = None
        try:
            res = requests.post(
                HOST + "/v1/batch",
                json={"sent_at": now(), "batch": events},
                headers={"Authorization": "Bearer " + WRITE_KEY},
                timeout=10,
            )
        except requests.RequestException:
            pass  # network error: retry below

        if res is not None and res.ok:
            for r in res.json()["rejected"]:
                print("rejected", events[r["index"]]["event"], r["errors"])
            return
        if res is not None and res.status_code not in (408, 429) and res.status_code < 500:
            # Bad key or bad payload: retrying won't help
            raise RuntimeError(f"HTTP {res.status_code}: {res.text}")

        retry_after = res.headers.get("Retry-After") if res is not None else None
        time.sleep(float(retry_after) if retry_after else 2**attempt)
    raise RuntimeError("gave up; keep the events and try again later")


send([event("order_completed", "user_123", {"order_id": "ord_123", "total": 49.9})])
```

### C# (.NET)

`Program.cs`:

```csharp
// .NET 6 or newer
using System;
using System.Net.Http;
using System.Net.Http.Json;

var http = new HttpClient { Timeout = TimeSpan.FromSeconds(10) };
http.DefaultRequestHeaders.Add("Authorization", "Bearer vxw_YOUR_WRITE_KEY");

var batch = new[]
{
    new
    {
        uuid = Guid.NewGuid().ToString(), // keep it if you resend this event
        @event = "order_completed",
        distinct_id = "user_123",
        timestamp = DateTime.UtcNow.ToString("o"),
        properties = new { order_id = "ord_123", total = 49.9 },
    },
};

var response = await http.PostAsJsonAsync("https://api.anyanalytics.org/v1/batch", new
{
    sent_at = DateTime.UtcNow.ToString("o"),
    batch,
});

var status = (int)response.StatusCode;
if (status == 408 || status == 429 || status >= 500)
{
    // Send the same batch again later, after Retry-After when it's given
    var wait = response.Headers.RetryAfter?.Delta ?? TimeSpan.FromSeconds(5);
}
else if (!response.IsSuccessStatusCode)
{
    // Bad key or bad payload: don't retry
    Console.Error.WriteLine(await response.Content.ReadAsStringAsync());
}
```

---

Web version: https://anyanalytics.org/docs/http-api. All docs: https://anyanalytics.org/docs.md

# Events and people

> Naming events, properties, identifying users, sessions, and the limits that apply.

Everything in AnyAnalytics is an event: a name, some properties, who did it and when. Pageviews, level completions and purchases all go through the same pipe, so every report works with every event. This page covers how to name them, what they can carry, and how events become people and sessions.

## Naming events

Pick names that say what happened, in the past tense, in `snake_case`: `signup_completed`, `level_completed`, `item_purchased`. A few habits keep reports tidy:

- Keep the name generic and put the details in properties. Send `level_completed` with `{ level: 3 }`, not `level_3_completed`.
- Names are case-sensitive and compared exactly. `Signup` and `signup` are two different events.
- Names can be up to 200 characters. Leading and trailing spaces are trimmed.
- Don't start your own event names with `$`. That prefix is used by the events the SDKs send for you.

## Properties

Properties are a JSON object: strings, numbers, booleans, `null`, arrays and nested objects all work. Values that can't be turned into JSON (functions, for example) make the SDK drop the event and log why.

`track.js`:

```ts
analytics.track("level_completed", {
  level: 5,
  duration: 82,
  score: 1200,
  difficulty: "hard",
  power_ups: ["shield", "magnet"],
});
```

Use the same property name and type for the same idea across events (`level` is always a number, `plan` is always a string). Breakdowns and filters then line up across your whole project.

> **Heads up**
>
> Don't put secrets or personal data you don't need into properties. They're stored as you send them.

### Reserved property names

| Property | Meaning |
| --- | --- |
| `revenue` | A number (or numeric string) counted as money. See [Revenue](https://anyanalytics.org/docs/revenue.md). |
| `currency` | ISO code of the revenue, such as USD or EUR. |
| `$set` | Person properties to set, overwriting earlier values. |
| `$set_once` | Person properties to set only if they were never set. |

## Events sent for you

The SDKs and the script tag send a few events with a `$` prefix. They power the built-in reports, and the busy ones are kept out of "Top events" lists.

| Event | Sent when | Key properties |
| --- | --- | --- |
| `$pageview` | A page loads or a single-page app navigates (script tag, or the SDK with pageviews on) | `$url`, `$pathname`, `$title`, `$referrer`, `$referring_domain`, `utm_*` |
| `$screen` | You call `screen("Shop")` in an app or game | `$screen_name` |
| `$outbound_click` | A visitor clicks a link to another site (script tag) | `$outbound_url`, `$outbound_domain` |
| `$session_start` | The first event of a new session | None |
| `$identify` | You call `identify()` | `$anon_distinct_id`, `$set` |
| `$exception` | Your app or game reports an error (the engine and mobile SDKs can catch them for you) | See [Game events](https://anyanalytics.org/docs/game-events.md) |
| `$payment`, `$refund` | Your Stripe integration records a payment or refund | `revenue` or `refund`, `currency` |

## People and identify()

Each device gets a random anonymous id the first time it runs your code. Every event carries it, so you get a full picture of a visitor or player before they ever sign in. When they do, call `identify()` with your own user id:

`auth.js`:

```ts
// After sign-in: everything this device sent before is merged into user_123
analytics.identify("user_123", {
  name: "Ada Lovelace",
  email: "ada@example.com",
  avatar: "https://example.com/ada.png", // shown instead of their critter
  plan: "pro",
});

// On sign-out: forget the user, start a new anonymous id and session
analytics.reset();
```

- From then on, events are sent under your user id. The device's anonymous id is linked to it, so the person's earlier anonymous events count as theirs too.
- The same user signing in on a phone and a laptop becomes one person with both devices' history.
- An anonymous id belongs to the first user it was linked to. Call `reset()` on sign-out so the next person on a shared device starts fresh.
- Use a stable id that never changes, such as your database id, rather than an email address. Ids can be up to 200 characters.
- Send `name`, `email` and `avatar` (an https image URL) traits and the dashboard shows the person by name and picture: in the Overview's Visitors tab, on Persons and in their story. Without them, a visitor gets a friendly critter name instead.

### Person properties

Traits passed to `identify()` become the person's properties, shown on their profile in **Persons**. You can also attach `$set` and `$set_once` to any event. Properties set while someone was still anonymous follow them once they're identified.

`billing.js`:

```ts
// Person properties can ride on any event
analytics.track("plan_changed", {
  plan: "team",
  $set: { plan: "team" },             // overwrites the latest value
  $set_once: { first_plan: "free" },  // keeps the first value ever sent
});
```

## Sessions

A session is a run of activity from one device. The SDKs start a new one after 30 minutes without any event, send a `$session_start`, and tag every event with the session's id. Each SDK lets you change that timeout. Session length is the time between a session's first and last event.

Server code has no device to remember a session on. With the JavaScript SDK on a server, use `capture()` and pass a `sessionId` yourself if you want events grouped; see the [JavaScript SDK](https://anyanalytics.org/docs/javascript.md).

## Context: platform, version and device

Alongside properties, each event carries a `context` describing where it came from. The SDKs fill in what they can detect; you can add the rest.

| Field | Example |
| --- | --- |
| `platform` | web, ios, android, windows, node… |
| `app_version` | 1.4.2 |
| `app_build` | 212 |
| `os`, `os_version` | Android, 15 |
| `device` | Pixel 9 |
| `browser`, `browser_version`, `device_type` | Chrome, 129, mobile |
| `locale`, `timezone` | en-US, Europe/Paris |
| `country`, `region`, `city` | Two-letter country code (US); usually left to us |

`analytics.js`:

```ts
analytics.init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  host: "https://…",
  context: { platform: "windows", app_version: "1.4.2", app_build: "212" },
});

// Later, once you know more
analytics.setContext({ device: "Steam Deck" });
```

Platform names are lowercased, so `Windows` and `windows` count as one. For web traffic, browser, operating system and device type are read from the browser when you don't send them. Location comes from the visitor's IP address unless you send a country yourself. Events from server code (platform `node` or `server`) get no location from the request, since a server's address says nothing about your user.

## Timestamps and late events

Each event records when it happened, not when it arrived. Games played offline can send events hours or days later and they still land at the right time.

- Device clocks are often wrong. The SDKs send their clock time with each batch, and event times are shifted by the difference to ours.
- Events more than 7 days old are rejected, or 60 days with a secret write key (for imports).
- Events more than 60 minutes in the future are recorded at the time they arrived.
- The SDKs give every event a unique id. If a batch is sent twice (after a network error, say), the copies are recognized and counted once.

## Limits

| What | Limit |
| --- | --- |
| Event name | 200 characters |
| User or anonymous id | 200 characters |
| Properties of one event | 32 KB as JSON |
| Context of one event | 8 KB as JSON |
| Events per request | 500 |
| Request size | 1 MB |

Each event is checked on its own: one invalid event is rejected with the reason, and the rest of the batch is still stored. The SDKs batch and retry for you; if you send events yourself, see the [HTTP API](https://anyanalytics.org/docs/http-api.md).

## What gets filtered out

- Search engine crawlers, link previews, uptime monitors and AI crawlers, whatever they send.
- Automated browsers. The script tag doesn't even start in them.
- Command-line and script HTTP clients claiming to be a web browser (platform `web`), and "browsers" with no user agent at all. The same clients are fine for server and game-engine events.
- Known referrer-spam domains.
- Your own traffic, if you set it up. See [Excluding your own traffic](https://anyanalytics.org/docs/privacy.md#exclude-your-traffic).

## Checking your events

Open **Live events** in your project. It shows every event as it arrives, marked accepted, duplicate or rejected, and rejected events come with the reason. Turn on your SDK's `debug` option (or `data-debug` on the script tag) to see what's being sent from the browser or app console.

---

Web version: https://anyanalytics.org/docs/events. All docs: https://anyanalytics.org/docs.md

# Game events

> The level, error and session events that power the Game report.

Game events are ordinary events with agreed names. Send them with `track()` from any SDK and the **Game** report fills itself in: where players get stuck, how long they play, and what crashes. They also work in every other report, like funnels and retention.

> **Tip**
>
> The [Unity](https://anyanalytics.org/docs/unity.md), [Godot](https://anyanalytics.org/docs/godot.md), [iOS](https://anyanalytics.org/docs/ios.md) and [Android](https://anyanalytics.org/docs/android.md) SDKs have helpers that send exactly these events, so you don't have to remember the names.

## Levels

| Event | Properties | Also accepted |
| --- | --- | --- |
| `level_start` | `level` (number or name, required) | `level_started` |
| `level_complete` | `level`, optional `duration` (seconds) and `score` | `level_completed` |
| `level_fail` | `level`, optional `duration` (seconds) and `reason` | `level_failed` |

`level.js`:

```ts
// The same "level" on every event of one attempt
analytics.track("level_start", { level: 3 });

// ...the player wins
analytics.track("level_complete", { level: 3, duration: 74, score: 1200 });

// ...or loses
analytics.track("level_fail", { level: 3, duration: 41, reason: "died" });
```

- Use the same `level` value on the start and the end of an attempt, or they won't line up.
- `level` can be a number or a name. Numbers sort naturally (1, 2, 10), and names come after them, so `"boss"` follows your numbered levels.
- Send `duration` in seconds. The report shows the median time to complete each level.
- `reason` is up to you (`"died"`, `"timeout"`, `"quit"`). Break `level_fail` down by it in Insights to see why players lose.

## Errors and crashes

| Event | Properties |
| --- | --- |
| `$exception` or `error` | `$exception_type` (or `type`), `$exception_message` (or `message`), `fatal` |

`errors.js`:

```ts
analytics.track("$exception", {
  $exception_type: "NullReferenceException",
  $exception_message: "Object reference not set",
  fatal: false, // true counts as a crash
});
```

`fatal: true` marks a crash. Errors are grouped by type and message (the first 300 characters), so keep changing details like ids or file paths out of the message.

You often don't need to send these yourself. The Unity and Godot SDKs report unhandled errors as non-fatal `$exception` events, and the iOS and Android SDKs record uncaught exceptions as crashes. Each can be turned off; see the page for your engine.

## Sessions, platforms and versions

Nothing to send here. The SDKs start a session on the first event and a new one after 30 minutes without any, and they add the `platform` and your `app_version` to every event. The engine and mobile SDKs read the version from your project settings, and each lets you override it. See [Context](https://anyanalytics.org/docs/events.md#context).

## What the Game report shows

The **Game** page is in the sidebar of projects set up as a game. You can filter the whole report to one platform or app version.

- **Headline numbers**: players, sessions, median and average session length, level completion rate with the hardest level, and crash-free sessions with the error rate.
- **Activity chart**: players, sessions and sessions with errors over time.
- **Levels**: for each level, starts, completions and fails (as a share of starts), the median completion time, how many players reached it, and where players are right now (the level they started most recently).
- **Sessions**: session count, players, length, and error and crash rates per platform or per app version, so a bad build stands out.
- **Errors and crashes**: the most frequent errors with how many players hit them, how many were fatal, when they were last seen and which version reported them most.

### How the numbers are counted

- The hardest level is the one with the highest fail rate among levels started at least 10 times.
- Crash-free sessions are sessions without a fatal error.
- The error rate is the share of sessions with any error.
- A session's platform and version are those of its first event, so a session that updates mid-way still counts once.

## Economy, ads and purchases

The Game report reads only the events above, but any event works in Insights. These names are a good start for the rest of your game:

`economy.js`:

```ts
analytics.track("resource_earned", { resource: "gold", amount: 50, source: "quest" });
analytics.track("resource_spent", { resource: "gold", amount: 30, item: "sword" });
analytics.track("ad_watched", { placement: "extra_life", reward: true });
analytics.track("purchase_completed", { item: "gem_pack_s", revenue: 4.99, currency: "USD" });
```

A `revenue` property on any event counts as money in your reports. See [Revenue](https://anyanalytics.org/docs/revenue.md).

---

Web version: https://anyanalytics.org/docs/game-events. All docs: https://anyanalytics.org/docs.md

# Revenue

> Record purchases with a revenue property, connect Stripe, or post payments from your own backend.

There are three ways to get revenue into AnyAnalytics: add a `revenue` property to your own events, connect your Stripe account so payments and refunds are recorded for you, or post them from your own backend, whatever takes the payments. Either way, revenue shows on the Overview next to the visitors and players who brought it.

## The revenue property

Any event with a numeric `revenue` property counts as revenue. Add an optional `currency` with the three-letter ISO code.

`shop.js`:

```ts
// Any event with a numeric "revenue" property counts as revenue
analytics.track("purchase_completed", {
  item: "gem_pack_s",
  revenue: 4.99,
  currency: "USD",
});
```

- Send the amount in major units: `4.99` dollars, not `499` cents.
- Numbers and numeric strings (`"4.99"`) both work. Anything else counts as zero.
- The event name is up to you. `purchase_completed`, `order_paid` or `subscription_renewed` all count.
- Send revenue from wherever the payment is confirmed. For real money, that's usually your server, so a player can't fake a purchase.

## Where revenue shows up

When the period you're looking at has revenue, the Overview shows revenue, conversion rate (the share of people who paid) and revenue per person to its headline numbers. Revenue is credited to the source, entry page, country and device of the visit that brought the person, so the breakdowns show which channels make money, not just traffic. The **Journey** tab under Goals lists the paying customers and how they first found you.

### Goals

Each goal on the Overview shows the revenue of the events that match it. Make your purchase event a goal to see its completions, conversion rate and revenue side by side.

## Several currencies

Amounts in different currencies are never added together. Each report picks one currency, the one used by most revenue events in the period, and totals that. Revenue in any other currency is listed separately next to the total, marked as not included.

Events without a `currency` are counted in the report's currency. If you only ever sell in one currency you can leave it out; if you sell in several, always send it.

## Refunds

Refunds recorded by the Stripe integration appear as dashed bars on top of the revenue chart. They're shown alongside revenue rather than subtracted from it, so you can see both.

## Connect Stripe

If you take payments with Stripe, connect it to record real payments and refunds without sending any events yourself. It's included on every plan. Only owners and admins of a project can connect it.

1. **Add a webhook endpoint in Stripe**

   In your project, open **Settings → Revenue** and copy the webhook URL. In Stripe, open **Developers → Webhooks → Add destination**, paste the URL and select these events:

   - `checkout.session.completed`
   - `checkout.session.async_payment_succeeded`
   - `invoice.paid`
   - `charge.refunded`
2. **Paste the signing secret**

   Stripe shows a signing secret for the new endpoint, starting with `whsec_`. Paste it in **Settings → Revenue** and click **Connect**. It's stored encrypted and never shown again; every delivery is checked against it.

3. **Pass the visitor into Checkout**

   So that each payment is credited to the right visitor, send their AnyAnalytics ids to your checkout endpoint and put them in the Checkout Session's metadata.

`checkout-button.js`:

```ts
// In the browser, when the visitor clicks "Buy" (script tag: window.anyanalytics,
// npm SDK: the analytics instance you initialized).
const res = await fetch("/api/checkout", {
  method: "POST",
  headers: { "Content-Type": "application/json" },
  body: JSON.stringify({
    priceId: "price_123",
    visitorId: anyanalytics.distinctId,
    sessionId: anyanalytics.sessionId,
  }),
});
location.href = (await res.json()).url;
```

`api/checkout.js`:

```ts
// On your server (Node, stripe package). The ids also go on the payment /
// subscription so refunds and renewals are attributed to the same visitor.
const metadata = {
  anyanalytics_visitor_id: body.visitorId ?? "",
  anyanalytics_session_id: body.sessionId ?? "",
};
const session = await stripe.checkout.sessions.create({
  mode: "payment", // or "subscription"
  line_items: [{ price: body.priceId, quantity: 1 }],
  success_url: "https://example.com/thanks",
  client_reference_id: body.visitorId,
  metadata,
  payment_intent_data: { metadata }, // mode "payment"
  // subscription_data: { metadata }, // mode "subscription"
});
return { url: session.url };
```

The metadata keys are `anyanalytics_visitor_id` and `anyanalytics_session_id`. If there's no visitor id in the metadata, the Checkout Session's `client_reference_id` is used instead.

> **Test it**
>
> Use **Send test event** on the endpoint in Stripe. The Revenue settings show when the last delivery arrived, and the last error if one was rejected.

### What's recorded

- Paid one-time Checkout payments, as a `$payment` event with `revenue` and `currency`. Payments by bank debit count once they succeed.
- Subscription invoices, the first one and every renewal. Invoices sent through Stripe Invoicing that aren't part of a subscription are not counted.
- Refunds, as a `$refund` event for the amount refunded. Partial refunds are recorded one by one.
- Stripe sometimes delivers the same event twice. Each one is recorded once.

### How payments are matched to visitors

- A payment is credited to the visitor named in its metadata. It takes that visitor's device, location and session, so it lands on the source that brought them.
- Renewals and refunds are matched through the ids on the payment or subscription metadata (the server snippet above sets them), or through the Stripe customer remembered from their first Checkout.
- Payments that can't be matched still count, as a person of their own.

Disconnecting Stripe stops new payments and refunds from being recorded. Revenue already recorded stays.

## Payments from your own backend

Taking payments with Paddle, PayPal, Lemon Squeezy, crypto, bank transfers or your own billing? Your server posts each payment and refund to a webhook, and they're recorded exactly like Stripe's: `$payment` and `$refund` events, credited to the visitor and the source that brought them.

1. **Create the webhook**

   In the site's **Settings → Revenue**, under *Your own backend*, click **Create webhook**. Copy the secret into your server's environment: it's shown once (only a fingerprint is kept), and **Rotate secret** makes a new one if it's lost or leaked.

2. **Keep the visitor on the order**

   At checkout, send `anyanalytics.distinctId` and `anyanalytics.sessionId` from the browser to your server and store them on the order, as in the Stripe snippet above. A signed-in user's id (the one you pass to `identify()`) works too.

3. **Post the payment once it's confirmed**

   `server.js`:

   ```ts
   // On your server, once the payment is confirmed (e.g. in your provider's
   // webhook handler). Store visitorId / sessionId on the order at checkout:
   // in the browser they're anyanalytics.distinctId and anyanalytics.sessionId.
   await fetch("https://api.anyanalytics.org/v1/webhooks/payments/<site id>", {
     method: "POST",
     headers: {
       Authorization: `Bearer ${process.env.ANYANALYTICS_PAYMENTS_SECRET}`,
       "Content-Type": "application/json",
     },
     body: JSON.stringify({
       id: order.id, // the same id again is ignored, so retries are safe
       amount: order.total, // 49.90, not cents
       currency: order.currency,
       distinct_id: order.visitorId,
       session_id: order.sessionId,
       customer_id: order.customerId,
     }),
   });
   
   // A refund: its own id, and the amount refunded this time.
   // { type: "refund", id: refund.id, amount: 10, currency: "EUR", customer_id: "cus_7" }
   ```

### Fields

- `id` (required): Your order, transaction or refund id. Sent twice, counted once.
- `amount` (required): In major units: 49.90, not 4990.
- `currency` (required): 3-letter code: USD, EUR, MAD…
- `type`: "payment" (default) or "refund".
- `distinct_id`: Who paid: anyanalytics.distinctId from the browser, or your identify() id.
- `session_id`: anyanalytics.sessionId at checkout: credits the visit's source.
- `customer_id`: Your customer id. Linked once with a distinct_id, renewals and refunds follow.
- `timestamp`: ISO 8601 or epoch ms; now by default. Up to 60 days back.
- `provider`: Where it came from: "paddle", "paypal"… ("custom" by default).
- `properties`: Anything else to filter by later: plan, product, coupon…

### Answers

- `200`: recorded, or already recorded (the same `id` is only counted once, so retrying is always safe).
- `400`: a field is wrong; `errors` lists what to fix.
- `401`: missing or wrong secret. `402`: the plan doesn't include revenue.
- `503`: busy, try again in a moment.

Like Stripe customers, a `customer_id` sent once together with a `distinct_id` is remembered, so later renewals and refunds for that customer are credited to the same visitor without one. The settings page shows the last payment received and the last request refused, with the reason.

## See also

- [Events and people](https://anyanalytics.org/docs/events.md) for how visitors become people when they sign in.
- [Game events](https://anyanalytics.org/docs/game-events.md#more) for in-app purchase and economy events.

---

Web version: https://anyanalytics.org/docs/revenue. All docs: https://anyanalytics.org/docs.md

# Ad blockers and proxies

> Serve the tag and endpoint from your own domain so ad blockers don't drop events.

Many ad blockers and privacy extensions block requests to analytics services, which can hide a good share of your website visits. The fix is to serve the script and send events through your own domain. To the browser they're then part of your site, and blockers leave them alone.

This mostly matters for websites and web apps. Events sent from servers, and from games and apps outside a browser, don't go through browser extensions.

## How it works

You forward two paths on your domain to AnyAnalytics:

- `/aa/script.js` to `https://api.anyanalytics.org/script.js`
- `/aa/batch` to `https://api.anyanalytics.org/v1/batch`

Then you load the script from the first path and point it at the second. The examples use `/aa`, but any path works. Pick one without words like "analytics" or "track", which blockers look for.

## Set it up

1. **Forward the two paths**

   Pick your hosting below and add the config to your site.

2. **Load the script from your domain**

   Replace your tag with this one. `data-api` sends events to your own path.

`index.html`:

```html
<script defer src="/aa/script.js" data-api="/aa/batch" data-key="vxw_YOUR_WRITE_KEY"></script>
```

### Next.js

`next.config.js`:

```ts
module.exports = {
  async rewrites() {
    return [
      { source: "/aa/script.js", destination: "https://api.anyanalytics.org/script.js" },
      { source: "/aa/batch", destination: "https://api.anyanalytics.org/v1/batch" },
    ];
  },
};
```

### Vercel

`vercel.json`:

```json
{
  "rewrites": [
    { "source": "/aa/script.js", "destination": "https://api.anyanalytics.org/script.js" },
    { "source": "/aa/batch", "destination": "https://api.anyanalytics.org/v1/batch" }
  ]
}
```

### Netlify

`netlify.toml`:

```toml
[[redirects]]
  from = "/aa/script.js"
  to = "https://api.anyanalytics.org/script.js"
  status = 200
  force = true

[[redirects]]
  from = "/aa/batch"
  to = "https://api.anyanalytics.org/v1/batch"
  status = 200
  force = true
```

### Cloudflare Worker

`worker.js`:

```ts
// Route this worker to example.com/aa/*
const API = "https://api.anyanalytics.org";

export default {
  async fetch(request) {
    const url = new URL(request.url);
    if (url.pathname === "/aa/script.js") {
      return fetch(API + "/script.js", { cf: { cacheTtl: 3600 } });
    }
    if (url.pathname === "/aa/batch") {
      const upstream = new Request(API + "/v1/batch", request);
      upstream.headers.delete("cookie");
      upstream.headers.set("X-Forwarded-For", request.headers.get("CF-Connecting-IP") ?? "");
      return fetch(upstream);
    }
    return fetch(request);
  },
};
```

### nginx

`nginx.conf`:

```nginx
# Inside your site's server { ... } block
location = /aa/script.js {
    proxy_pass https://api.anyanalytics.org/script.js;
    proxy_set_header Host api.anyanalytics.org;
    proxy_ssl_server_name on;
}

location = /aa/batch {
    proxy_pass https://api.anyanalytics.org/v1/batch;
    proxy_set_header Host api.anyanalytics.org;
    proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    proxy_set_header Cookie "";
    proxy_ssl_server_name on;
}
```

### With the JavaScript SDK

If you installed the SDK from npm, the script is already part of your bundle. Only forward the batch path, and set `endpoint` instead of `host`:

`analytics.js`:

```ts
analytics.init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  endpoint: "/aa/batch", // a path on your own domain
  capturePageviews: true,
});
```

## Check that it works

Open your site with an ad blocker on and look at the browser's network tab. Requests should go to `/aa/script.js` and `/aa/batch` on your own domain and succeed. Your visit should show up in **Live events** within a few seconds.

## Locations and IP addresses

Behind a proxy, the request reaches us from your proxy's address instead of the visitor's. Locations and [IP exclusion](https://anyanalytics.org/docs/privacy.md#exclude-your-traffic) need the visitor's real IP address.

- The **Cloudflare Worker** and **nginx** configs pass it on in the `X-Forwarded-For` header. We only accept that header from proxy addresses we trust.
- **Next.js, Vercel and Netlify** rewrites run on serverless platforms with no fixed addresses, so the visitor's IP can't be passed on reliably. Visits sent this way are counted without a location, and IP exclusion can't apply to them.

When we can't tell where a visitor is, the visit is counted with no location rather than your proxy's location. Everything else (pageviews, sources, devices, events) works the same.

> **Note**
>
> Because the batch path is on your own domain, the browser sends your site's cookies to it. The Cloudflare Worker and nginx configs remove them before forwarding.

---

Web version: https://anyanalytics.org/docs/ad-blockers. All docs: https://anyanalytics.org/docs.md

# AI agents and crawlers

> See which AI assistants and crawlers read your site, and what they fetched.

AI assistants read the web for the people who use them. When someone asks ChatGPT, Claude or Perplexity a question, the assistant may fetch your pages on the spot; search indexes and training crawlers read them ahead of time. The **AI agents** page shows which agents visit, how often, what they fetched, and how many people then clicked through from an assistant.

## Why your server reports them

Agents fetch pages without running JavaScript, so the browser script never sees them. Your server (or the edge in front of it) does. Add a few lines that report each request to `/v1/agents`. `trackAgentVisit` from `@anyanalytics/analytics` checks the User-Agent first and only sends requests from AI agents, so people's visits never leave your server. IP addresses are neither sent nor stored, and query strings are dropped.

> **Note**
>
> Crawlers that do render pages, like Googlebot and Bingbot, load the script and are picked up without any setup. Their pageviews still never count as visitors.

## Set it up

### Next.js

`middleware.ts`:

```ts
import { trackAgentVisit } from "@anyanalytics/analytics";
import { type NextFetchEvent, type NextRequest, NextResponse } from "next/server";

export function middleware(request: NextRequest, event: NextFetchEvent) {
  // Only AI agents are sent; people's visits never leave your server.
  event.waitUntil(trackAgentVisit({ writeKey: "vxw_YOUR_WRITE_KEY", host: "https://api.anyanalytics.org" }, request));
  return NextResponse.next();
}

export const config = {
  matcher: ["/((?!_next/static|_next/image|favicon.ico).*)"],
};
```

### Node / Express

`server.js`:

```ts
import { trackAgentVisit } from "@anyanalytics/analytics";

const agents = { writeKey: "vxw_YOUR_WRITE_KEY", host: "https://api.anyanalytics.org" };

app.use((req, res, next) => {
  // After the response, so the status code is known.
  res.on("finish", () => {
    trackAgentVisit(agents, {
      url: `${req.protocol}://${req.get("host")}${req.originalUrl}`,
      userAgent: req.get("user-agent") ?? "",
      signatureAgent: req.get("signature-agent"),
      status: res.statusCode,
    });
  });
  next();
});
```

### Cloudflare Worker

`worker.js`:

```ts
import { trackAgentVisit } from "@anyanalytics/analytics";

// A Worker on your site's route, in front of your origin.
export default {
  async fetch(request, env, ctx) {
    const response = await fetch(request);
    ctx.waitUntil(trackAgentVisit({ writeKey: "vxw_YOUR_WRITE_KEY", host: "https://api.anyanalytics.org" }, request, response.status));
    return response;
  },
};
```

### Any server

`POST /v1/agents`:

```sh
# Send requests from AI agents (or all of them: others are ignored),
# up to 100 per call, e.g. from your access logs.
curl -X POST https://api.anyanalytics.org/v1/agents \
  -H "Authorization: Bearer vxw_YOUR_WRITE_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "hits": [{
      "url": "https://example.com/pricing",
      "user_agent": "Mozilla/5.0 ... ChatGPT-User/1.0; +https://openai.com/bot",
      "status": 200,
      "timestamp": "2026-10-08T12:00:00Z"
    }]
  }'
```

Each request accepts up to 100 `hits` with `url`, `user_agent` and optional `status`, `timestamp` (up to a month back, so you can import access logs) and `signature_agent` (the `Signature-Agent` header that browsing agents like ChatGPT agent send). Hits from anything other than a known AI agent are ignored.

## What the tabs mean

- **AI answers**: fetched live because someone asked an assistant.
- **Indexing**: building the search index assistants answer from.
- **Training**: collecting pages to train models.

## Agents we recognize

| User-Agent token | Product | Operator | Purpose |
| --- | --- | --- | --- |
| `ChatGPT-User` | ChatGPT | OpenAI | AI answers |
| `Claude-User` | Claude | Anthropic | AI answers |
| `Claude-Web` | Claude | Anthropic | AI answers |
| `Perplexity-User` | Perplexity | Perplexity | AI answers |
| `Gemini-Deep-Research` | Gemini | Google | AI answers |
| `Google-NotebookLM` | NotebookLM | Google | AI answers |
| `DuckAssistBot` | DuckDuckGo | DuckDuckGo | AI answers |
| `MistralAI-User` | Mistral | Mistral AI | AI answers |
| `meta-externalfetcher` | Meta AI | Meta | AI answers |
| `Amzn-User` | Alexa | Amazon | AI answers |
| `cohere-ai` | Cohere | Cohere | AI answers |
| `OAI-SearchBot` | ChatGPT | OpenAI | Indexing |
| `Claude-SearchBot` | Claude | Anthropic | Indexing |
| `PerplexityBot` | Perplexity | Perplexity | Indexing |
| `Google-CloudVertexBot` | Vertex AI | Google | Indexing |
| `Googlebot` | Google | Google | Indexing |
| `bingbot` | Bing | Microsoft | Indexing |
| `Applebot` | Apple | Apple | Indexing |
| `DuckDuckBot` | DuckDuckGo | DuckDuckGo | Indexing |
| `Amzn-SearchBot` | Amazon | Amazon | Indexing |
| `Amazonbot` | Amazon | Amazon | Indexing |
| `YouBot` | You.com | You.com | Indexing |
| `PetalBot` | Petal | Huawei | Indexing |
| `GPTBot` | OpenAI | OpenAI | Training |
| `ClaudeBot` | Anthropic | Anthropic | Training |
| `anthropic-ai` | Anthropic | Anthropic | Training |
| `GoogleOther` | Google | Google | Training |
| `meta-externalagent` | Meta | Meta | Training |
| `FacebookBot` | Meta | Meta | Training |
| `Bytespider` | ByteDance | ByteDance | Training |
| `CCBot` | Common Crawl | Common Crawl | Training |
| `cohere-training-data-crawler` | Cohere | Cohere | Training |
| `Applebot-Extended` | Apple | Apple | Training |
| `AI2Bot` | Ai2 | Ai2 | Training |
| `Diffbot` | Diffbot | Diffbot | Training |
| `Timpibot` | Timpi | Timpi | Training |
| `omgili` | Webz.io | Webz.io | Training |
| `ImagesiftBot` | ImageSift | Hive | Training |
| `PanguBot` | Pangu | Huawei | Training |
| `Kangaroo Bot` | Kangaroo | Kangaroo LLM | Training |

## Visitors sent by AI

The same page lists people who arrived from an assistant (chatgpt.com, perplexity.ai, claude.ai, gemini.google.com and others), from the referrer or `utm_source` the browser script records. That part needs no server setup.

---

Web version: https://anyanalytics.org/docs/ai-agents. All docs: https://anyanalytics.org/docs.md

# Google Search Console

> Import Google Search Console: queries, pages, rankings and index status, next to what search visitors do on your site.

Connect Google Search Console and the **Google Search** page shows what people search on Google before they find your site: every query and page with its clicks, impressions, click-through rate (CTR) and average position, by country, device and search appearance, for the 16 months Google keeps. Next to each page you see what those visitors did on your site: visits, goal conversions and revenue, from your own tracking.

## Connect it

- In your site's **Settings → Google Search**, click **Connect with Google** and sign in with an account that can see the site in Search Console. Access is read-only: nothing changes in your Search Console.
- The property matching your site's domain is picked for you (domain properties like `sc-domain:example.com` first, since they cover every subdomain), and you land on the report. You only choose when none matches.
- The last week arrives within a minute or two, then the rest of the 16 months in the background. New days follow every few hours.

> **Note**
>
> Owners and admins connect and disconnect. Disconnecting deletes the imported data and revokes AnyAnalytics' access at Google.

## What's imported

| Data | Detail |
| --- | --- |
| Performance | Clicks, impressions, CTR and position per day, query, page, country, device and search appearance, for web, image, video, news, Discover and Google News. |
| Sitemaps | The sitemaps Google knows, when it last read them, and their errors. |
| Index status | Whether Google indexed your most visited and most shown pages (or why not), their canonical and last crawl. Checked weekly, within Google's daily limit. |

Google publishes each day about two days later and keeps revising the last few, so they're imported again every few hours. Days are Google's, in Pacific Time.

## Reading the numbers

- **Queries don't add up to the totals.** Google hides rare queries for privacy. Totals, pages, countries and devices are imported on their own, so they're complete.
- **Position** is the average of your top position in each search (1 is the first result); lower is better.
- Click a query to see the pages it shows, or a page to see its queries. Countries, devices and search appearance stay site-wide: Google doesn't export them per query.
- **Visits**, **Conv.** and **Revenue** count sessions that came from organic Google search and started on that page, from your site's script. Conversions use your goals.
- **Opportunities** lists queries and pages that rank on page one but get few clicks for their position (rewrite the title and description), ones just off page one (positions 11 to 20), and ones that lost ranking since the previous period.

## In the read API and MCP

The read API answers `POST /search` (the whole report) and `POST /search/breakdown` (one dimension, with filters and sorting). AI agents connected over MCP get `get_search_report`, `get_search_performance`, `get_search_opportunities`, `get_page_search_detail` and `get_search_console_status`, so you can ask things like "which queries bring people to /pricing, and do they convert?".

## Self-hosting

Google sign-in needs your own Google OAuth client, created once. In the admin console, open **Settings → Google Search**: it walks you through the Google Cloud steps with the exact redirect URI to paste, and checks the client ID and secret with Google before saving them (the secret encrypted). No restart needed. `GOOGLE_CLIENT_ID` and `GOOGLE_CLIENT_SECRET` environment variables work too; the console's client wins.

---

Web version: https://anyanalytics.org/docs/search-console. All docs: https://anyanalytics.org/docs.md

# Privacy and data

> Cookieless mode, excluding your own traffic, bots, and exporting or deleting a person's data.

AnyAnalytics is built to collect what you need for good numbers and little else. This page explains what's stored, the switches you have, and how to answer a person who asks for their data.

## What's stored on the device

By default, the script tag and the JavaScript SDK keep a small record in the browser's `localStorage`: a random anonymous id, the user id if you called `identify()`, the current session, and events waiting to be sent. The anonymous id and session (not the user id) are also kept in two first-party cookies on your domain, so a visitor stays the same person across your subdomains (see [Subdomains](https://anyanalytics.org/docs/websites.md#subdomains); `data-cookie-domain="false"` turns them off). The game and mobile SDKs keep the same things in the app's own storage.

## Cookieless mode

Cookieless mode stores nothing at all in the visitor's browser: no cookies, no `localStorage`. Add `data-cookieless` to the script tag:

`index.html`:

```html
<script defer src="https://api.anyanalytics.org/script.js" data-key="vxw_YOUR_WRITE_KEY" data-cookieless></script>
```

Or pass `cookieless: true` to the [JavaScript SDK](https://anyanalytics.org/docs/javascript.md):

`analytics.js`:

```ts
analytics.init({
  writeKey: "vxw_YOUR_WRITE_KEY",
  host: "https://api.anyanalytics.org",
  cookieless: true,
});
```

### How visitors are counted

- Each visitor gets an id made from a one-way hash of their IP address, their browser's user agent and your project, mixed with a random value that changes every day. The IP address itself isn't kept.
- The same browser keeps one id for the day. The next day it gets a new one, and the two can't be linked. Ids from different projects can't be linked either.
- Sessions still work: a new one starts after 30 minutes without activity.

### What you give up

- Returning visitors and retention only cover a single day, since a visitor can't be recognized across days.
- `identify()` only lasts for the current page. Call it again after each page load.
- Events that couldn't be sent (because the visitor went offline) are lost when the page reloads, since there's nowhere to keep them.

> **Note**
>
> Whether you need a consent banner depends on where you and your visitors are and on what else your site does. Check your local rules; this page isn't legal advice.

## Excluding your own traffic

Keep your own visits and your team's out of the numbers in two ways:

- **Per browser.** Open any page of your site with `?anyanalytics_ignore=true` once, and the script tag stops tracking that browser. Use `?anyanalytics_ignore=false` to undo it. You can also do it from **Settings → Traffic filters**. Repeat it in each browser you use.
- **By IP address.** In **Settings → Traffic filters**, list your office, home or test machines: one IP address or CIDR range (like `203.0.113.0/24`) per line, IPv4 or IPv6, up to 100 entries. Events from those addresses are dropped before they're stored. Changes apply within a minute.

IP exclusion needs the visitor's real address. If you send events through your own proxy, see [Locations and IP addresses](https://anyanalytics.org/docs/ad-blockers.md#locations).

## Bots and automated traffic

You don't need to set anything up for this. The script tag doesn't run in automated browsers, and when events arrive we drop those from search engine crawlers, link previews, uptime monitors, AI crawlers and known referrer-spam sites. Scripted HTTP clients are dropped when they claim to be a web browser. See [What gets filtered out](https://anyanalytics.org/docs/events.md#filtered).

## IP addresses

We use the IP address of each request to work out an approximate location (country, region and city). The address itself is never stored with your events. Events sent from server code get no location from the request, since a server's address says nothing about your user.

## What's kept from URLs

URLs often carry things that shouldn't end up in analytics: password-reset tokens, login codes, email addresses. Before a page URL or referrer is recorded, the SDK removes the fragment (`#…`) and every query parameter except `utm_source`, `utm_medium`, `utm_campaign`, `utm_term`, `utm_content` and `ref`. Ad click ids like `gclid` are recorded by name only, never their value.

The JavaScript SDK's `urlQueryParams` option lets you keep extra parameters you know are safe.

## Exporting or erasing a person's data

When someone asks for their data, find them in **Persons** (search by user id or a property such as email) and open their profile. Owners and admins see two buttons:

| Button | What it does |
| --- | --- |
| Export data (JSON) | Downloads everything recorded about the person: their profile, ids and every event with its properties and context. |
| Erase data | Permanently deletes every event, session, property and id of the person. Report totals drop accordingly. This can't be undone. |

Erasing removes what's already stored. If the person's device keeps sending events with the same id, they'll appear again, so stop tracking them in your app as well (for example by calling `reset()`).

## Data retention

Each project keeps its data until you say otherwise. To keep less, open **Settings → Data & privacy** and pick how long to keep events: 30, 90 or 180 days, or 1, 2 or 3 years. Older events are deleted every day, along with the sessions and visitor activity built from them, and reports can't look further back afterwards. Shortening the period deletes the older data within a day of saving.

## Your responsibilities

For the data you collect about your own visitors and players, you decide what's tracked and why, and we process it on your behalf. That usually means:

- Telling your users what you collect, in your own privacy policy.
- Asking for consent where your local rules require it.
- Not sending data you don't need, especially sensitive personal data, in properties.
- Answering access and deletion requests, with the tools above.

Rules differ by country, so check the ones that apply to you. Our [privacy policy](https://anyanalytics.org/privacy) and [terms](https://anyanalytics.org/terms) describe how we handle data on our side.

---

Web version: https://anyanalytics.org/docs/privacy. All docs: https://anyanalytics.org/docs.md

# The dashboard

> Overview, realtime, live events, persons and sessions: what each screen shows and how the numbers are counted.

Every project has the same set of screens in the sidebar, under **Analytics**: Overview, Dashboards, Realtime, Game (game projects only), Insights, Live events, Persons and Sessions. This page covers the ones that work out of the box. Insights and dashboards have [their own page](https://anyanalytics.org/docs/insights.md).

## Overview

The Overview is the project's home: a row of headline numbers, a chart, and breakdown cards below it. It refreshes on its own every minute, and the refresh button reloads it right away.

### Headline numbers

Websites say *Visitors* and *Pageviews*; games and apps say *Users* and *Screen views*. Each number shows its change against the previous period of the same length.

| Metric | What it counts |
| --- | --- |
| Visitors / Users | Unique people. Anonymous activity is merged into the identified person after identify. |
| Sessions | Visits. A session ends after 30 minutes of inactivity (SDK default). |
| Pageviews / Screen views | Page and screen view events. |
| Events | Every event received, of any name. |
| Bounce rate | Share of sessions with at most one view and under 10 seconds. Lower is better. |
| Session time | Average session duration. |
| Revenue | Sum of the `revenue` property, in the project's most common currency. Amounts in other currencies are shown apart, never added in. |
| Conversion rate | Share of people who paid. |
| Revenue/visitor (or /user) | Revenue divided by unique people. |
| Online | People with an event in the last 5 minutes. Opens Realtime. |

Revenue, conversion rate and revenue per visitor only appear once the project has revenue, and then they take the place of Sessions, Pageviews and Events in the row (those stay in the chart's tooltip). See [Revenue](https://anyanalytics.org/docs/revenue.md) for how to send it.

Click a counted number (visitors, sessions, views, events or revenue) to chart it. Ratios like bounce rate have no chart line; their change is shown in percentage points.

### Range, granularity and compare

- **Range**: today, last 24 hours, last 7 days (the default), 30 days, 90 days, 12 months, or a custom range of up to two years.
- **Granularity**: hourly, daily, weekly or monthly, limited to what fits the range (hourly for a day, weekly or monthly for a year).
- **Compare** adds the previous period to the chart as a dashed line.

Days start at midnight in the project's reporting timezone (set in **Settings → General**). The page address keeps the range, filters and chosen metric, so you can bookmark a view or send it to a teammate.

## Filters

Every row in a breakdown card is a filter. Click a country, a page or a source and the whole Overview (numbers, chart and every other card) narrows to it. Click it again to remove it. You can combine filters across cards; picking another value in the same dimension replaces the first one. Active filters sit above the chart, where you can remove them one by one or clear them all.

## Breakdown cards

| Card | Tabs |
| --- | --- |
| Pages (Screens for apps) | Page, entry page, exit link (outbound clicks, websites) |
| Sources | Channel, referrer, UTM source, campaign |
| Locations | Country, region, city |
| Devices | Browser, OS, device type, platform, app version |
| Top events | Every event name, with a shortcut to chart it in Insights |
| Goals | Your conversion goals, and a Journey tab |

Each card has a **Details** button for the full list and a **CSV** button that downloads the current tab. For apps and games, tabs that only browser traffic fills (referrers, UTM tags, browsers…) are hidden unless they have data.

### Channels

The Channel tab groups each session by where it started, using its first referrer, its `utm_source` and `utm_medium` tags, and any ad click id. Rules apply to all past data too.

| Channel | Roughly when |
| --- | --- |
| Direct | No referrer, no UTM tags and no ad click id |
| Paid Search | A paid medium (cpc, ppc, paid…) or search ad click id, from a search engine |
| Paid Social | A paid medium or social ad click id, from a social network |
| Paid Video | A paid medium from a video site |
| Paid Other | Any other paid medium or ad click id |
| Display | A display or banner medium, or a display ad click id |
| Affiliates | A medium containing "affiliate" |
| Email | A webmail referrer, or an email or newsletter source or medium |
| AI Assistants | ChatGPT, Perplexity, Claude, Gemini, Copilot and similar |
| Organic Search | A search engine, or the medium "organic" |
| Organic Social | A social network or community site, or a social medium |
| Organic Video | YouTube, Vimeo, Twitch and similar |
| SMS | The medium "sms" or "text" |
| Referral | Any other site linking to you |

### Goals

A goal counts a conversion: a custom event such as `signup_completed`, or a visit to a page such as `/thank-you` (use `*` as a wildcard, like `/blog/*`). The card shows, per goal, the people who completed it, the conversion rate (those people out of everyone in the range), the completions and the revenue they brought. Click a goal to filter the Overview to the people who completed it. Owners and admins add and delete goals.

When the project has revenue, the **Journey** tab lists the latest paying customers: where they first came from, their device, what they spent and how long they took to pay.

## Realtime

Realtime shows who's here right now on a 3D globe: one pin per person active in the last 5 minutes, with their country, current page or screen and device. Next to it:

- a visitors-per-minute bar for the last 30 minutes;
- the top referrers, countries and devices of the people online;
- a live activity feed of the last 30 minutes, including payments.

It updates every few seconds and can go full screen, for a TV in the office.

## Live events

Live events is the place to check an integration. The **Live stream** shows every event as it arrives with its status:

- **Accepted**: stored and counted.
- **Duplicate**: an event with the same id was already received (usually an SDK retry), so it isn't counted twice.
- **Rejected**: the event was invalid. Open it to see the reasons and the payload that was sent. Rejected events are never stored.

Pause, resume or clear the stream, and filter by event name, distinct id, platform, status or any text in the properties. Click an event to see everything it carried.

Switch to **Stored events** to search history instead: filter by exact event name or distinct id and scroll to load more.

> **Tip**
>
> Bots, crawlers and traffic from excluded IP addresses are dropped before this point, so they don't show up here. See [traffic filters](https://anyanalytics.org/docs/team.md#traffic-filters).

## Persons

Persons lists everyone the project has seen, merged across devices: anonymous activity joins the identified person after `identify()`. Search by id, email or any person property. A person's page shows first and last seen, their event count and platform, their properties, and their activity:

- **Player story** (games) or **Journey** (other projects): every session, newest first, told step by step. For games it includes their level map.
- **Events** and **Sessions**: the raw lists.

Owners and admins can export everything recorded about a person as JSON, or erase it for good. See [Privacy](https://anyanalytics.org/docs/privacy.md).

## Sessions

Sessions lists visits for the last 24 hours, 7, 30 or 90 days, filterable by platform and app version. The summary shows the number of sessions and people, median and average duration, and average events per session. Each row has the start time, duration, person, device, event count and entry and exit screens. Click a row to see that session's events.

## Game report

Game projects get a **Game** screen: players, sessions, median session length, level completion and crash-free sessions, per platform and version. It's built from your game's level and error events; see [Game events](https://anyanalytics.org/docs/game-events.md) for what to send.

---

Web version: https://anyanalytics.org/docs/dashboard. All docs: https://anyanalytics.org/docs.md

# Insights and dashboards

> Trends, funnels, retention and paths, saved to dashboards and shared through public links.

Insights answer the questions the Overview doesn't. Open **Insights** in the sidebar: **Saved** lists what your team has saved, and the four builders sit under it: Trends, Funnels, Retention and Paths. Results update as you change the query, and every builder can download its result as CSV.

## Trends

Trends chart any event over time. Add up to five series; each one is an event (or all events) with a metric:

| Metric | What it shows |
| --- | --- |
| Total count | How many times the event happened. |
| Unique users | How many different people did it. |
| Daily active users | People who did it that day. |
| Weekly active users | People who did it in the 7 days up to each day (rolling). |
| Monthly active users | People who did it in the 30 days up to each day (rolling). |
| Sum of property | The total of a numeric event property, such as `revenue` or `coins`. |
| Average of property | The average of a numeric event property. |

Around the series:

- **Breakdown** splits each series by one property: an event property, the event name, or a device field like platform, app version, OS, browser, device type or country.
- **Filters** keep only matching events (equals, contains, greater than, is set…).
- **Range and interval**: from the last 24 hours to the last 365 days, or a custom range, by hour, day, week or month. Daily, weekly and monthly active users are computed per day.
- **Compare** overlays the previous period of the same length.
- Show the result as a **line** or **bar** chart, or as a **table**.

## Funnels

A funnel shows where people drop off between steps. Add 2 to 8 steps; each is a custom event or a page (or screen) view, with `*` as a wildcard (`/blog/*`). Steps count in order: a person reaches step 3 only after steps 1 and 2.

- **Conversion window**: people must finish every step within 1, 3, 7 (the default), 14 or 30 days of the first one.
- For each step you get the people who reached it, the conversion from the start and from the previous step, how many dropped, and the **median time** it took from the previous step.
- View it as a flow or as bars, over the last 7, 30, 90 or 365 days or a custom range.

## Retention

Retention shows who comes back. Pick what puts someone in a cohort (any event, a specific event, or a page), and what counts as coming back. People are grouped by the day, week or month they did the first action, and each row shows how many of them came back in each following period. The bottom row averages all cohorts, weighted by their size.

| Counted | Who joins a cohort |
| --- | --- |
| First time | Each person joins once, in the period they first did it: your new users. |
| Recurring | Each person joins every period in which they did it. |

Choose how many cohorts to show, ending with the current period.

## Paths

Paths show the routes people take within a session. Start from the beginning of each session, from a page or screen, or from an event, and follow up to 8 steps. Choose what counts as a step: pages and events, only pages and screens, or only events. The same step repeated in a row counts once. The most common steps at each depth are shown, and the rest are grouped as "Other". Below the chart, the most common complete paths are listed.

## Saving insights

Click **Save insight** and give it a name and an optional description. Saved insights live in the project, so everyone on the team can open them from **Insights → Saved**. When you change a saved insight, it shows "unsaved changes" until you save or discard them.

Anyone on the team can save insights. Members can edit and delete the ones they created; owners and admins can edit and delete any. See [Roles](https://anyanalytics.org/docs/team.md#roles).

## Dashboards

Dashboards put saved insights side by side: one page per question your team keeps asking.

- Open **Dashboards** in the sidebar and create one with a name.
- Pin saved insights onto it (up to 40). Trends, funnels, retention and paths all work.
- Make a tile half or full width, move it earlier or later, or unpin it.
- Deleting a dashboard keeps the insights that were on it.

Every member can create dashboards and change their own. Owners and admins can change any dashboard.

## Sharing publicly

To show your numbers to people outside the team (investors, a public stats page), create a public link in the project's **Settings → Sharing & alerts**. Anyone with the link sees a live, read-only copy of the Overview: the headline numbers, chart and breakdowns, with ranges and filters. They never see individual people or customers, and they don't need an account.

- Give each link a name, so you know who it's for.
- Add an optional password (at least 6 characters). Viewers enter it once per browser session.
- Revoke a link at any time: it stops working right away.

> **Max plan**
>
> Public links are part of Max. Only owners and admins can create, see and revoke them. See [Plans and usage](https://anyanalytics.org/docs/plans.md) or the [pricing](https://anyanalytics.org/#pricing).

---

Web version: https://anyanalytics.org/docs/insights. All docs: https://anyanalytics.org/docs.md

# Alerts and reports

> A weekly email report, and traffic spike alerts by email, Slack or Discord.

Two kinds of notifications keep you up to date without opening the dashboard: a weekly email report, and an alert when traffic suddenly spikes. Both are set per project, in **Settings → Sharing & alerts**, under **Reports & alerts**.

> **Max plan**
>
> Reports and alerts are part of Max (and of the free trial). On Pro, none are sent. See [Plans and usage](https://anyanalytics.org/docs/plans.md) or the [pricing](https://anyanalytics.org/#pricing).

## Weekly report

Every Monday from 8:00 in the project's reporting timezone, subscribers get an email about the last full week, Monday to Sunday. It contains:

- Visitors (Users for apps and games), pageviews (screen views), sessions, bounce rate and average session time, each with its change against the week before.
- Revenue, when the project has any.
- The top 5 pages (or screens) and the top 5 sources.
- A link to the dashboard.

Each member subscribes for themselves: turn on **Weekly report** under **Email me**. It's off until you do. **Send me last week's report** sends a copy to you right away, whatever your subscription. Every report email has a link back to the settings to unsubscribe.

## Traffic spike alerts

A spike alert tells you when a project is suddenly much busier than usual: a launch, a post that took off, a streamer playing your game. The rule compares unique visitors in the last hour with the average hourly visitors of the 7 days before.

| Setting | What it does |
| --- | --- |
| Watch for spikes | Turns spike alerts on for the project. Off by default. |
| Multiplier | Alert when the last hour has 2×, 3× (the default), 5× or 10× the usual visitors per hour. |
| Minimum visitors | And at least this many visitors in the last hour (20 by default), so a jump from 1 to 4 isn't news. |
| Email subscribed members | Email the alert to members who turned on Traffic spike alerts for themselves. On by default. |
| Slack or Discord webhook | Also post the alert to a channel. |

Spikes are checked every 10 minutes, and at most one alert goes out every 6 hours, since a spike usually lasts a while. A project needs a week of data before it can spike. The alert says how many visitors came in the last hour, how that compares with usual, and links to Realtime so you can watch it happen.

Owners and admins set the rules. Any member can subscribe to the emails for themselves.

### Slack and Discord

Alerts can post to a Slack or Discord channel through an incoming webhook:

1. **Create the webhook**

   In Slack, add the Incoming Webhooks app and pick a channel. In Discord, open the channel's settings → Integrations → Webhooks.

2. **Paste its URL**

   Paste the URL into **Slack or Discord webhook** and save. It's stored encrypted; the settings only show which service it belongs to.

3. **Test it**

   **Send a test alert** posts a sample alert, marked as a test, to the saved webhook, and emails it to you when email alerts are on.

Only Slack (`hooks.slack.com`) and Discord (`discord.com/api/webhooks`) webhook URLs are accepted. Webhooks receive spike alerts; the weekly report goes by email only.

---

Web version: https://anyanalytics.org/docs/alerts. All docs: https://anyanalytics.org/docs.md

# Team and projects

> Organizations, roles, invitations, and managing projects, their settings and write keys.

Everything in AnyAnalytics belongs to an organization: its projects, its members and its plan. Your account gets its own organization automatically when you sign up, and you can also join other organizations by invitation. Switch between them, and between projects, with the switcher at the top of the sidebar.

## Roles

Every member can see all the data of all the organization's projects. Roles decide what they can change.

|  | Owner | Admin | Member |
| --- | --- | --- | --- |
| See every project's data | Yes | Yes | Yes |
| Save insights and create dashboards | Yes | Yes | Yes |
| Edit and delete their own insights and dashboards | Yes | Yes | Yes |
| Edit and delete anyone's insights and dashboards | Yes | Yes | No |
| Invite, remove and change the role of members | Yes | Yes | No |
| Create projects and change their settings | Yes | Yes | No |
| Add goals, set alert rules, create public links | Yes | Yes | No |
| Create and revoke write keys and API keys | Yes | Yes | No |
| Export or erase a person's data | Yes | Yes | No |
| Delete a project | Yes | No | No |
| Change the plan and payment details | Yes | No | No |

Usage warning emails go to owners, since they're the ones who can change the plan.

## Inviting members

Owners and admins invite people from **Members**, in the organization's sidebar: enter an email address and pick a role. The invitee gets an email with a link and signs in, or creates an account, with that address to accept.

- Invitations expire after 7 days.
- Pending invitations are listed with their expiry date. Copy the link from there to share it yourself, or cancel the invitation.
- Inviting the same address again replaces the earlier invitation.
- Removing a member takes away their access to the organization and all its projects.

> **Member limits**
>
> Pro includes 2 team members and Max 30. Pending invitations count toward the limit. See [Plans and usage](https://anyanalytics.org/docs/plans.md).

## Projects

A project is one website, game or app, with its own data, keys and settings. Add one from the organization's **Sites** page: pick what you're tracking and how you'll send events, then give it a name, an icon, a domain (for websites) and a reporting timezone. It comes with a first write key, and its **Setup** page shows the snippet to install.

Pro includes 2 projects and Max 30. A project's **Settings** has these sections:

| Section | What's there |
| --- | --- |
| General | Name, icon (an emoji or your logo), reporting timezone, type and domain. The timezone decides where days, weeks and months start in reports. |
| Traffic filters | Ways to keep your own and your team's visits out of the numbers (see below). |
| Revenue | Connect your Stripe account. See [Revenue](https://anyanalytics.org/docs/revenue.md). |
| Sharing & alerts | Public links and [reports and alerts](https://anyanalytics.org/docs/alerts.md). |
| Data & privacy | Data retention and cookieless tracking. See [Privacy](https://anyanalytics.org/docs/privacy.md). |

### Traffic filters

Crawlers, link previewers, headless browsers and referrer spam are dropped automatically. On top of that:

- **Exclude a browser**: open your site with `?anyanalytics_ignore=true` once, and the script tag ignores that browser from then on.
- **Excluded IP addresses**: one IP or CIDR range per line, IPv4 or IPv6, up to 100. Changes apply within a minute.

Filtered traffic is never stored and never counts toward your plan.

### Data retention

Choose how long to keep events: 30, 90 or 180 days, 1, 2 or 3 years, or forever. Older events are deleted every day, with the sessions and activity built from them. Your plan also caps history: 3 years on Pro, 5 years on Max.

### Deleting a project

Owners can delete a project at the bottom of **Settings → General**, by typing its name. It permanently deletes every event, person, session, insight and write key of the project. Anything still sending events with its keys is rejected.

## Write keys

A write key tells AnyAnalytics which project an event belongs to. Manage them in **Keys & API**. Use one per build or platform (say, "iOS production" and "Web") so you can rotate one without breaking the others.

- Owners and admins create keys and revoke them.
- A revoked key stops working for good: events sent with it are rejected.

> **Write keys are public**
>
> Write keys ship inside your website and your game builds, so anyone can find them. That's expected: they can only send events, never read data. To read your numbers from code, use a [read API key](https://anyanalytics.org/docs/read-api.md), which you keep secret.

---

Web version: https://anyanalytics.org/docs/team. All docs: https://anyanalytics.org/docs.md

# Plans and usage

> The free trial, the Pro and Max plans, the shared allowance, and what happens when you go over.

AnyAnalytics has a Free plan and two paid plans, Pro and Max, each offered at a range of traffic tiers. Every organization starts with a free trial of Max, then moves to Free unless it picks a paid plan. Prices for every tier are on the [pricing page](https://anyanalytics.org/#pricing).

## Free trial

- 14 days with everything in Max. The trial's traffic allowance is shown on the Plan & usage page.
- No card needed. Pick a plan any time during the trial.
- One trial per account.

When a trial ends without a plan, the organization moves to Free. Nothing is lost, though data older than Free's 3 months of history is deleted after a 30-day grace (owners get an email a week before the first deletion). Revenue from Stripe stops being recorded until you pick a paid plan.

## Free

Free is its own plan, not Pro for free: up to 2,000 players or 100,000 website events a month (one shared pool), with the limits below. It never expires.

Going over Free's allowance doesn't stop collection either. After 7 days over it in a month, reports lock until you pick a plan or the next month starts; events are still received and stored the whole time.

## Plans

|  | Free | Pro | Max |
| --- | --- | --- | --- |
| Projects | 1 | 2 | 30 |
| Team members | 1 | 2 | 30 |
| Data history | 3 months | 3 years | 5 years |
| Trends, funnels, retention, paths & game report | Included | Included | Included |
| Realtime globe & live events | Included | Included | Included |
| AI agent & crawler traffic | Included | Included | Included |
| MCP for coding agents | Included | Included | Included |
| Revenue from Stripe | – | Included | Included |
| Alerts & weekly email reports | – | – | Included |
| Public dashboard links | – | – | Included |
| Read API keys | – | – | Included |

Pending invitations count toward the member limit. Events older than your plan's history are deleted; see [Data retention](https://anyanalytics.org/docs/team.md#retention). The Max-only features are covered in [Alerts and reports](https://anyanalytics.org/docs/alerts.md), [Sharing publicly](https://anyanalytics.org/docs/insights.md#sharing) and the [Read API](https://anyanalytics.org/docs/read-api.md).

## The shared allowance

Each tier comes with two numbers, for example "10K players or 500K events":

- **Monthly active players** for games and apps: the people with at least one event in the billing period. A person is counted once per project, however many devices or sessions they used (their anonymous and identified ids are merged). Fair use is about 1,000 events per player a month: a game or app that sends more on average (say, an event every frame) counts one player per 1,000 events instead. The Plan & usage page marks the projects where that applies.
- **Website events** for websites: every event a site sends, pageviews and custom events alike.

These two numbers are one shared pool, not two separate allowances. An organization with only games gets all of it as players; one with only websites gets all of it as events. With both, each kind of traffic uses its share of the pool, and the rest is left for the other.

> **Example**
>
> On a tier of 10K players or 500K events, 4K players use 40% of the pool. The other 60% is left for websites: 300K events.

Never counted:

- bots, crawlers and referrer spam;
- traffic from excluded IP addresses and browsers;
- rejected and duplicate events.

## Going over

Going over your allowance never stops collection: every event is still received and stored. Instead:

- At 80% of the allowance, the dashboard shows a banner, and owners get an email with your pace for the rest of the period.
- Past 100%, the banner and a second email suggest a bigger tier.

Each warning is sent once per billing period.

## The Plan & usage page

Open **Plan & usage** in the organization's sidebar to see:

- your current plan, tier and price, and when it renews (or when the trial ends);
- usage this period for each meter, what's left of the pool, and your pace to the end;
- a day-by-day chart of usage against the plan limit;
- usage by project;
- projects and members used against the plan's limits.

Every member can see it. Only owners can change the plan.

## Billing

### Monthly or yearly

Pay monthly, or yearly and pay for 10 months instead of 12.

### Changing plans

Switch plan, tier or billing interval from the Plan & usage page. Changes are prorated: you're invoiced the difference for the rest of the period right away, and the new plan applies once that's paid. Your payment method and invoices are under **Payment method & invoices**.

### Cancelling

Cancel from **Payment method & invoices**. Your plan stays active until the end of the period you paid for; after that the dashboard asks for a plan again, while events keep being collected.

---

Web version: https://anyanalytics.org/docs/plans. All docs: https://anyanalytics.org/docs.md

# Read API

> Query your numbers from scripts and tools with read-only API keys.

The read API gives scripts, spreadsheets, notebooks and internal tools read-only access to a project's numbers: the same overview and insights you see in the dashboard. JSON in, JSON out.

> **Max plan**
>
> Read API keys are part of Max. See [Plans and usage](https://anyanalytics.org/docs/plans.md) or the [pricing](https://anyanalytics.org/#pricing).

## API keys

Owners and admins create keys in the project's **Keys & API** page, under **API keys (read-only)**. Give each key a name that says what uses it.

- Keys start with `vxr_`.
- A key reads one project only, and can't change anything.
- A key is shown once, right after you create it. Copy it then: it can't be shown again. The list only shows its first characters, who created it and when it was last used.
- You can revoke a key at any time. Requests with a revoked key get a 401.

> **Heads up**
>
> Unlike write keys, API keys read your data. Keep them secret: don't put them in a website or a game build.

## Making requests

Send the key as a bearer token. Your project id is in the dashboard's address bar, right after `/projects/`.

`terminal`:

```sh
curl -H "Authorization: Bearer vxr_YOUR_API_KEY" \
  "https://api.anyanalytics.org/api/v1/projects/<projectId>/overview?range=30d"
```

### Endpoints

All paths are relative to `/api/v1/projects/:projectId`.

| Request | Returns |
| --- | --- |
| `GET /` | The project: id, name, kind, timezone and domain. |
| `GET /overview` | The project overview, with the query in the URL. |
| `POST /overview` | The same, with the query as a JSON body. |
| `POST /query` | Runs any trends, funnel, retention or paths query. |
| `POST /search` | The [Google Search](https://anyanalytics.org/docs/search-console.md) report, when Search Console is connected; `POST /search/breakdown` returns one dimension's rows. |
| `GET /insights` | Saved insights: id, name, description, query and when it was last updated. |
| `GET /insights/:insightId` | A saved insight and its current result, as `{ insight, result }`. |

## Overview

The overview takes these parameters, in the query string or the JSON body:

| Parameter | Values |
| --- | --- |
| `range` | `today`, `24h`, `7d` (default), `30d`, `90d`, `12m` or `custom` |
| `from`, `to` | With `custom`: the first and last day, inclusive, as `YYYY-MM-DD`, up to two years apart |
| `interval` | `hour`, `day`, `week` or `month`. Defaults to the range's natural one. |
| `compare` | `true` adds the previous period's series |
| `filters` | A JSON array like `[{"dim":"country","value":"FR"}]`. A dimension is any breakdown (`page`, `channel`, `referrer`, `country`, `browser`, `platform`, `event`…) or `goal` with a goal id. Up to 10. |

`terminal`:

```sh
curl -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"range":"custom","from":"2026-09-01","to":"2026-09-30","filters":[{"dim":"channel","value":"Organic Search"}]}' \
  "https://api.anyanalytics.org/api/v1/projects/<projectId>/overview"
```

The response has the headline `totals` and the `previous` period's, the chart `series` and their `buckets`, the `breakdowns` for every dimension, your `goals` and the revenue `currency`. Times are wall-clock in the project's `timezone`. The numbers are defined in [The dashboard](https://anyanalytics.org/docs/dashboard.md#overview).

## Insight queries

`POST /query` takes `{ "query": { ... } }` with the same query a saved insight holds. The easiest way to write one is to build it in the dashboard, save it, and read its `query` from `GET /insights`. A query without a `kind` is a trend; funnels, retention and paths set `"kind"` to `"funnel"`, `"retention"` or `"paths"`.

`trend.sh`:

```sh
curl -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"query":{"series":[{"event":"$pageview","metric":"unique_users"}],"dateRange":{"from":"-30d"},"interval":"day"}}' \
  "https://api.anyanalytics.org/api/v1/projects/<projectId>/query"
```

`funnel.sh`:

```sh
curl -H "Authorization: Bearer $KEY" -H "Content-Type: application/json" \
  -d '{"query":{"kind":"funnel","steps":[{"kind":"page","value":"/pricing"},{"value":"signup_completed"}],"dateRange":{"from":"-30d"}}}' \
  "https://api.anyanalytics.org/api/v1/projects/<projectId>/query"
```

Date ranges are relative (`"-24h"`, `"-30d"`) or a `YYYY-MM-DD` day, read in the project's timezone, and can cover up to two years.

## Limits and caching

- Each key can make 300 requests per minute.
- Each key can have 8 requests in flight at once.
- Results are cached for a minute, so asking more often than that returns the same numbers.
- Browsers can call the API directly (CORS is open), since the key travels in a header.

## Errors

Errors come back as JSON with an `error` field. When a response has a `Retry-After` header, wait that many seconds before trying again.

| Status | error | Meaning |
| --- | --- | --- |
| 400 | `validation_failed` | The query is invalid; `details` says why. |
| 400 | `invalid_range` | The range is too long, or too many buckets for the interval. |
| 401 | `invalid_api_key` | The key is missing, unknown or revoked. |
| 402 | `plan_required` or a message | The organization's plan doesn't include the read API, or its trial has ended. |
| 404 | `Project not found` | The key belongs to another project. Unknown insights also answer 404. |
| 429 | `rate_limited` | Over 300 requests in a minute. |
| 429 / 503 | `too_many_concurrent_queries` | Too many queries running at once. Retry shortly. |

---

Web version: https://anyanalytics.org/docs/read-api. All docs: https://anyanalytics.org/docs.md

# MCP for AI agents

> Let coding agents read your reports and build funnels, insights and dashboards over MCP.

Connect AI coding agents (Claude Code, Codex, Cursor, VS Code, Claude.ai and any other client that speaks the Model Context Protocol) to your analytics. Ask them about your numbers, or have them build what you need: a signup funnel, a retention insight, a dashboard for the launch, conversion goals.

> **On every plan**
>
> MCP is included in every plan. Agents can read reports and build insights, but they can never send, change or delete tracked events: your data only comes from your own SDKs.

## Connect an agent

The MCP server lives at `https://api.anyanalytics.org/mcp`. Agents sign in with OAuth: the first time, a browser tab opens where you choose what the agent may do.

### Claude Code

`terminal`:

```sh
claude mcp add --transport http anyanalytics https://api.anyanalytics.org/mcp
```

Then run `/mcp` in Claude Code, pick **anyanalytics** and **Authenticate**.

### Codex

`terminal`:

```sh
codex mcp add anyanalytics --url https://api.anyanalytics.org/mcp
codex mcp login anyanalytics
```

### Cursor

`.cursor/mcp.json`:

```json
{
  "mcpServers": {
    "anyanalytics": {
      "url": "https://api.anyanalytics.org/mcp"
    }
  }
}
```

Cursor shows **Needs login** next to the server: click it to authorize.

### VS Code

`.vscode/mcp.json`:

```json
{
  "servers": {
    "anyanalytics": {
      "type": "http",
      "url": "https://api.anyanalytics.org/mcp"
    }
  }
}
```

### Claude.ai and Claude Desktop

Open **Settings → Connectors → Add custom connector**, paste `https://api.anyanalytics.org/mcp` and click **Connect**.

## Choosing what an agent may do

When an agent connects, you pick:

1. **The organization**

   Its sites are the only ones the agent can see.
2. **An access level**

   | Level | The agent can |
   | --- | --- |
   | **Read only** | Read reports, saved insights, dashboards and the list of events your sites receive. |
   | **Can build** | Also create and edit funnels, insights, dashboards and conversion goals. |
   | **Full control** | Also delete them. |

3. **Which sites**

   All sites (including ones you add later), or a list of sites.

An agent acts as you and never gets more than your own role allows. As a member (not an owner or admin), your agents can change only what you created and can't manage goals. If you leave the organization, your agents lose access too. See [Team and roles](https://anyanalytics.org/docs/team.md).

## Managing connections

Every connection is listed under **MCP** in your organization's sidebar, with its access level, its sites, when it was last used and how many calls it made. From there you can:

- **Activity**: see every tool call (what, on which site, whether it worked), kept 90 days.
- **Edit access**: change the level or the sites. It applies from the agent's next request.
- **Disconnect**: its tokens stop working at once.

Owners and admins see everyone's connections. They can lower another member's access or disconnect it, but only the person who connected an agent can give it more.

Funnels, insights, dashboards and goals an agent creates show a badge with the agent's name, so you always know where they came from.

## What agents can do

| Tool | Access | What it does |
| --- | --- | --- |
| `list_projects` | Read | The sites the connection can see. |
| `describe_project` | Read | The events a site receives (with counts), their properties, goals, saved insights and dashboards. |
| `get_overview` | Read | The overview report for a range. |
| `get_search_report`, `get_search_performance` | Read | Google Search Console data: clicks, impressions, CTR and position by query, page, country, device or day, with what Google visitors did on the site. |
| `get_search_opportunities`, `get_page_search_detail`, `get_search_console_status` | Read | Queries and pages to improve, one page's queries and Google index status, sitemaps and the connection. |
| `run_query` | Read | Runs a trends, funnel, retention or paths query without saving it. |
| `top_users` | Read | Ranks the people behind an event, with how often, how many hours they were active and how regular the gaps between their events are. Spots scripted or botted behaviour and power users. |
| `get_user_activity`, `list_events` | Read | One identified user's summary and raw event timeline, and raw events by name, person, session or distinct id. |
| `list_insights`, `get_insight` | Read | Saved insights, and one insight with its current result. |
| `list_dashboards`, `get_dashboard` | Read | Dashboards and their tiles. |
| `create_funnel` | Build | Saves a funnel and returns its first result. |
| `save_insight`, `update_insight` | Build | Saves or edits any insight. |
| `create_dashboard`, `update_dashboard`, `add_dashboard_tile` | Build | Builds dashboards from saved insights. |
| `create_goal` | Build | Adds a conversion goal (owners and admins). |
| `delete_insight`, `delete_dashboard`, `remove_dashboard_tile`, `delete_goal` | Full | Deletes them. Clients ask you before running these. |

Every answer includes links to open the result in the dashboard. Queries use the same shapes as the [Read API](https://anyanalytics.org/docs/read-api.md).

### Only real events

Before an agent saves a funnel, an insight or an event goal, the events it uses are checked against what your site received in the last 90 days. A typo or an invented event is refused with suggestions, like `No events named "sign_up" (did you mean "signed_up"?)`. For tracking you are shipping right now, the agent can say so explicitly (`allowUnseenEvents`).

## Access tokens

For clients without OAuth sign-in (CI jobs, scripts), create a personal access token under **MCP → Connect another client → Access token**. It starts with `vxm_`, is shown once, acts as you with the access you chose, and works until you disconnect it.

`terminal`:

```sh
claude mcp add --transport http anyanalytics https://api.anyanalytics.org/mcp \
  --header "Authorization: Bearer vxm_YOUR_TOKEN"
```

> **Heads up**
>
> Tokens read your data: keep them secret, like a password.

## Limits

- 300 requests per minute and 8 at once per connection.
- Report results are cached for a minute, like in the dashboard.
- If your free trial has ended, report tools answer with an error until you choose a plan.

Looking for the report on AI crawlers visiting your site instead? That's [AI agents](https://anyanalytics.org/docs/ai-agents.md).

---

Web version: https://anyanalytics.org/docs/mcp. All docs: https://anyanalytics.org/docs.md
