Analytics
What to emit, what Connect already counts, and reading it back.
Two halves: analytics.emit sends your own product events, analytics.series
reads back aggregated numbers for your application. Neither is billing — see
Metering at the bottom.
Emit
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:
const events = campaigns.map((campaign) => ({
name: "campaign.sent",
workspaceId,
props: { recipients: campaign.recipientCount },
}));
await connect.analytics.emit(events);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.
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
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:
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.
// 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.
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.
// 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.