Analytics
What to emit, what Connect already counts, and reading it back.
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 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.
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
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.
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:
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):
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:
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
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
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.
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
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.
Caching with the KV store
Cache-aside, rate limits, feature flags and idempotency — with the TTL semantics that make them safe.
Calling another application
One CosmoOps application calling another's API for a workspace, with a short-lived token Connect issues - and verifying such a call on the receiving side.