Two halves: `analytics.emit` sends your own product events, `analytics.series`
reads back aggregated numbers for your application. Neither is billing — see
[Metering](#metering-is-not-analytics) at the bottom.

## Emit

```ts
await connect.analytics.emit([
  { name: "campaign.sent", workspaceId, props: { recipients: 1_240, template: "welcome" } },
]);
```

Batched, because a request per event on a hot path costs more than the events
are worth:

```ts
const events = campaigns.map((campaign) => ({
  name: "campaign.sent",
  workspaceId,
  props: { recipients: campaign.recipientCount },
}));

await connect.analytics.emit(events);
```

<Callout title="Fire-and-forget, always">
  Emitting must never fail the thing it measures. `void connect.analytics.emit(…)`
  in a use-case, or await it inside a `try` that swallows — never on the path
  that decides whether the user's action succeeded.
</Callout>

### Naming

`noun.verb`, past tense, lowercase. `campaign.sent`, not `sentCampaign` or
`SEND_CAMPAIGN`. It reads correctly in a funnel — `campaign.created` →
`campaign.sent` → `campaign.opened` — and sorts usefully in a list.

Keep `props` **low-cardinality**. A template name is a dimension you can group
by; a campaign id is a value you already have in your own store, and putting
it here turns one series into millions.

| Good | Bad | Why |
|---|---|---|
| `template: "welcome"` | `campaignId: "c_8f2a…"` | Unbounded cardinality |
| `plan: "growth"` | `email: "a@b.com"` | Never put PII in props |
| `channel: "sms"` | `body: "Hi there…"` | Content is not a dimension |

## What Connect already counts

You do not emit these, and emitting your own version means maintaining a second
number that will disagree:

| Metric | Counted when |
|---|---|
| `api.requests` | Any authenticated `/api/v1/application/*` call |
| `usage.events` | Every event you emit |
| Subscription state | Activation, upgrade, downgrade, cancellation |
| Revenue | Every settled charge |

The API count comes from the one choke point every partner call passes through,
which is why it is attributable per application without any endpoint being
instrumented.

## Read back

```ts
const series = await connect.analytics.series({
  metrics: ["usage.events", "api.requests"],
  from: "2026-08-01",
  to: "2026-08-31",
});
```

Days are UTC. `toUtcDayKey` is the same bucketing both lanes of the pipeline
use, so a chart you draw and a rollup Connect computed agree about where a day
starts — a local-time copy would disagree for a few hours daily.

Rendering it is ordinary:

```tsx
const { data } = useConnectResource("/analytics?metrics=usage.events&from=2026-08-01&to=2026-08-31");

<ConnectSection title="Usage">
  <LineChart data={data?.series ?? []} />
</ConnectSection>
```

## What to emit across the SDK

Every SDK surface has a moment worth recording. This is a complete set for a
typical integration — the point is less the specific names than that each one
is a moment where the *user* did something, not where your code did.

<Tabs items={["Identity", "Billing", "Store", "AI & Drive", "Workspaces"]}>

<Tab value="Identity">

```ts
// After exchangeCode succeeds, in your /callback route
void connect.analytics.emit([
  { name: "session.started", workspaceId: user.workspaceId, props: { firstSignIn } },
]);
```

`firstSignIn` — a boolean you derive from your own store — is what turns this
series into activation rather than traffic.

Do **not** emit sign-in failures. You do not see most of them (Connect refuses
them before your callback runs), so the series would be a biased sample that
looks like a real one.

</Tab>

<Tab value="Billing">

```ts
void connect.analytics.emit([
  { name: "plans.viewed", workspaceId, props: { source: "upgrade-prompt" } },
  { name: "checkout.started", workspaceId, props: { planId } },
]);
```

Emit `plans.viewed` and `checkout.started` yourself; **do not** emit
`subscription.activated` — Connect records that when the charge settles, and
your version would fire when checkout *opened*, which is a different and
smaller number.

`source` is what makes this actionable: it separates people who went looking
for pricing from people a gate pushed there.

</Tab>

<Tab value="Store">

```ts
void connect.analytics.emit([
  { name: "campaign.created", workspaceId, props: { template: input.template } },
]);
```

Emit on the domain event, not the storage call. `campaign.created` is a fact
about your product; `data.create` is an implementation detail, and one that
changes if you ever move where campaigns live.

A scan (`data.scan`) is worth recording too — it is a cross-tenant read, and
knowing which of your internal tools run them is useful when one gets slow.

</Tab>

<Tab value="AI & Drive">

```ts
void connect.analytics.emit([
  { name: "ai.asked", workspaceId, props: { surface: "sidebar" } },
  { name: "file.uploaded", workspaceId, props: { kind: "csv" } },
]);
```

For AI, record the *question being asked*, never its text. The prompt is the
user's content; `surface` tells you where the feature is being discovered,
which is the thing you can act on.

</Tab>

<Tab value="Workspaces">

```ts
void connect.analytics.emit([
  { name: "workspace.switched", workspaceId: next.workspaceId, props: { from: previousWorkspaceId } },
]);
```

Attribute to the workspace switched **to** — that is where the subsequent
activity lands, and a series split across both ends double-counts.

</Tab>

</Tabs>

## Metering is not analytics

The one distinction worth being strict about.

| | Analytics | Metering |
|---|---|---|
| Call | `analytics.emit` | `billing.consumeAddon` |
| Guarantee | Best-effort, sampled, approximate | Transactional, exact |
| Fails how | Silently, on purpose | Loudly — the caller must handle it |
| Answers | "Is anyone using this?" | "What do we charge for this?" |

Billing on analytics events undercharges, because they are allowed to be
dropped. Metering a product question costs a transactional write on a path that
did not need one.

```ts
// Billing: must succeed, and the caller handles a refusal.
const result = await connect.billing.consumeAddon({ workspaceId, addonId: "sms-credits", quantity: 1 });
if (!result.allowed) throw new QuotaExceededError();

// Analytics: cannot fail the send.
void connect.analytics.emit([{ name: "sms.sent", workspaceId }]);
```

Both, for the same action. They are answering different questions.