DataGauge for the Convex runtime.
The problem it solves
Convex mutations cannot fetch(). The correct pattern is to schedule an
action with scheduler.runAfter(0, …), which commits transactionally with
the mutation: if the mutation rolls back, nothing is sent; if it commits,
the send is guaranteed to run. track() wraps that scheduling, and
makeSendHandler() gives you the action body that performs the actual
HTTP send.
Install
npm install @datagauge/convex@^0.2.0This page documents 0.2.0, which is where the handler started reading
ingest's response: the failure table below, onError, and the throw on a
missing key all arrive with it. On 0.1.0 the response is discarded, every
refusal is silent, and getKey is typed () => string, so the setup line
there needs process.env.DATAGAUGE_KEY!. Upgrading is a version bump and
deleting that !.
Prefer zero dependencies? Every SDK is a single file, so vendor it and point your import at the local copy:
curl -o convex/lib/datagauge.ts \
https://raw.githubusercontent.com/raymond-UI/datagauge/main/sdks/convex/index.tsUsage
Register the send action once, in convex/datagauge.ts:
// convex/datagauge.ts
import { internalAction } from "./_generated/server";
import { makeSendHandler } from "@datagauge/convex";
export const send = internalAction(makeSendHandler(() => process.env.DATAGAUGE_KEY));Then track from any mutation (or action):
import { internal } from "./_generated/api";
import { track } from "@datagauge/convex";
await track(ctx, internal.datagauge.send, { name: "user_signup", user: userId });If the mutation rolls back, nothing is sent. If it commits, the send runs.
API
makeSendHandler(getKey, opts?)
makeSendHandler(getKey: () => string | undefined, opts?: SendOptions)
Returns the { args, handler } body for the internalAction that performs
the HTTP send. getKey is called at send time so the key is read from the
action's environment (process.env.DATAGAUGE_KEY); returning undefined or
an empty string is an error, not a silent no-op (see below). SendOptions
accepts an optional url (defaults to https://in.datagauge.dev), a
fetcher for tests, and an onError callback.
What happens when ingest refuses the batch
fetch() rejects only on transport failure, so an SDK that ignores the
response cannot tell 401 from success. This one reads it, and splits on
whether re-sending the identical bytes could ever work:
| Response | What the handler does |
|---|---|
202 | Nothing, unless the body carries a non-empty errors[], in which case a per-event rejection is reported. |
Any other 4xx (400, 401, 402, 403, 413, 422) | Reports the failure with the likely cause named, and returns. Retrying is refused identically. |
429, 5xx | Throws. Nothing was stored, so the batch is still recoverable. |
| Transport failure | Throws, with the underlying error as cause. |
A throw marks the scheduled action failed and puts the reason in your
deployment's logs. Convex does not re-run a failed action on its own (unlike a
mutation, an action may have side effects), so if the events matter enough to
re-send, register send through
@convex-dev/action-retrier. Either
way, the batch is no longer lost in silence.
onError
Route failures somewhere other than console.error:
export const send = internalAction(
makeSendHandler(() => process.env.DATAGAUGE_KEY, {
onError: (f) => {
// f: { status, body, events, message, retriable }
Sentry.captureMessage(f.message, { level: f.retriable ? "warning" : "error" });
},
}),
);It is called for every failure, including the retriable ones the handler then throws on, so what you see is the whole picture rather than the half that doesn't throw.
If your reporter itself throws, the failure it was called about is logged to the console instead, and the reporter's own error alongside it. A misconfigured Sentry client cannot replace the reason your batch failed.
A deployment with no key
process.env.DATAGAUGE_KEY! is a type assertion, not a check. Forget the
variable on one deployment, or typo it, and every event for the life of that
deployment goes out as Bearer undefined. So a missing key throws before
anything is sent, naming the variable:
npx convex env set DATAGAUGE_KEY sk_live_… # production
npx convex env set DATAGAUGE_KEY sk_test_… # developmentConvex keeps environment variables per deployment, so setting it in one place does not set it in the other.
If a deployment legitimately has no key (a preview deployment, a fresh clone, a template consumer who doesn't use DataGauge), check for it at the call site so no action is ever scheduled:
export async function trackSelf(ctx: MutationCtx, event: DataGaugeEvent) {
if (!process.env.DATAGAUGE_KEY) return;
await track(ctx, internal.datagauge.send, event);
}That is a deliberate opt-out, which is a different thing from a key you meant to set and didn't.
track(ctx, ref, event)
track(ctx: { scheduler }, ref, event: DataGaugeEvent | DataGaugeEvent[]): Promise<void>
Schedules the send transactionally via ctx.scheduler.runAfter(0, ref, …).
ref is your registered internalAction (e.g. internal.datagauge.send).
It's deliberately loosely typed because the generated FunctionReference
lives in your app's codegen, which the package can't import.
Validators
eventValidator and sendArgsValidator are exported for composing your
own actions. A DataGaugeEvent is { name, user?, id?, at?, props? }; see
POST /v1/events for the field reference.
Key hygiene
The send action runs server-side and reads an sk_live_ key from
process.env, never exposed to the client. That's the right place
for money-writing events; see Keys & scoping. Point
process.env.DATAGAUGE_KEY at an sk_test_ key to send into your isolated
test environment instead.