@datagauge/convex

Last updated Aug 12, 2026View as MarkdownAgent setup

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.0
0.2.0 and later

This 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.ts

Usage

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:

ResponseWhat the handler does
202Nothing, 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, 5xxThrows. Nothing was stored, so the batch is still recoverable.
Transport failureThrows, 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_…          # development

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