# tgram-analytics JS SDK

Lightweight, privacy-first analytics SDK for [tgram-analytics](https://github.com/tgram-analytics/server).

- **Zero dependencies** — only browser APIs
- **Under 4 KB gzipped** — the ESM build is 3,816 B gzip at 0.3.0; CI fails the build above 4,096 B
- **TypeScript-first** — full type definitions included, no `@types` package needed
- **Privacy-friendly** — no cookies, no fingerprinting, respects Do Not Track
- **SPA-ready** — auto-tracks route changes with React Router, Vue Router, Next.js, etc.

---

## Prerequisites

Before using this SDK you need:

1. A running tgram-analytics server. See the [server repo](https://github.com/tgram-analytics/server) for setup instructions.
2. A project API key. Create one by sending `/add myapp.com` to the Telegram bot. The bot replies with a key that starts with `proj_`.

Get a free `proj_` API key from [@MyTelegramAnalyticsBot](https://t.me/MyTelegramAnalyticsBot) on Telegram (1 project free), or [self-host the server](https://github.com/tgram-analytics/server) and create keys via your own bot.

---

## Installation

### npm / yarn / pnpm

```bash
npm install tgram-analytics
```

### `<script>` tag (no build step needed)

Host `dist/index.iife.js` from your server (or a CDN) and load it on your page. It exposes the global `TGA` object.

```html
<script src="https://your-server.com/sdk/tga.min.js"></script>
<script>
  TGA.init("proj_xxx", { serverUrl: "https://your-server.com" });
</script>
```

The global `TGA` **is** the singleton — the same object you get from
`import TGA from "tgram-analytics"` — so every method documented below
(`TGA.init`, `TGA.track`, `TGA.pageview`, …) is called directly on it. The
`TGAClient` class is also reachable as `TGA.TGAClient` if you need a second,
isolated client instance.

> **Loading it correctly:** load the file as a plain `<script>` (no
> `type="module"`), and make sure your `TGA.init(...)` call runs *after* the SDK
> script has executed. If `TGA` is undefined, the SDK script did not load —
> check the URL and your network tab.

---

## Quick start

### ESM / TypeScript

```ts
import TGA from "tgram-analytics";

// Step 1 — initialise once, at the top of your app.
TGA.init("proj_abc123", {
  serverUrl: "https://analytics.example.com",
});

// Step 2 — track custom events anywhere in your code.
TGA.track("purchase", { amount: 49, currency: "USD", plan: "pro" });
```

Pageviews are sent automatically on load and on every SPA route change — no extra code needed.

### CommonJS

```js
const { default: TGA } = require("tgram-analytics");

TGA.init("proj_abc123", { serverUrl: "https://analytics.example.com" });
TGA.track("signup");
```

---

## API reference

### `TGA.init(apiKey, options)`

Initialises the SDK. **Call this once**, before using any other method.

```ts
TGA.init("proj_abc123", {
  serverUrl: "https://analytics.example.com",
  autoPageview: true,
  respectDNT: true,
});
```

Throws an `Error` if `apiKey` is invalid or `serverUrl` is missing.

---

### `TGA.track(eventName, properties?)`

Tracks a custom named event.

```ts
TGA.track("purchase", { amount: 49, currency: "USD", plan: "pro" });
TGA.track("signup"); // properties are optional
```

| Parameter    | Type               | Description |
|--------------|--------------------|-------------|
| `eventName`  | `string`           | Event identifier, e.g. `"purchase"`, `"signup"`. |
| `properties` | `EventProperties?` | Optional key-value metadata. Values must be `string`, `number`, `boolean`, `null`, or an **array of those scalars**. See [Multi-value properties](#multi-value-properties). |

---

### `TGA.pageview(url?, referrer?)`

Tracks a pageview. You rarely need this — `autoPageview: true` (the default) handles it automatically.

```ts
// Use defaults (current URL and document.referrer).
TGA.pageview();

// Pass explicit values.
TGA.pageview("/checkout", "https://example.com/cart");
```

| Parameter  | Type      | Default |
|------------|-----------|---------|
| `url`      | `string?` | `window.location.pathname + search` |
| `referrer` | `string?` | `document.referrer` |

---

### `TGA.identify(properties)`

Attaches persistent properties to every subsequent `track()` call.

```ts
// After a user logs in:
TGA.identify({ plan: "pro", locale: "en-US" });

// Every subsequent track() call now includes plan and locale automatically.
TGA.track("purchase", { amount: 49 }); // => properties: { plan, locale, amount }
```

Per-event properties (passed directly to `track()`) override `identify()` properties when keys conflict.

---

### `TGA.opt("in" | "out")`

Manually opts the current user in or out of tracking.

```ts
// Disable all tracking (e.g. user declined consent banner).
TGA.opt("out");

// Re-enable tracking.
TGA.opt("in");
```

> **Note:** The opt-out state is not persisted across page loads. If you want persistence, store the preference yourself (e.g. in `localStorage`) and call `TGA.opt("out")` on every page load when needed.

---

### `TGA.flush()`

Force-sends all buffered events immediately. Only relevant when `batch` is enabled.

```ts
// Ensure no events are lost before a programmatic navigation.
await TGA.flush();
router.push("/thank-you");
```

Returns `Promise<void>`.

---

### `TGA.reset()`

Starts a new session. Clears the session ID from `sessionStorage` and generates a fresh one. Also clears all properties set via `identify()`.

```ts
function onLogout() {
  TGA.reset();
  // Future events are now attributed to a new anonymous session.
}
```

---

## Configuration options

All options are passed as the second argument to `TGA.init()`.

| Option         | Type                          | Default | Description |
|----------------|-------------------------------|---------|-------------|
| `serverUrl`    | `string`                      | —       | **Required.** Base URL of your tgram-analytics server. No trailing slash. |
| `autoPageview` | `boolean`                     | `true`  | Automatically track pageviews on load and SPA navigations. |
| `respectDNT`   | `boolean`                     | `true`  | Honour the browser's Do Not Track setting. |
| `batch`        | `boolean \| BatchOptions`     | `false` | Buffer events before sending. See [Batching](#batching). |
| `sessionId`    | `string`                      | —       | Override the auto-generated session ID. Rarely needed. |
| `collectContext` | `boolean`                   | `true`  | Automatically collect visitor context (OS, browser, language, screen, timezone, device type) and include it as `$`-prefixed properties on every event. |
| `heatmaps`     | `boolean`                     | `false` | Record tap positions and scroll depth for tap heatmaps. See [Tap heatmaps (opt-in)](#tap-heatmaps-opt-in). |
| `test`         | `boolean`                     | `false` | Mark every request as a test event. See [Test mode](#test-mode). |

### `BatchOptions`

| Option    | Type     | Default | Description |
|-----------|----------|---------|-------------|
| `maxSize` | `number` | `10`    | Send when the buffer reaches this many events. |
| `maxWait` | `number` | `5000`  | Send after this many milliseconds, even if `maxSize` is not reached. |

---

## SPA support

When `autoPageview` is `true` (the default), the SDK automatically sends a pageview after every distinct URL change — no configuration required. It works with:

- **React Router** — `pushState` / `popstate`
- **Vue Router** — `pushState` / `replaceState`
- **Next.js App Router** — `pushState`
- **Svelte Kit** — `pushState`
- **Any other framework** that uses the History API

Consecutive navigations to the **same URL** are deduplicated, so rapid `replaceState` calls (e.g. syncing query params) do not generate duplicate pageviews.

---

## Batching

Batching is useful when you track many events in a short time (e.g. scroll depth, click heatmaps). Instead of one request per event, the SDK buffers events and sends them together.

```ts
TGA.init("proj_xxx", {
  serverUrl: "https://analytics.example.com",
  batch: { maxSize: 20, maxWait: 3000 },
});
```

The queue flushes automatically when:
- The buffer reaches `maxSize` events.
- `maxWait` milliseconds have passed since the first event in the batch.
- The user navigates away from the page (`visibilitychange`, `pagehide`).
- You call `TGA.flush()` manually.

---

## Test mode

Set `test: true` to mark every request (track, pageview, taps) as a test event:

```ts
TGA.init("proj_abc123", {
  serverUrl: "https://analytics.example.com",
  test: true,
});
```

The server stores test events but leaves them out of analytics, reports, digests, funnels, exports and alerts. Recent activity in the bot, `/doctor`, and the MCP `recent_events` and `verify_integration` tools still show them, marked 🧪. Test taps are not stored.

You do not need this option for local development: the server marks events as test automatically when the page origin or URL is `localhost`, `*.localhost`, `127.0.0.0/8`, `::1` or `0.0.0.0`. Use it for other non-production builds, such as a staging deploy. For example, with Vite:

```ts
TGA.init("proj_abc123", {
  serverUrl: "https://analytics.example.com",
  test: import.meta.env.MODE === "staging",
});
```

When `test` is `false` (the default), the SDK does not send the field. Servers without test-event support ignore the field and store the events as normal events.

---

## Tap heatmaps (opt-in)

The SDK can record where visitors tap or click on each page, and how far they scroll. The server uses this data to show which elements get the most taps on a page. It is **off by default**. With it off, the SDK sends nothing more than before.

```ts
TGA.init("proj_abc123", {
  serverUrl: "https://analytics.example.com",
  heatmaps: true,
});
```

Needs a server version that has the `POST /api/v1/taps` endpoint.

### What is sent

The SDK sends **one request per pageview** to `POST /api/v1/taps`. It sends it when the visitor goes to another route, when the page is hidden, or when the page reaches 50 taps. Example body:

```json
{
  "api_key": "proj_abc123",
  "session_id": "550e8400-e29b-41d4-a716-446655440000",
  "path": "/browse?sort=new",
  "viewport": "mobile",
  "vw": 390,
  "taps": [{ "x": 0.512, "y": 1330, "el": "button \"Browse albums\"" }],
  "scroll": 0.62
}
```

| Field      | Content |
|------------|---------|
| `path`     | `location.pathname + location.search`, the same string as the pageview URL. The hash is not included. |
| `viewport` | `"mobile"` (`innerWidth` < 768), `"tablet"` (< 1024), or `"desktop"`. |
| `vw`       | `window.innerWidth` in CSS px. |
| `taps`     | 0 to 50 taps. At most 50 taps per pageview; later taps on the same page are dropped. |
| `taps[].x` | Horizontal position as a fraction of the document width, 0 to 1, 3 decimals. |
| `taps[].y` | Vertical position in CSS px from the top of the page. For a tap inside a `position: fixed` or `sticky` element (for example a sticky header), it is the position in the viewport. |
| `taps[].el` | Element label, at most 80 characters. See below. |
| `scroll`   | Maximum scroll depth, 0 to 1: the lowest viewport bottom reached (`scrollY + innerHeight`, in px) ÷ the page height when the request is sent. Sent once per pageview. Left out when the visitor did not scroll. |
| `session_id` | The server uses it for the request only. The server does not store it with the taps. |

A tap has no timestamp, no text that the visitor typed, and no other event properties.

### Element labels

The SDK finds the nearest link, button, form field, `label`, `summary`, `[role="button"]`, `[role="link"]`, or element with `data-tga-label`, `aria-label`, or `id` (the tapped element or one of its ancestors). If there is none, it uses the tapped element. The label is the first of:

1. its `data-tga-label` attribute,
2. its `aria-label` attribute,
3. `tag#id`, for example `div#hero`,
4. the tag name, for example `p` or `img`. For an `input` it is `input[type=…]`, for example `input[type=email]`.

Only for `a`, `button`, `label`, `summary`, `[role="button"]`, and `[role="link"]` does rule 4 add up to 40 characters of visible text, with whitespace collapsed, for example `button "Browse albums"`. Any other element gives its tag name only: a tap on `<p>Email: leo@example.com</p>` gives `p`. Visible text comes from text nodes only. Text inside `script`, `style`, `noscript`, `template`, `[hidden]`, and `[aria-hidden="true"]` is skipped.

Set `data-tga-label` to give an element a stable name:

```html
<button data-tga-label="hero-cta">Start free trial</button>
```

### What is never read

- The SDK never reads the **value or the text** of `input`, `textarea`, `select`, or contenteditable elements. For these, rule 4 gives the tag only: `input[type=email]`, `textarea`, `select`. Rules 1 to 3 still apply, because they read attributes that you write, not what the visitor types.
- The SDK does not read visible text when the tap is inside a contenteditable element, or when the labelled element contains a form field or a contenteditable element.
- Taps on an element with `data-tga-ignore`, or inside one, are **not recorded**. Put it on areas that show personal data:

```html
<section data-tga-ignore>
  <!-- account details: no taps recorded here -->
</section>
```

`TGA.opt("out")` stops tap recording and discards the pending taps and scroll depth of the current page at once; they are never sent, also after `TGA.opt("in")`. Do Not Track (with `respectDNT: true`) stops tap recording too.

---

## Multi-value properties

Properties accept **arrays of scalars** in addition to single scalars — useful for multi-select inputs, A/B variant memberships, or any set-style attribute that would otherwise be lossy to flatten into one column.

```ts
TGA.track("onboarding_completed", {
  role: "creator",
  frequency: "weekly",
  interest: ["vertical_to_horizontal", "unsure"], // <-- array, name it whatever you want
});
```

The server stores the array natively in the JSONB `properties` column, so a single event powers two complementary dashboards:

```sql
-- 1. Per-element counts (e.g. a pie chart of how often each interest is picked):
SELECT elem, count(*) AS n
FROM events,
     jsonb_array_elements_text(properties->'interest') AS elem
WHERE name = 'onboarding_completed'
GROUP BY elem
ORDER BY n DESC;

-- 2. Most common combinations of selected values:
SELECT properties->'interest' AS combo, count(*) AS n
FROM events
WHERE name = 'onboarding_completed'
GROUP BY combo
ORDER BY n DESC
LIMIT 20;
```

### Sort behaviour

The server **sorts every array property at write time** — no naming convention required. `["a", "b"]` and `["b", "a"]` land in the same JSONB value, so the "combo" query above is a trivial `GROUP BY` without read-time normalisation.

> **Order-sensitive use cases.** Because every array is sorted, a list like `recent_searches: ["pizza", "pasta"]` loses its original order on the wire. If insertion order matters, serialize to a string (`"pizza,pasta"`) or use an object with positional keys. Heterogeneous arrays (mixed types that can't be ordered, e.g. `[1, "a"]`) fall back to insertion order automatically.

### Allowed value shapes

| Shape                          | Allowed? | Example                               |
|--------------------------------|----------|---------------------------------------|
| Scalar                         | ✅       | `{ amount: 49 }`                      |
| Array of strings               | ✅       | `{ tags: ["a", "b"] }`                |
| Array of numbers / booleans    | ✅       | `{ scores: [1, 2, 3] }`               |
| Heterogeneous array of scalars | ✅       | `{ misc: ["a", 1, true] }`            |
| Nested object                  | ❌       | `{ user: { id: 1 } }`                 |
| Nested array                   | ❌       | `{ matrix: [[1, 2]] }`                |
| `undefined` / `NaN` / `Infinity` | ❌     | `{ x: undefined }`                    |

Bad shapes throw a clear error synchronously from `track()` / `identify()` so they surface in dev rather than silently corrupting your data.

---

## Privacy

- **No cookies.** Session IDs are stored in `sessionStorage` only, which is scoped to a single tab and cleared automatically when the tab closes.
- **No fingerprinting.** The SDK does not access canvas, WebGL, audio, or any other fingerprinting surface.
- **Do Not Track.** When `respectDNT: true` (the default), the SDK checks `navigator.doNotTrack` on init. If DNT is enabled, all tracking is silently disabled — no requests are sent.
- **Write-only API.** The server's ingestion endpoints are write-only. No user data can be read back via the API key.

---

## CORS and domain allowlist

The server enforces a **domain allowlist** per project. When the allowlist is not empty, requests from origins not on the list are rejected with `403 Forbidden`.

To add your domain:
1. Open the Telegram bot.
2. Send `/projects` and select your project.
3. Tap **⚙️ Settings** → **Domain allowlist** and add your domain.

If you are developing locally, either leave the allowlist empty (allows all origins) or add `http://localhost:3000` (or whichever port you use).

The server must also have CORS middleware enabled. If you deployed using the official server image, CORS is configured automatically.

---

## TypeScript

Full type definitions are included in the package — no separate `@types` package is needed.

```ts
import TGA from "tgram-analytics";
import type { TGAOptions, EventProperties } from "tgram-analytics";

const options: TGAOptions = {
  serverUrl: "https://analytics.example.com",
  autoPageview: true,
};

TGA.init("proj_xxx", options);

const props: EventProperties = { amount: 49, plan: "pro" };
TGA.track("purchase", props);
```

---

## Contributing

Contributions are welcome! Please open an issue or pull request on GitHub.

### Development setup

```bash
git clone https://github.com/tgram-analytics/tgram-analytics-js
cd tgram-analytics-js
npm install
```

### Available commands

| Command           | Description |
|-------------------|-------------|
| `npm run build`   | Build all output formats to `dist/` |
| `npm run size`    | Print gzip sizes of the bundles; fails when `dist/index.js` is above 4,096 B |
| `npm test`        | Run the test suite |
| `npm run typecheck` | Check TypeScript types (no emit) |
| `npm run check`   | Lint + format with Biome (auto-fixes) |
| `npm run lint`    | Lint only |
| `npm run format`  | Format only |

### Before submitting a PR

1. Run `npm run check` — fixes linting and formatting automatically.
2. Run `npm test` — all tests must pass.
3. Run `npm run typecheck` — zero TypeScript errors.
4. Run `npm run build` — the build must succeed.
5. Run `npm run size` — `dist/index.js` must stay at or below 4,096 B gzip.

---

## License

MIT — see [LICENSE](./LICENSE).

---

## Links

- Website: <https://tgram-analytics.com>
- Server (API): <https://github.com/tgram-analytics/server>
- JS SDK: <https://github.com/tgram-analytics/tgram-analytics-js>
- Python SDK: <https://github.com/tgram-analytics/tgram-analytics-py>
- Flutter SDK: <https://github.com/tgram-analytics/tgram-analytics-flutter>
