@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-axiom

npm · 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.ts JSDoc, 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 to AXIOM_* 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 to AXIOM_* 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. enabled is false and every call resolves without sending anything. Check provider.enabled at 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 to https://api.axiom.co/v1/datasets/<dataset>/ingest).

  • Batching: a batch is sent every flushIntervalMs (2 s), or at once when maxBatchEvents (500) are queued; a request body never exceeds maxBatchBytes (1 MB). Network errors, 429 and 5xx are retried with exponential backoff (maxRetries 4, from retryBaseMs 500 ms); a 429/503 with Retry-After waits that long instead (at most 60 s); other 4xx drop the batch. A 200 whose body reports failed events counts only the stored ones as sent and the rest as dropped (with a warning). When maxQueueEvents (10 000) are waiting, or the queue holds maxQueueBytes (32 MB, never less than maxBatchBytes), 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 than maxBatchBytes is dropped, never queued. Every kind of failure warns at most once a minute, through console.warn (or the warn option) — never through a logger bond, so a logger that mirrors into Axiom cannot loop.

  • Short-lived processes must await provider.shutdown() (or flush()) before exiting, or queued events are lost. The flush timer is unref'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 and Retry-After waits, so nothing keeps the process alive after shutdown() resolves. Events not sent by then (queued or in flight) are counted as dropped and reported in one warning with the pending count. A Retry-After never waits less than retryBaseMs. Events tracked after shutdown() are counted as dropped, never queued.

  • Event shape: { _time, kind, event, userId?, anonymousId?, properties?, service?, env?, region?, version? }. kind is track / identify / page / group; event is the event name (page.view for pages, user.identified for identify). service / region / version come from the options, env defaults to NODE_ENV; stamp adds more fixed fields.

  • properties is 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) or null to drop it.

  • provider is 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-axiom uses 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 a track(...) 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 by read_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 via read_logs.
  • Events are ATTRIBUTED to the right identity: identify({ userId, ... }) runs at signup/login so events carry that userId. Server-side has no ambient session — a track() / page() with neither userId nor anonymousId collapses every user into one shared "anonymous" identity. If the app tracks activity before login, confirm those anonymousId events 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 properties or identify traits: 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).