# @datagauge/convex

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

```sh
npm install @datagauge/convex@^0.2.0
```

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:

```sh
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`:

```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):

```ts
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`](https://www.convex.dev/components/retrier). Either
way, the batch is no longer lost in silence.

### `onError`

Route failures somewhere other than `console.error`:

```ts
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:

```sh
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:

```ts
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](/docs/api/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](/docs/concepts/keys). Point
`process.env.DATAGAUGE_KEY` at an `sk_test_` key to send into your isolated
test environment instead.
