MIRA FIVE

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-server

Peers: 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:

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

convex/orders.ts
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

  1. 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 null when MIRA FIVE accepted the batch, and fails with the server's error otherwise. events is a JSON string, as the scheduler stores it.

  2. 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:deliver run.

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:

convex/mirafive.ts
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.

convex/schema.ts
import { miraOutboxTable } from '@mirafive/sdk-convex'
import { defineSchema } from 'convex/server'

export default defineSchema({
  miraOutbox: miraOutboxTable,
})
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,
  outbox: true,
  deliver: internal.mirafive.deliver,
})

export const deliver = mira.deliverAction()
export const { flushOutbox, outboxRows } = mira.flushOutbox()
convex/crons.ts
import { cronJobs } from 'convex/server'

import { internal } from './_generated/api'

const crons = cronJobs()

crons.interval('mirafive outbox', { seconds: 30 }, internal.mirafive.flushOutbox)

export default crons

The 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
LatencyImmediateUp to one cron interval
Convex costOne action run per callAn insert and a delete per event, one action run per flush
SetupNoneA schema table, two exports, a cron
When a send failsThe action fails after 3 retries; use a workpool to retry itRows stay and go with the next flush
Where queued events are_scheduled_functionsYour 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 with outbox: true. idempotencyKey is 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:

convex/convex.config.ts
import workpool from '@convex-dev/workpool/convex.config'
import { defineApp } from 'convex/server'

const app = defineApp()

app.use(workpool, { name: 'mirafivePool' })

export default app
convex/mirafive.ts
import { 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.

  • 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. Passing userId, anonymousId or sessionId, or calling identify(), then throws a TypeError in the calling function.
  • Do Not Track and Global Privacy Control reach an httpAction as DNT: 1 and Sec-GPC: 1 headers. Leave the identifiers out of track() for such requests.
  • Outbox rows are your data in your own miraOutbox table, one event per row as a JSON string in event. A row lives until the next flush sends it. Without the outbox, a queued delivery's arguments sit in Convex's _scheduled_functions table, 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.

NameTypeDefaultDescription
deliverDeliverReferenceRequired. The exported deliverAction(), e.g. internal.mirafive.deliver.
keystring | undefinedThe source's secret key. Missing: events are dropped with one warning.
hoststringhttps://events.mirafive.ioWith https://. An empty string means the default.
mode'full' | 'consentless''full'Collection mode of every batch.
outboxbooleanfalseMutations 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.
disabledbooleanfalseDrops every event, e.g. on preview deployments. Programming errors still throw.
MemberDescription
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.
ExportDescription
miraOutboxTableThe outbox table: defineSchema({ miraOutbox: miraOutboxTable }).
MiraErrorRe-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 as invalid_event or payload_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-server as their SDK.

Troubleshooting

SymptomCause and fix
Nothing arrivesCheck 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 …:outboxRowsExport flushOutbox and outboxRows from the module that exports deliver, under those names.
miraOutbox table or validator errorsAdd 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_bearerYou set the website key. Server sources need the secret key.
400 collection_mode_not_allowedThe 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 refusesA $ 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 growsDeliveries 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.

On this page