Two halves: `analytics.emit` sends your own product events, and
`analytics.metrics` reads back the metrics **you declared** over them, per
workspace, in that workspace's time zone. (`analytics.series` still reads the
handful of numbers Connect counts for every application.) Neither is billing —
see [Metering](#metering-is-not-analytics) at the bottom.

## Declare your metrics

A metric that is not declared does not exist — the same rule as your store's
collections. Your application's **analytics schema** names each metric, the
event it reads and how it turns that event into a number, plus the dimensions
an event may be broken down by. It is edited in Console (Applications ›
Analytics) or written by your seed script next to `storeSchema`, never through
an API key.

```ts
analyticsSchema: {
  metrics: [
    { key: "deals.won",       label: "Deals won",     event: "deal.won",          agg: "count" },
    { key: "deals.won_value", label: "Won value",     event: "deal.won",          agg: "sum", unit: "paise" },
    { key: "deals.closers",   label: "Reps closing",  event: "deal.won",          agg: "distinct" },
    { key: "pipeline.open",   label: "Open pipeline", event: "pipeline.snapshot", agg: "gauge", unit: "paise" },
  ],
  dimensions: [
    { key: "ownerId", label: "Owner", maxCardinality: 500 },
    { key: "stage",   label: "Stage", maxCardinality: 50 },
  ],
}
```

| `agg` | The number | Across days |
|---|---|---|
| `count` | How many times the event happened | Added |
| `sum` | The sum of the events' `value` | Added |
| `distinct` | How many different users produced it | **Merged**, never added — one rep closing on three days is one rep. `null` for today. |
| `gauge` | The last `value` pushed — a level, like open pipeline | The latest one, never added |

Several metrics may read one event, and history is per *event*, not per
metric: declare a metric over an event you have been emitting for months and
its history is already there. `connect.analytics.schema()` returns the
declaration, so a dashboard can draw its picker from it.

## 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>

### Attribution and idempotency

Each event can carry three optional fields that say who it is about and whether
it has been seen before:

| Field | What it does |
|---|---|
| `sessionToken` | The signed-in user and their workspace. Wins over `workspaceId`; if both are sent they must name the same workspace, or the batch is refused (`422`). |
| `workspaceId` | The workspace alone, for a server-side emit with no user in hand. Accepted only where your application is installed (a live subscription). One workspace that is not refuses the **whole batch** (`403`, `delivered: false`). |
| `eventId` | Your idempotency key, e.g. `deal.won:<dealId>`: up to 64 letters, digits and `. _ : -`. A retry or a replay counts once. Unique within your application. |
| `subjectId` | Who the event is about when it is not a signed-in user - a caller, a requester - so a `distinct` metric counts them from a server-side emit. Your own pseudonymous key (an HMAC of a phone number, or a record id), never raw personal data. Same alphabet as `eventId`. |

With none of the three, the event still lands, just unattributed and not
de-duplicated. Send `eventId` on anything someone will read as a business
figure:

```ts
void connect.analytics.emit([
  { name: "deal.won", workspaceId, eventId: `deal.won:${deal.id}`, value: deal.amountPaise, unit: "paise" },
]);
```

### Dimensions

A dimension is what a series is broken down by — "won value **per owner**".
An event carries at most two, each one declared in your schema (an undeclared
key refuses the batch with a `422` naming it):

```ts
void connect.analytics.emit([
  {
    name: "deal.won",
    workspaceId,
    eventId: `deal.won:${deal.id}`,
    value: deal.amountPaise,
    unit: "paise",
    dimensions: { ownerId: deal.ownerId, stage: deal.stage },
  },
]);
```

Each dimension's `maxCardinality` is a ceiling per day: the busiest values are
kept and the rest fold into `"_other"`. An owner id fits; a deal id or free
text does not — that is what `props` and your own store are for.

### Emit with the write that caused it

When the number moves *because* a record changed, send the event with the
commit rather than after it. Connect publishes `events` only once the commit
has applied, attributed to its workspace — so a commit refused for a stale
precondition counts nothing, and a retried `transaction` counts once:

```ts
await connect.data.commit({
  workspaceId,
  operations: [{ op: "merge", collection: "deals", id: deal.id, data: { stage: "won" } }],
  events: [{ name: "deal.won", eventId: `deal.won:${deal.id}`, value: deal.amountPaise, dimensions: { ownerId: deal.ownerId } }],
});
```

This replaces keeping a counter document beside the record: you state what
happened, and Connect makes the number move. Up to 50 events per commit, the
same shape `emit` takes.

### 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 your metrics

```ts
const result = await connect.analytics.metrics({
  metrics: ["deals.won", "deals.won_value"],
  from: "2026-10-01",
  to: "2026-10-31",
  granularity: "week",
  workspaceId,
});
// result.timeZone === "Asia/Kolkata"
// result.series[0] = { metric: "deals.won", label, agg, unit, dimValue: null, points: [{ bucket, value }], total, partial }
```

| Parameter | |
|---|---|
| `metrics` | Declared metric keys. An undeclared one is a `422` naming it. |
| `from`, `to` | Inclusive **local days** in the workspace's time zone, `YYYY-MM-DD`. Up to 400 days. |
| `granularity` | `day` (default), `week` (buckets start Monday), `month` (the 1st) or `total`. |
| `groupBy` | A declared dimension: one series per value, busiest first. |
| `filter` | `{ dimKey, dimValue }` — one value of one declared dimension. |
| `workspaceId` | The workspace, verified against your subscriptions (`403` otherwise). **Omit it for the application-wide figure** across every workspace — that one stays on your server. |

**Days are the workspace's days.** A workspace in India has its "today" start
at midnight IST, and an event at 00:30 IST lands on that day, not on
yesterday's UTC date. `timeZone` in the answer says which zone the buckets are
in; use it with the period helpers below rather than your server's clock.

**`null` is not zero.** Today comes from live counters and moves through the
day; every earlier day comes from the nightly rollup, and a day it has not
reached yet is `null` — on a development deployment, where the rollup never
runs, that is every past day. A `distinct` metric is `null` for today. A day
the rollup *did* finish with nothing in it is a real `0`. `total` combines
only what was measured, by the metric's own rule, and `partial: true` says
something was skipped — show that caveat rather than a confident total.

### Periods

```ts
import { resolvePeriod, metricDelta, formatMetricValue } from "@cosmoops/connect-sdk";

const period = resolvePeriod("this_month", result.timeZone);
// { from: "2026-10-01", to: "2026-10-08", previousFrom: "2026-09-01", previousTo: "2026-09-08", ... }
```

`resolvePeriod(preset, timeZone)` covers `today`, `yesterday`,
`last_7_days` … `last_90_days`, `this_week`, `last_week`, `this_month`,
`last_month`, `this_quarter` and `this_year`, each with the period it compares
against: a rolling window against the one before it, a to-date period against
the same stretch of the last one. `parsePeriod({ period, from, to }, timeZone)`
reads a report URL (falling back rather than refusing on a bad link) and
`periodSearchParams` writes one back. `periodGranularity(days)` picks `day`,
`week` or `month` for a chart.

### Components

`@cosmoops/connect-sdk/ui` draws the answer. All three are presentational —
no hooks — so they render in a Server Component next to the read, and use
semantic tokens only, so they take your theme.

```tsx
import { ConnectMetricChart, ConnectStatStrip, ConnectStatTile, formatMetricValue, metricDelta, periodGranularity } from "@cosmoops/connect-sdk/ui";

const [current, previous] = await Promise.all([
  connect.analytics.metrics({ metrics: ["deals.won", "deals.won_value"], from: period.from, to: period.to, granularity: "total", workspaceId }),
  connect.analytics.metrics({ metrics: ["deals.won", "deals.won_value"], from: period.previousFrom, to: period.previousTo, granularity: "total", workspaceId }),
]);

<ConnectStatStrip columns={2}>
  {current.series.map((series, index) => (
    <ConnectStatTile
      key={series.metric}
      label={series.label}
      value={formatMetricValue(series.total, series.unit)}
      delta={metricDelta(series.total, previous.series[index]?.total ?? null)}
      partial={series.partial}
    />
  ))}
</ConnectStatStrip>

const daily = await connect.analytics.metrics({ metrics: ["deals.won"], from: period.from, to: period.to, granularity: periodGranularity(period.days), workspaceId });

<ConnectMetricChart series={daily.series[0]} />
```

`ConnectMetricChart` draws columns for a flow and a line for a `gauge`. A
`null` bucket keeps its slot as a dotted floor, breaks the line, and its hover
says "not yet measured" — it is never drawn as zero. `ConnectStatTile` shows no
delta pill when there is nothing to compare against, rather than a "+0%".

## Connect's own numbers

```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.