# Custom Events

Track conversions, button clicks, and custom application workflows using browser calls or backend requests.

## Browser event tracking

Record events in the browser using `window.sa_event`. To record events before `latest.js` finishes loading, declare the asynchronous buffer queue early in your document:

```html
<script>
  window.sa_event =
    window.sa_event ||
    function () {
      (window.sa_event.q = window.sa_event.q || []).push(Array.from(arguments));
    };
</script>
<script
  async
  src="https://analytics.bitgate.dev/latest.js"
  data-hostname="example.com"
></script>
```

Always use standard function syntax rather than an arrow function so that `arguments` is captured correctly.

### Triggering events from UI

Call `window.sa_event(name, metadata?, callback?)` inside event handlers:

```html
<button id="upgrade-btn">Upgrade Plan</button>

<script>
  document.getElementById("upgrade-btn").addEventListener("click", function () {
    if (typeof window.sa_event === "function") {
      window.sa_event("upgrade_clicked", { tier: "pro", period: "annual" });
    }
  });
</script>
```

### TypeScript definitions

If you use TypeScript, augment the global `Window` interface:

```typescript
declare global {
  interface Window {
    sa_event?: (
      name: string,
      metadata?: Record<string, string | number | boolean>,
      callback?: () => void,
    ) => void;
    sa_pageview?: (
      path?: string,
      metadata?: Record<string, string | number | boolean>,
    ) => void;
  }
}

export {};
```

## Naming conventions and normalization

Use short, descriptive event names in lowercase snake_case, such as `signup_completed` or `pricing_opened`.

Incoming event names undergo automated normalization:

- The collector truncates names exceeding 256 characters before normalization.
- Non-alphanumeric character sequences are converted to underscores.
- Leading and trailing underscores are stripped.

## Event metadata rules

Event metadata must be a plain JavaScript object:

- The collector stores at most 4,096 characters of serialized metadata JSON.
- Payloads exceeding this limit are truncated, which may result in non-parseable JSON.
- Send compact, non-sensitive strings, numbers, or booleans.

Totallytics aggregates event counts and unique visitor totals by event name. The API does not provide custom metadata filtering, funnel analysis, or custom goal query endpoints.

Query aggregated event breakdown data using the API:

```bash
curl --fail-with-body -sS --max-time 30 \
  'https://analytics.bitgate.dev/api/sites/demo.bitgate.dev/breakdown?dim=events&limit=10'
```

## Event callbacks

The optional callback runs on image load/error and can also run immediately after a local validation failure, without a request:

```javascript
window.sa_event("lead_form_submitted", { source: "nav" }, function () {
  console.log("Event dispatch finished");
});
```

The callback is not a success signal; it can run even when no request was sent. It does not guarantee persistence or delivery to permanent storage. Do not gate mission-critical logic on this callback.

## Automated event scripts

`latest.js` does not record link clicks, file downloads, or form submissions automatically. Companion scripts like `/auto.js` or `/auto-events.js` are not supported. If migrating from older scripts, attach explicit `window.sa_event` triggers.

## Backend server-side events

Send events directly from backend services via `POST /events` without loading client tracking scripts:

Use the real incoming visitor user-agent, not a fabricated browser string. This example runs in a backend with `fetch` support:

```typescript
async function recordSignup(hostname: string, visitorUserAgent: string) {
  if (!hostname || !visitorUserAgent) {
    throw new Error(
      "A registered hostname and the real visitor user-agent are required",
    );
  }

  const response = await fetch("https://analytics.bitgate.dev/events", {
    method: "POST",
    headers: { "Content-Type": "application/json" },
    signal: AbortSignal.timeout(10_000),
    body: JSON.stringify({
      hostname,
      type: "event",
      event: "signup_completed",
      path: "/signup",
      metadata: { plan: "starter" },
      ua: visitorUserAgent,
    }),
  });

  const result = await response.text();
  if (!response.ok) {
    throw new Error(`Event collector ${response.status}: ${result}`);
  }

  return result;
}
```

Server-side requirements and behaviors:

- Send a JSON-encoded object. Use `Content-Type: application/json` for clarity, although the handler parses JSON regardless of that header. Form-encoded bodies are not accepted.
- Payload must be a single JSON object. Batch requests are not supported.
- Always set `"type": "event"`. Omitting this field records the request as a standard pageview.
- Returns HTTP 200 with plain text `ok` on success, or HTTP 400 plain text on malformed input.
- Pass the visitor user-agent string via the `ua` field.
- Country and geolocation are computed from the incoming HTTP connection IP. Server-side dispatches reflect the geographic location of your server, not the end user. Client IP overrides are not accepted.
- Timestamps are assigned on server arrival. You cannot pass custom timestamps or backdate events.
- There is no accepted user-ID or client-IP override. Visitor hashes incorporate the UTC day, site, connection IP, user-agent, and a deployment salt, so a shared server does not recreate end-user visitor identity. Bot-marked rows are excluded from normal metrics; bot classification includes the effective user-agent and the `bot` payload field.
- There is no idempotency key. Do not automatically retry an event POST after an ambiguous timeout: a retry can record the event twice.

Review the [Collector Specification](/docs/collector) for low-level protocol details.
