@molecule/api-analytics-axiom
Provider bond · analytics · API (Node) · v1.0.2 · Apache-2.0
Axiom analytics provider for molecule.dev — batches tracked events into an Axiom dataset over the ingest API, never blocking the caller.
npm install @molecule/api-analytics-axiomnpm · Source on GitHub · Implements @molecule/api-analytics
How it works
@molecule/api-analytics-axiom is a provider bond on the API (Node) side: it implements the analytics core interface (@molecule/api-analytics) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider, track } from '@molecule/api-analytics'
import { createProvider } from '@molecule/api-analytics-axiom'
const axiom = createProvider({
// token / dataset default to AXIOM_TOKEN / AXIOM_DATASET
service: 'my-api',
region: process.env.FLY_REGION,
version: process.env.APP_VERSION,
})
setProvider(axiom)
await track({ name: 'order.placed', userId: 'u_123', properties: { total: 42 } })
// Before the process exits, send what is still queued:
await axiom.shutdown()Works with: @molecule/api-analytics
Secrets: AXIOM_TOKEN (optional), AXIOM_DATASET (optional), AXIOM_ORG_ID (optional), AXIOM_EDGE_URL (optional)
Reference
Axiom analytics provider for molecule.dev.
Sends every track() / identify() / page() / group() call as one
event to an Axiom dataset through Axiom's ingest API.
Events are queued in memory and sent in NDJSON batches, so a call never
waits on the network and never throws.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider, track } from '@molecule/api-analytics'
import { createProvider } from '@molecule/api-analytics-axiom'
const axiom = createProvider({
// token / dataset default to AXIOM_TOKEN / AXIOM_DATASET
service: 'my-api',
region: process.env.FLY_REGION,
version: process.env.APP_VERSION,
})
setProvider(axiom)
await track({ name: 'order.placed', userId: 'u_123', properties: { total: 42 } })
// Before the process exits, send what is still queued:
await axiom.shutdown()
Type
provider
Installation
npm install @molecule/api-analytics-axiom @molecule/api-analytics
API
Interfaces
AxiomIngesterOptions
Options for createAxiomIngester. Every field is optional.
interface AxiomIngesterOptions {
/** Axiom API token. Defaults to `$AXIOM_TOKEN`. No token → the ingester is disabled (a silent no-op). */
token?: string
/** Target dataset. Defaults to `$AXIOM_DATASET`. No dataset → disabled. */
dataset?: string
/** Org id, sent as `X-Axiom-Org-Id` (needed for personal tokens only). Defaults to `$AXIOM_ORG_ID`. */
orgId?: string
/**
* Edge deployment base URL (e.g. `https://eu-central-1.aws.edge.axiom.co`).
* Defaults to `$AXIOM_EDGE_URL`. When set, events go to `<edge>/v1/ingest/<dataset>`;
* otherwise to `https://api.axiom.co/v1/datasets/<dataset>/ingest`.
*/
edgeUrl?: string
/** Fields stamped onto every event (e.g. `service`, `env`, `region`, `version`). Event fields win on conflict. */
stamp?: Record<string, unknown>
/** Flush period in ms (default 2000). */
flushIntervalMs?: number
/** Most events per request (default 500). A full batch flushes immediately. */
maxBatchEvents?: number
/** Most bytes per request body (default 1_000_000). A single larger event is dropped. */
maxBatchBytes?: number
/** Most events held in memory (default 10_000). Further events are dropped. */
maxQueueEvents?: number
/**
* Most bytes held in memory, counting each serialized event plus its newline (default
* 32_000_000; never less than `maxBatchBytes`). When a new event would exceed it, that new event
* is dropped and counted; the queued (older) events are kept.
*/
maxQueueBytes?: number
/**
* Retries per batch after the first attempt, for network errors, 429 and 5xx (default 4).
* A 429/503 with `Retry-After` waits that long (at most 60 s) instead of the backoff.
*/
maxRetries?: number
/** First retry delay in ms; doubles per attempt (default 500). */
retryBaseMs?: number
/** Per-request timeout in ms (default 10_000). */
timeoutMs?: number
/** Transform applied to each event before it is queued (e.g. secret redaction). Return null to drop it. */
beforeSend?: (event: Record<string, unknown>) => Record<string, unknown> | null
/** Injected fetch (tests). Defaults to the global `fetch`. */
fetch?: typeof fetch
/** Where throttled warnings go (default `console.warn`). Never routed through a logger bond, so a logger mirror cannot loop. */
warn?: (message: string, detail?: Record<string, unknown>) => void
}
AxiomShutdownOptions
Options for shutdown().
interface AxiomShutdownOptions {
/**
* Total budget in ms for the final drain (default 5000). At the deadline every request in
* flight, retry wait and drain still running stops; whatever was not sent is counted as dropped.
*/
deadlineMs?: number
}
AxiomIngesterStats
Running counters, for health reporting and tests.
interface AxiomIngesterStats {
/** Events accepted into the queue. */
queued: number
/** Events Axiom stored (a 200 can report some events of a batch as failed; those are not sent). */
sent: number
/**
* Events dropped (queue full by count or bytes, oversized, unserializable, rejected, failed inside an
* acknowledged batch, out of retries, or ingested after `shutdown()`).
*/
dropped: number
/** Events waiting in memory right now. */
pending: number
}
AxiomIngester
An Axiom ingest client.
interface AxiomIngester {
/** Whether a token and dataset are configured. When false every method is a no-op. */
readonly enabled: boolean
/** The dataset events go to, or null when disabled. */
readonly dataset: string | null
/** Queue one event. Synchronous, never throws, never waits on the network. After `shutdown()` it only counts the event as dropped. */
ingest(event: Record<string, unknown>): void
/** Send everything queued now. Resolves when the queue is drained or a batch has exhausted its retries. */
flush(): Promise<void>
/**
* Stop the timer and drain what is left within a total budget (`deadlineMs`, default 5000):
* each remaining batch gets one attempt (no retries). At the deadline everything still running
* stops — including a flush that was already in flight, with its retries — so no work outlives
* the returned promise. Events not sent by then (queued or in flight) are counted as dropped and
* reported in one warning with the pending count.
*/
shutdown(options?: AxiomShutdownOptions): Promise<void>
/** Current counters. */
stats(): AxiomIngesterStats
}
AxiomAnalyticsOptions
Options for createProvider. Same as the ingester's, plus the stamped identity fields.
interface AxiomAnalyticsOptions extends AxiomIngesterOptions {
/** Stamped as `service` on every event (e.g. `molecule-api`). */
service?: string
/** Stamped as `env`. Defaults to `$NODE_ENV`. */
env?: string
/** Stamped as `region` (e.g. the Fly region). */
region?: string
/** Stamped as `version` (release or commit). */
version?: string
}
AxiomAnalyticsProvider
An analytics provider backed by Axiom, with its ingester's lifecycle exposed.
interface AxiomAnalyticsProvider extends AnalyticsProvider {
/** Whether a token and dataset are configured. When false every call is a no-op. */
readonly enabled: boolean
/** The dataset events go to, or null when disabled. */
readonly dataset: string | null
/** Records a group association as a `group` event. */
group(groupId: string, traits?: Record<string, unknown>): Promise<void>
/** Send everything queued now. */
flush(): Promise<void>
/** Stop the flush timer and send what is left within `deadlineMs` (default 5000). Call before the process exits. */
shutdown(options?: AxiomShutdownOptions): Promise<void>
/** Queue/sent/dropped counters. */
stats(): AxiomIngesterStats
}
Functions
retryAfterMs(header)
Milliseconds a Retry-After header asks for (delta-seconds or an HTTP
date), capped at MAX_RETRY_AFTER_MS; null when absent or unreadable.
function retryAfterMs(header: string | null): number | null
header— The header value.
Returns: The wait in ms, or null.
safeStringify(value)
JSON.stringify that survives what loggers and analytics callers pass in:
Errors become { name, message, stack }, bigints become strings and cycles
become "[Circular]".
function safeStringify(value: unknown): string | null
value— Any value.
Returns: The JSON text, or null when it cannot be serialized.
axiomIngestUrl(dataset, edgeUrl)
Builds the ingest URL for a dataset.
function axiomIngestUrl(dataset: string, edgeUrl?: string): string
dataset— The dataset name.edgeUrl— Optional edge deployment base URL.
Returns: The full ingest URL.
createAxiomIngester(options)
Creates an Axiom ingest client. Disabled (every call a no-op) unless both a token and a dataset are configured.
function createAxiomIngester(options?: AxiomIngesterOptions): AxiomIngester
options— Ingest configuration; unset fields fall back toAXIOM_*env vars.
Returns: The ingest client.
createProvider(options)
Creates an Axiom analytics provider.
function createProvider(options?: AxiomAnalyticsOptions): AxiomAnalyticsProvider
options— Ingest settings and stamped identity fields; unset ingest fields fall back toAXIOM_*env vars.
Returns: The provider. Disabled (a silent no-op) without a token and dataset.
Constants
AXIOM_DEFAULT_API_URL
Default Axiom API origin (the dataset-path ingest endpoint lives here).
const AXIOM_DEFAULT_API_URL: 'https://api.axiom.co'
provider
The default provider, configured from AXIOM_TOKEN / AXIOM_DATASET /
AXIOM_ORG_ID / AXIOM_EDGE_URL. Created on first use, so env loaded after
import still applies. Disabled (a silent no-op) without a token and dataset.
const provider: AxiomAnalyticsProvider
Core Interface
Implements @molecule/api-analytics interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-analytics'
import { provider } from '@molecule/api-analytics-axiom'
export function setupAnalyticsAxiom(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-analytics^1.0.1
Environment Variables
AXIOM_TOKEN(optional) — Axiom API token- Setup: Create an API token with ingest permission for the dataset in Axiom → Settings → API tokens. Unset disables sending.
- Get it here: https://app.axiom.co/settings/api-tokens
- Example:
xaat-...
AXIOM_DATASET(optional) — Axiom dataset- Setup: Name of the Axiom dataset events are sent to. Unset disables sending.
- Example:
my-app-prod
AXIOM_ORG_ID(optional) — Axiom org id- Setup: Only needed with a personal token; API tokens carry their org.
AXIOM_EDGE_URL(optional) — Axiom edge URL- Setup: Edge deployment base URL for region-local ingest; unset uses https://api.axiom.co.
- Example:
https://eu-central-1.aws.edge.axiom.co
Runtime Dependencies
-
@molecule/api-analytics -
No token or no dataset → a silent no-op.
enabledisfalseand every call resolves without sending anything. Checkprovider.enabledat boot if you want to log whether Axiom is on. -
Configuration (each option falls back to the env var):
token→AXIOM_TOKEN,dataset→AXIOM_DATASET,orgId→AXIOM_ORG_ID(only needed for a personal token; an API token carries its org),edgeUrl→AXIOM_EDGE_URL(e.g.https://eu-central-1.aws.edge.axiom.co; when set, events go to<edge>/v1/ingest/<dataset>, otherwise tohttps://api.axiom.co/v1/datasets/<dataset>/ingest). -
Batching: a batch is sent every
flushIntervalMs(2 s), or at once whenmaxBatchEvents(500) are queued; a request body never exceedsmaxBatchBytes(1 MB). Network errors, 429 and 5xx are retried with exponential backoff (maxRetries4, fromretryBaseMs500 ms); a 429/503 withRetry-Afterwaits that long instead (at most 60 s); other 4xx drop the batch. A 200 whose body reportsfailedevents counts only the stored ones assentand the rest asdropped(with a warning). WhenmaxQueueEvents(10 000) are waiting, or the queue holdsmaxQueueBytes(32 MB, never less thanmaxBatchBytes), a new event is dropped and counted — the older queued events are kept, so the record stays contiguous from where sending stopped; a single event larger thanmaxBatchBytesis dropped, never queued. Every kind of failure warns at most once a minute, throughconsole.warn(or thewarnoption) — never through a logger bond, so a logger that mirrors into Axiom cannot loop. -
Short-lived processes must
await provider.shutdown()(orflush()) before exiting, or queued events are lost. The flush timer isunref'd and does not keep a process alive.shutdown({ deadlineMs })(default 5000 ms; pass a larger budget if your host waits longer before killing the process) drains within that total budget: each remaining batch gets one attempt (no retries). At the deadline everything still running stops, including a flush already in flight with its retries andRetry-Afterwaits, so nothing keeps the process alive aftershutdown()resolves. Events not sent by then (queued or in flight) are counted asdroppedand reported in one warning with the pending count. ARetry-Afternever waits less thanretryBaseMs. Events tracked aftershutdown()are counted asdropped, never queued. -
Event shape:
{ _time, kind, event, userId?, anonymousId?, properties?, service?, env?, region?, version? }.kindistrack/identify/page/group;eventis the event name (page.viewfor pages,user.identifiedfor identify).service/region/versioncome from the options,envdefaults toNODE_ENV;stampadds more fixed fields. -
propertiesis sent as a nested object, so Axiom indexes each key as its own field (properties.projectId). Axiom caps fields per dataset (256 on the free Personal plan, 1 024 on Axiom Cloud as of 2026-10), so keep property names stable rather than putting ids or free text in KEYS. -
beforeSend(event)runs on every event before it is queued; return a changed copy (for example with secrets redacted) ornullto drop it. -
provideris a default instance configured from env on first use;createProvider()makes independent instances (each with its own queue). -
The ingest client is exported as
createAxiomIngester()for other Axiom bonds (@molecule/api-logger-axiomuses it).
E2E Tests
Integration checklist — drive the real UI (live preview, no mocks), adapt each item to this app's actual screens/flows, and check every box off one by one. A box you can't check is an integration bug to fix — not a skip:
- Each user action the app cares about (signup/login, a page or screen
view, a purchase/conversion or other domain event) EMITS a
track()/page()with the RIGHT event name + properties when performed in the UI — and the event is actually RECORDED, proven by reading it back, not by finding atrack(...)call in source. Analytics is fire-and-forget to a third-party (Mixpanel/PostHog/Segment) that is NOT reachable in the sandbox and NOT captured byread_activity(that captures email/sms/push/webhook/ channel, never analytics). So prove the wiring locally: read the event back from the app's own events store/dashboard/funnel if it has one, or bond a local recording/console provider for dev and confirm the call fired viaread_logs. - Events are ATTRIBUTED to the right identity:
identify({ userId, ... })runs at signup/login so events carry thatuserId. Server-side has no ambient session — atrack()/page()with neitheruserIdnoranonymousIdcollapses every user into one shared "anonymous" identity. If the app tracks activity before login, confirm thoseanonymousIdevents associate/merge to the user on identify. - Tracking is NON-BLOCKING. Unlike the app-side bond, these SERVER
functions PROPAGATE provider failures (they reject when the provider does,
and throw when none is bonded), so fire-and-forget is the CALLER's job:
every call site wraps the call in
.catch()/ log-and-continue. Verify by forcing a failure (unbonded or erroring provider) — the user action (signup, checkout) still completes with no visible error and no added latency; the analytics await never blocks the response. - PRIVACY — no secret or sensitive PII in event
propertiesor identifytraits: never a password, full card/PAN, CVV, auth token, or session cookie. Read the recorded event back and confirm only non-sensitive attributes (plan name, amount, item id) are present. - If the app exposes analytics reports/dashboards/funnels, the numbers
reflect REAL recorded events (perform an action, the matching count/funnel
step increments) and are SCOPED — a user/org reads only its OWN analytics;
no endpoint lets one org read another org's numbers (
group()associates a user under the bond's group type, 'company' by default).