Convex
Record MIRA FIVE events from Convex mutations and actions, sent only after the transaction commits.
@mirafive/sdk-convex records events from Convex mutations and actions. A mutation never sends: it schedules the delivery, which Convex runs only if the mutation commits, so an order that rolled back is never counted. Delivery goes through @mirafive/sdk-server. For the browser side of a Convex app, use the browser SDK or your framework's package.
Install
npm install @mirafive/sdk-convex @mirafive/sdk-serverPeers: convex 1.25 or newer and @mirafive/sdk-server 1.0. It runs in Convex's default runtime; you do not need "use node".
Set up
You need the secret key of a server source (mf_…). See Keys for where to find it. Set it on the deployment, never in code:
npx convex env set MIRAFIVE_SECRET_KEY mf_…Create the client and export its delivery action:
import { MiraConvex } from '@mirafive/sdk-convex'
import { internal } from './_generated/api'
export const mira: MiraConvex = new MiraConvex({
key: process.env.MIRAFIVE_SECRET_KEY,
deliver: internal.mirafive.deliver,
})
export const deliver = mira.deliverAction()Keep the : MiraConvex annotation. mira refers to internal.mirafive.deliver, whose generated type comes from this module, so without it TypeScript reports TS7022 ('mira' implicitly has type 'any').
Track where things happen:
import { v } from 'convex/values'
import { mutation } from './_generated/server'
import { mira } from './mirafive'
export const pay = mutation({
args: { orderId: v.id('orders') },
handler: async (ctx, { orderId }) => {
const order = await ctx.db.get(orderId)
if (!order) {
throw new Error('order not found')
}
await ctx.db.patch(orderId, { paidAt: Date.now() })
await mira.track(ctx, 'order paid', {
userId: order.customerId,
properties: { revenue: order.total, currency: 'EUR' },
idempotencyKey: `order-${orderId}`,
})
},
})That is the whole install. A missing key never breaks the deployment: events are dropped with one warning in the logs.
Verify
-
Run the delivery action once with an install check. It proves the key and host work and is never stored or billed:
npx convex run mirafive:deliver '{"events":"[{\"name\":\"$install_check\"}]","idempotencyKey":"install-check"}'It prints
nullwhen MIRA FIVE accepted the batch, and fails with the server's error otherwise.eventsis a JSON string, as the scheduler stores it. -
Run a mutation that tracks, then open the source's live view in MIRA FIVE and find the event. The Convex dashboard shows the
mirafive:deliverrun.
Nothing arriving? See Troubleshooting.
Track events
track(ctx, name, options) works in mutations and actions. ctx is any mutation or action context; queries cannot schedule. await every call.
In a mutation, track() schedules deliver with ctx.scheduler.runAfter(0, …). Convex runs a scheduled function only if the mutation commits, and a mutation cannot reach the network, so nothing is sent from it. Each call is one action run and one batch. To put several events in one delivery, use trackMany():
await mira.trackMany(ctx, [
{ name: 'order paid', userId, properties: { revenue: 49.9, currency: 'EUR' } },
{ name: 'invoice created', userId, properties: { invoiceId } },
])time (a Date, epoch milliseconds or an ISO string) defaults to the start of the calling function. An unparseable time throws a TypeError at the call.
Events travel as JSON strings, so property keys Convex refuses as field names ($…, _…, non-ASCII) are fine. The protocol's own limits still apply (keys up to 128 characters, at most 32 KB of properties). An event that is not JSON (a BigInt, a cycle) is dropped with a warning; it never fails your transaction.
To have TypeScript check event names and properties, pass an event map. It is types only:
type Events = { 'order paid': { revenue: number; currency: string } }
export const mira: MiraConvex<Events> = new MiraConvex<Events>({
key: process.env.MIRAFIVE_SECRET_KEY,
deliver: internal.mirafive.deliver,
})Event names, properties and revenue are covered in Track events.
Identify users
identify(ctx, userId, traits?, { anonymousId? }) records the person's traits. With the browser's anonymous id, it also links the visitor's earlier events to the user:
await mira.identify(ctx, userId, { plan: 'pro' }, { anonymousId })Use your internal user id, never an email address. See Identify users.
Idempotent deliveries
Every delivery carries an idempotency key, fixed when it is queued: the idempotencyKey you pass to track() or trackMany(), or a fresh UUID. The delivery's sentAt and every event's time are fixed at the same moment. A rerun of the action therefore sends the byte-identical batch, and MIRA FIVE stores it once. Pass your own key when a mutation can run twice for the same thing, such as a webhook. An empty key throws a TypeError.
The outbox
By default every track() or trackMany() call is one action run. That is immediate and needs no setup, and it suits up to roughly one event per second. Above that, turn on the outbox: mutations write events to a table in your schema, and a cron sends them in batches of up to 1000 events.
import { miraOutboxTable } from '@mirafive/sdk-convex'
import { defineSchema } from 'convex/server'
export default defineSchema({
miraOutbox: miraOutboxTable,
})import { MiraConvex } from '@mirafive/sdk-convex'
import { internal } from './_generated/api'
export const mira: MiraConvex = new MiraConvex({
key: process.env.MIRAFIVE_SECRET_KEY,
outbox: true,
deliver: internal.mirafive.deliver,
})
export const deliver = mira.deliverAction()
export const { flushOutbox, outboxRows } = mira.flushOutbox()import { cronJobs } from 'convex/server'
import { internal } from './_generated/api'
const crons = cronJobs()
crons.interval('mirafive outbox', { seconds: 30 }, internal.mirafive.flushOutbox)
export default cronsThe table must be named miraOutbox. flushOutbox and outboxRows must be exported under those names from the module that exports deliver: the drain finds outboxRows next to deliver.
| Per-call scheduling (default) | Outbox | |
|---|---|---|
| Latency | Immediate | Up to one cron interval |
| Convex cost | One action run per call | An insert and a delete per event, one action run per flush |
| Setup | None | A schema table, two exports, a cron |
| When a send fails | The action fails after 3 retries; use a workpool to retry it | Rows stay and go with the next flush |
| Where queued events are | _scheduled_functions | Your miraOutbox table |
MIRA FIVE meters per event either way: batching saves Convex action time, not MIRA FIVE usage.
How the drain works:
- A flush reads only rows created before it started; rows written during a flush go with the next one. Convex runs one instance of a cron at a time, so no lock is needed.
- A batch is named after its row ids. A batch that was sent but not yet deleted is resent under the same batch id and stored once.
- One run drains the whole backlog, at most 1000 events or 1 MiB per batch.
- An action has no table to write to, so
track()in an action schedules a delivery even withoutbox: true.idempotencyKeyis ignored for events that go to the outbox.
Retries with a workpool
The scheduler does not retry a failed action. To retry deliveries, hand them to a Convex workpool with enqueue. Install @convex-dev/workpool and register the component:
import workpool from '@convex-dev/workpool/convex.config'
import { defineApp } from 'convex/server'
const app = defineApp()
app.use(workpool, { name: 'mirafivePool' })
export default appimport { Workpool } from '@convex-dev/workpool'
import { MiraConvex } from '@mirafive/sdk-convex'
import { components, internal } from './_generated/api'
import type { MutationCtx } from './_generated/server'
const pool = new Workpool(components.mirafivePool, { maxParallelism: 2, retryActionsByDefault: true })
export const mira: MiraConvex = new MiraConvex({
key: process.env.MIRAFIVE_SECRET_KEY,
deliver: internal.mirafive.deliver,
enqueue: (ctx, deliver, args) => pool.enqueueAction(ctx as MutationCtx, deliver, args),
})
export const deliver = mira.deliverAction()Because the idempotency key is fixed when the delivery is queued, a retried delivery is stored once.
Consent
- Full mode (default) sends the identifiers you pass:
userId,anonymousId,sessionId. You hold the consent or other lawful basis for them. - Consentless mode (
mode: 'consentless') counts without identifiers. PassinguserId,anonymousIdorsessionId, or callingidentify(), then throws aTypeErrorin the calling function. - Do Not Track and Global Privacy Control reach an
httpActionasDNT: 1andSec-GPC: 1headers. Leave the identifiers out oftrack()for such requests. - Outbox rows are your data in your own
miraOutboxtable, one event per row as a JSON string inevent. A row lives until the next flush sends it. Without the outbox, a queued delivery's arguments sit in Convex's_scheduled_functionstable, which Convex keeps for a while after the run.
See Consent.
API reference
import { MiraConvex, MiraError, miraOutboxTable } from '@mirafive/sdk-convex'
import type {
DeliverArgs,
DeliverReference,
Enqueue,
Events,
MiraConvexOptions,
Mode,
OutboxFunctions,
OutboxRow,
OutboxRowsArgs,
Page,
QueuedEvent,
Receipt,
SchedulingCtx,
TrackEvent,
TrackOptions,
} from '@mirafive/sdk-convex'new MiraConvex<Events>(options)
The constructor never throws, so a module that creates the client cannot take the deployment down.
| Name | Type | Default | Description |
|---|---|---|---|
deliver | DeliverReference | Required. The exported deliverAction(), e.g. internal.mirafive.deliver. | |
key | string | undefined | The source's secret key. Missing: events are dropped with one warning. | |
host | string | https://events.mirafive.io | With https://. An empty string means the default. |
mode | 'full' | 'consentless' | 'full' | Collection mode of every batch. |
outbox | boolean | false | Mutations write to miraOutbox instead of scheduling. |
enqueue | (ctx, deliver, args) => Promise<unknown> | ctx.scheduler.runAfter(0, deliver, args) | Queues one delivery, e.g. through a workpool. |
disabled | boolean | false | Drops every event, e.g. on preview deployments. Programming errors still throw. |
| Member | Description |
|---|---|
track(ctx, name, options?) | Promise<void>. Queues one event. options: userId, anonymousId, sessionId, properties, time, page ({ url?, title?, referrer? }), idempotencyKey. |
trackMany(ctx, events, { idempotencyKey? }?) | Promise<void>. Queues 1–1000 events ({ name, …track options without idempotencyKey }) as one delivery. |
identify(ctx, userId, traits?, { anonymousId? }?) | Promise<void>. Queues $identify. |
deliverAction() | The internal action that sends one delivery. Export it as deliver. |
flushOutbox() | { flushOutbox, outboxRows }: the cron action and its internal mutation. Export both. |
| Export | Description |
|---|---|
miraOutboxTable | The outbox table: defineSchema({ miraOutbox: miraOutboxTable }). |
MiraError | Re-exported from @mirafive/sdk-server. |
The calls never throw for transport reasons. They throw a TypeError only for programming errors: identifiers in consentless mode, an unparseable time, an empty idempotencyKey, or an empty or over-1000 trackMany().
What deliver does
deliver sends with @mirafive/sdk-server: 3 retries on 408, 429, 5xx, network errors and timeouts. When every retry fails, the action fails and shows in the Convex logs, and a workpool retries it.
- Events the server refuses (
400 validation_failed, or refused before sending asinvalid_eventorpayload_too_large) are found by halving the batch, dropped one by one with a warning, and the rest is sent. A malformed event never blocks the outbox. - Other refusals (
collection_mode_not_allowed,invalid_json, a wrong key) concern the whole source: the run fails and outbox rows are kept until you fix it. - A receipt that reports dropped events (
ingestion_paused,allowance_exhausted) is logged as a warning. - Deliveries report
mirafive-serveras their SDK.
Troubleshooting
| Symptom | Cause and fix |
|---|---|
| Nothing arrives | Check the Convex logs. [mirafive] no key: run npx convex env set MIRAFIVE_SECRET_KEY …. A failed deliver shows the server's error. With the outbox, check that convex/crons.ts is deployed. |
Could not find function …:outboxRows | Export flushOutbox and outboxRows from the module that exports deliver, under those names. |
miraOutbox table or validator errors | Add miraOutbox: miraOutboxTable to convex/schema.ts. |
TS7022 'mira' implicitly has type 'any' | Annotate the client: export const mira: MiraConvex = new MiraConvex({ … }). |
403 website_key_as_bearer | You set the website key. Server sources need the secret key. |
400 collection_mode_not_allowed | The source is consentless. Pass mode: 'consentless' and no identifiers. Outbox rows wait until then. |
TypeError: consentless mode … | Identifiers in consentless mode. Remove them or switch the mode. |
TypeError: time must be … | time is not a Date, epoch milliseconds or a parseable date string. |
[mirafive] dropped an event the server refuses | A $ name that is not reserved, a blank id, a sessionId that is not a UUID, or properties over the limits. |
[mirafive] the server dropped N events: … | allowance_exhausted or ingestion_paused: the organization is out of allowance or paused. |
| The outbox grows | Deliveries fail, typically for a wrong key. See the flushOutbox runs in the logs; rows are kept until it is fixed. |
Set up with an AI agent
Paste this into your coding agent:
Add MIRA FIVE server-side analytics to this Convex project with @mirafive/sdk-convex.
Docs: https://docs.mirafive.io/sdks/convex.md
1. Install @mirafive/sdk-convex and @mirafive/sdk-server with the project's package manager.
2. The secret key goes in the Convex deployment's environment, never in code or client bundles.
Tell the user to run: npx convex env set MIRAFIVE_SECRET_KEY <secret key of a server source>
3. Create convex/mirafive.ts:
import { MiraConvex } from '@mirafive/sdk-convex'
import { internal } from './_generated/api'
export const mira: MiraConvex = new MiraConvex({ key: process.env.MIRAFIVE_SECRET_KEY, deliver: internal.mirafive.deliver })
export const deliver = mira.deliverAction()
Keep the ": MiraConvex" annotation; without it TypeScript reports TS7022.
4. In the mutations where business events happen (signup, order paid, subscription started), add
await mira.track(ctx, 'order paid', { userId, properties: { revenue, currency: 'EUR' } })
and after signup: await mira.identify(ctx, userId, { plan }, { anonymousId }) (anonymousId only if the
browser sent it). Use the internal user id, never an email. Several events in one mutation: mira.trackMany(ctx, [...]).
5. Only if the app records more than about one event per second: pass outbox: true, add
miraOutbox: miraOutboxTable to convex/schema.ts, export
export const { flushOutbox, outboxRows } = mira.flushOutbox()
from convex/mirafive.ts, and add
crons.interval('mirafive outbox', { seconds: 30 }, internal.mirafive.flushOutbox)
to convex/crons.ts.
6. Keep the default full mode (the app holds consent). For anonymous counts only, use
mode: 'consentless' and pass no userId, anonymousId or sessionId.
7. Verify: npx convex run mirafive:deliver '{"events":"[{\"name\":\"$install_check\"}]","idempotencyKey":"install-check"}'
must print null. Report what you changed.
Do not add other analytics libraries, cookies or consent banners, and do not call fetch from mutations.