npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@iann29/rastro

v0.11.3

Published

Real-time product and web analytics for Convex: identified people, journeys, revenue, goals, funnels, affiliates, and a lightweight browser tracker.

Readme

Amage Rastro

CI

Real-time product and web analytics built as an isolated Convex component.

Amage Rastro combines a lightweight browser tracker with reactive traffic metrics, a live visitor atlas, journey timelines, trusted payment attribution, goals, funnels, affiliates, and multi-site reports. Data and functions stay inside your Convex deployment.

Amage Rastro Live Atlas

Project status

Amage Rastro is actively developed, with a canonical batched event store, protected CI, exact run-scoped benchmark reconciliation, and 0.1.0 as the first release on the untagged npm latest channel; the alphas before it stay available under their exact versions. Federation consumers must statically inspect an operator-approved exact registry artifact for every required runtime and type export before changing a host; follow the gate in the federation setup runbook. Never substitute a mutable branch or website-supplied source tarball.

Public integration documentation is available at www.amagerastro.com/docs/. Coding agents should start with llms.txt and verify the machine manifest.

Current evidence supports more than 140 million stored events/day for realistic traffic and more than 178 million for lean traffic. Near-limit heavy payloads are certified at 55.26 million/day on a data-heavy store (76.62 million/day on a fresh store, 2026-08-22). The feature-saturated profile failed its last certification on a rollup-contention defect (#52); the contended write path no longer exists (report rollups are folded by one job per site, off the ingestion transaction) and the profile awaits recertification. Workload-specific results and caveats are reported separately rather than collapsed into one capacity claim.

What ships

| Capability | Implementation | | --------------------------- | --------------------------------------------------------------------------------------------------------- | | Live visitor map | Reactive live sessions with city-level coordinates; hidden or closed tabs leave in 10 s, crashes in 90 s | | Traffic analytics | Sharded hour/day rollups, time series, top pages, sources, countries, devices, browsers, and events | | Field Web Vitals | Opt-in tracker module reporting LCP, CLS, INP, FCP, and TTFB into bounded per-page daily histograms | | Site map | Daily per-route rollups: entries, derived exits and bounces, time on page, and route-to-route transitions | | Journey replay | Bounded pageview/click/custom-event timelines; no DOM, keystroke, or form-value recording | | Payment conversion tracking | Trusted server-side ledger with idempotent event IDs and canonical site currency | | Goals and funnels | Exact pageview/custom-event goals and ordered conversion funnels | | Affiliates | Referral attribution, trusted revenue, and commission accounting | | Cross-site analytics | Reports over up to ten authorized site IDs with a preloaded pseudonymous visitor ID | | Automatic GeoIP | Opt-in native request metadata plus IPinfo/IPWhois fallback; bounded quota and no raw-IP persistence | | Auto-captured clicks | Anchors, buttons, and data-rastro-event controls with a bounded visible label; outbound origin+path | | Bot filtering | Self-declared crawlers, unfurlers, monitors, HTTP libraries, and headless browsers rejected at ingestion | | Cookieless mode | No cookies: pseudonymous visitor id in localStorage; sessions end after 30 idle minutes or a sign-in | | Tiny tracker | 2,999 bytes raw, 1,565 bytes gzip; the test suite enforces < 1,700 bytes gzip |

Quickstart

Prerequisites

  • Convex ^1.43.0 or a compatible Synapse deployment.
  • A way to authorize site administration and local report functions: the host's existing authentication provider, or, for a site with no user accounts, a fixed opaque owner ID and internal functions run with deployment credentials (see Hosts without user authentication). The browser ingestion route itself is public and origin-checked.
  • Two deployment origins recorded separately:

| Purpose | Convex Cloud example | Synapse example | | ------------------------------ | ------------------------------ | -------------------------------------- | | Functions and reactive queries | https://project.convex.cloud | https://project.synapse.example | | HTTP actions and tracker | https://project.convex.site | https://project.site.synapse.example |

Never append /rastro/tracker.js to the functions URL.

1. Install

npm install @iann29/rastro

The untagged npm latest channel is 0.6.0. Pin the exact version in the host; the changelog and the upgrading guide say what each release changes.

2. Mount the component

Merge the component into the project's existing configuration; preserve every component and environment binding already registered there.

// convex/convex.config.ts
import { defineApp } from "convex/server";
import rastro from "@iann29/rastro/convex.config.js";

const app = defineApp();
app.use(rastro, { httpPrefix: "/rastro/" });

export default app;

This creates isolated component tables and five HTTP routes:

  • POST /rastro/events — browser event ingestion
  • OPTIONS /rastro/events — CORS preflight
  • GET /rastro/tracker.js — the pre-gzipped tracker
  • GET /rastro/vitals.js — the pre-gzipped, opt-in Web Vitals module
  • GET /rastro/health — integration health

Push the component and regenerate host bindings before importing components.rastroAnalytics:

# Convex Cloud development deployment
npx convex dev

# Synapse development deployment
synapse dev --once

For production, use the product's existing release workflow. On an uncoordinated Convex project that is npx convex deploy; on Synapse use synapse release --execute when the project has a release manifest, otherwise synapse deploy. Never deploy before confirming the target.

Verify the HTTP origin independently from the functions origin:

curl -fsS https://YOUR_HTTP_ORIGIN/rastro/health

A successful health response proves that the component HTTP routes are mounted; it does not prove that a browser event was accepted.

Automatic GeoIP

Rastro uses Convex request metadata as the canonical client IP and can resolve coarse city-level coordinates on the first request of each session. GeoIP is opt-in because the configured provider receives the request IP. Declare the settings as optional host environment variables and pass them through, so each deployment decides on its own provider, token, and cap without a code change:

// convex/convex.config.ts
import { defineApp } from "convex/server";
import { v } from "convex/values";
import rastro from "@iann29/rastro/convex.config.js";

const app = defineApp({
  env: {
    RASTRO_GEOIP_PROVIDER: v.optional(
      v.union(v.literal("ipinfo"), v.literal("ipwhois")),
    ),
    RASTRO_GEOIP_TOKEN: v.optional(v.string()),
    RASTRO_GEOIP_DAILY_LIMIT: v.optional(v.string()),
    RASTRO_TRUST_PROXY: v.optional(v.literal("true")),
  },
});
app.use(rastro, {
  httpPrefix: "/rastro/",
  env: {
    RASTRO_GEOIP_PROVIDER: app.env.RASTRO_GEOIP_PROVIDER,
    RASTRO_GEOIP_TOKEN: app.env.RASTRO_GEOIP_TOKEN,
    RASTRO_GEOIP_DAILY_LIMIT: app.env.RASTRO_GEOIP_DAILY_LIMIT,
    RASTRO_TRUST_PROXY: app.env.RASTRO_TRUST_PROXY,
  },
});

export default app;

This is the shape of the example host. With nothing set, provider lookups stay off. To enable IPinfo Core on one deployment:

# Convex Cloud development deployment
npx convex env set --deployment dev RASTRO_GEOIP_PROVIDER ipinfo
npx convex env set --deployment dev RASTRO_GEOIP_TOKEN 'YOUR_IPINFO_TOKEN'
npx convex env set --deployment dev RASTRO_GEOIP_DAILY_LIMIT 1000

# Synapse development deployment
synapse convex --dev env set RASTRO_GEOIP_PROVIDER ipinfo
synapse convex --dev env set RASTRO_GEOIP_TOKEN 'YOUR_IPINFO_TOKEN'
synapse convex --dev env set RASTRO_GEOIP_DAILY_LIMIT 1000

Binding a literal instead of a passthrough (RASTRO_GEOIP_PROVIDER: "ipinfo") is also valid when every deployment of the project shares the provider.

ipwhois is also supported. Its token is optional for the documented 1,000-request/day free endpoint and recommended for production. Bind it as RASTRO_GEOIP_TOKEN in the same way as the IPinfo example. RASTRO_GEOIP_DAILY_LIMIT defaults to 1,000 lookups per site per UTC day and may be set from 0 through 1000000 to match the provider plan. Admission is transactional, so concurrent first-session requests cannot exceed the cap.

When Rastro runs behind a reverse proxy that supplies X-Forwarded-For or Vercel/Cloudflare geographic headers, set RASTRO_TRUST_PROXY to true on that deployment. Leave it unset for direct Convex deployments. Rastro uses forwarded data only when this opt-in is enabled and the runtime IP identifies a private immediate peer. Caller-supplied proxy or geographic headers are ignored when native metadata is absent or public.

RASTRO_CLIENT_RATE_LIMIT is for load-test canaries only. Set to off, it lifts the per-client ingest budget (see Operational limits), so a benchmark driver sending every request from one address is bounded by the per-site shards alone. Bind it like the others: RASTRO_CLIENT_RATE_LIMIT: v.optional(v.literal("off")) in defineApp and RASTRO_CLIENT_RATE_LIMIT: app.env.RASTRO_CLIENT_RATE_LIMIT in app.use, as the example host does. Leave it unset on every customer deployment.

The lookup has a 1.5-second timeout and fails open: telemetry ingestion continues without location when the provider is unavailable. A session reserves its single lookup attempt atomically only after its initial event is persisted; heartbeats and concurrent duplicate requests do not consume provider quota. The raw IP is never passed to a mutation or stored in component tables; only sanitized country, city, and coordinates rounded to one decimal place are persisted.

GET /rastro/health reports whether the runtime exposes a usable client IP and whether GeoIP is configured and the daily cap, without returning the address or token. Deployment administrators can exercise the configured provider through the internal geo:probe action. If health reports no usable client IP, use trusted Vercel/Cloudflare geographic headers with RASTRO_TRUST_PROXY or fix the deployment's proxy forwarding. Health also names the build the deployment serves under tracker: the package version plus the content hash of tracker.js and the vitalsHash of vitals.js. Both scripts carry that hash as a strong ETag, answer If-None-Match with 304, and are cached for five minutes with up to one hour of stale-while-revalidate, so a returning visitor runs a new build after at most one stale page load; an open tab keeps its script until it reloads.

Browser and device classification

The tracker sends no client identification. Ingestion derives browser, os, and device from headers the browser attaches itself, and persists only those three normalized values; it also reads the in-app browser's platform id (instagram, facebook, tiktok…) for the session's origin, below. The raw User-Agent never reaches a mutation or a component table.

Chromium attaches the low-entropy Sec-CH-UA, Sec-CH-UA-Mobile, and Sec-CH-UA-Platform client hints to cross-origin requests from secure origins, with no Accept-CH opt-in required of the tracked site. They are preferred when present, because they are the only signal separating Brave, Opera, Vivaldi, Samsung Internet, Yandex, and bare Chromium from Chrome: Brave ships Chrome's User-Agent verbatim by design. Hint brands are allowlisted, so an unrecognized or forged brand falls back to the User-Agent instead of entering the browser dimension verbatim.

The User-Agent remains the fallback for Firefox and Safari, which implement no client hints, and for insecure origins, which receive none. Known limits of that path:

  • iPadOS 13+ requests sites with a Macintosh User-Agent by default and Safari sends no hints, so those sessions are reported as desktop macOS.
  • Without hints every Android client is reported as mobile; Sec-CH-UA-Mobile: ?0 on the Android platform is the only available tablet signal, because the explicit Sec-CH-UA-Form-Factors hint is high entropy and would require an Accept-CH opt-in and a permissions-policy delegation from every tracked site.
  • Known bots are rejected at ingestion by User-Agent: self-declared crawlers, link unfurlers, uptime monitors, HTTP libraries, headless browsers, and any agent carrying a URL. The response keeps the usual counters with rejected set and adds X-Rastro-Rejected: bot; overview metadata reports botDetection as userAgent. A scripted browser that spoofs a stock User-Agent is still counted as an ordinary session.

3. Create a site

Components cannot read the host app's authentication state. The host must authenticate the caller and pass an opaque owner ID.

// convex/analyticsAdmin.ts
import { mutation } from "./_generated/server";
import { components } from "./_generated/api";
import { Rastro } from "@iann29/rastro";
import { v } from "convex/values";

const analytics = new Rastro(components.rastroAnalytics);

export const createSite = mutation({
  args: {
    name: v.string(),
    domains: v.array(v.string()),
    currency: v.string(),
  },
  returns: v.string(),
  handler: async (ctx, args) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    return await analytics.createSite(ctx, {
      ownerId: identity.tokenIdentifier,
      name: args.name,
      domains: args.domains,
      currency: args.currency,
      cookieless: true,
    });
  },
});

Call this mutation from the host application's authenticated administration UI and persist or display the returned siteId:

const createSite = useMutation(api.analyticsAdmin.createSite);
const siteId = await createSite({
  name: "Amage Product",
  domains: ["product.example.com", "localhost"],
  currency: "BRL",
});

Do not call the authenticated example with a bare CLI command: without an explicit test identity, ctx.auth.getUserIdentity() correctly returns null.

Allowed domains are checked at ingestion. Exact hosts and wildcard subdomains such as *.example.com are supported. The Origin check is a browser control, not an anti-abuse one: a browser cannot forge the header, but any other sender writes the one it likes, and the site ID is in every page's source. Abuse is bounded by event and byte budgets per session, per client network, and per site (see Operational limits).

Domain entries are hostnames, not host-and-port pairs. Use localhost or a full origin such as http://localhost:3000; both normalize to the localhost host. A site deployed on Vercel needs its preview hosts too: list *.vercel.app next to the production host, or preview deployments are rejected as ORIGIN_NOT_ALLOWED.

Hosts without user authentication

An institutional site with no user accounts has nobody to authenticate. Use one fixed opaque owner ID and internal functions, which only deployment credentials can run:

// convex/analyticsAdmin.ts
import { v } from "convex/values";
import { Rastro } from "@iann29/rastro";
import { components } from "./_generated/api";
import { internalMutation } from "./_generated/server";

const analytics = new Rastro(components.rastroAnalytics);

// Opaque and stable. Reuse the same value as `ownerId` when provisioning a
// federation grant.
const SITE_OWNER_ID = "acme-site-owner";

export const createSite = internalMutation({
  args: {
    name: v.string(),
    domains: v.array(v.string()),
    currency: v.string(),
  },
  returns: v.string(),
  handler: async (ctx, args) =>
    await analytics.createSite(ctx, {
      ownerId: SITE_OWNER_ID,
      name: args.name,
      domains: args.domains,
      currency: args.currency,
      cookieless: true,
    }),
});

Run it once per site with the deployment's own credentials, never from a browser:

# Convex Cloud development deployment
npx convex run --deployment dev analyticsAdmin:createSite \
  '{"name":"Acme","domains":["acme.example","*.vercel.app"],"currency":"BRL"}'

# Synapse development deployment
synapse convex --dev run analyticsAdmin:createSite \
  '{"name":"Acme","domains":["acme.example","*.vercel.app"],"currency":"BRL"}'

Such a host needs no local report functions: the central dashboard reads the federated surface, and its auth.config.ts lists only the Rastro federation provider, exactly as the example host does. Goals, funnels, affiliates, and retention policies are configured the same way, through internalMutations that call the Rastro class with SITE_OWNER_ID.

4. Add the tracker

Use the HTTP-actions URL for your deployment, not the Convex function URL.

<script
  defer
  data-site="YOUR_SITE_ID"
  src="https://YOUR_DEPLOYMENT.convex.site/rastro/tracker.js"
></script>

On Synapse the equivalent URL is:

https://YOUR_DEPLOYMENT.site.YOUR_SYNAPSE_DOMAIN/rastro/tracker.js

The endpoint is inferred from the script URL. Override it only when a proxy serves the script and ingestion from different origins:

<script
  defer
  data-site="YOUR_SITE_ID"
  data-endpoint="https://analytics.example.com/rastro/events"
  src="https://cdn.example.com/rastro/tracker.js"
></script>

In a Next.js App Router layout, next/script injects the same tag; document.currentScript is set when it runs, so data-site and the inferred endpoint work exactly as with a plain tag:

// app/layout.tsx
import Script from "next/script";

export default function RootLayout({ children }) {
  return (
    <html lang="pt-BR">
      <body>
        {children}
        <Script
          src="https://YOUR_DEPLOYMENT.convex.site/rastro/tracker.js"
          data-site="YOUR_SITE_ID"
          strategy="afterInteractive"
        />
      </body>
    </html>
  );
}

Remember the site's domains: a Vercel preview runs on *.vercel.app, not on the production host.

Field Web Vitals (optional)

A second, separate module measures real-visitor LCP, CLS, INP, FCP, and TTFB with PerformanceObserver and reports them once per page load, when the tab is first hidden. It stays out of tracker.js so the base tracker keeps its byte budget; add it only where you want performance data:

<script
  defer
  data-site="YOUR_SITE_ID"
  src="https://YOUR_DEPLOYMENT.convex.site/rastro/vitals.js"
></script>

The module derives the tracker's session and visitor ids by the same rule, from sessionStorage and localStorage, whichever script loads first, and accepts the same data-endpoint and data-visitor overrides. It needs the base tracker on the page: vitals annotate the session the tracker opened, and ingestion rejects them for a session it never saw. Measurements are attributed to the landing path of the page load, never to later SPA routes. Vitals feed only the vitalsReport histograms — analytics event counts, sessions, and live presence do not change. Browsers without an entry type simply skip that metric (Safari reports no LCP, CLS, or INP).

React

React is an optional peer dependency. It is required only when importing the @iann29/rastro/react entry point; backend-only consumers do not need it.

import { useState } from "react";
import { RastroScript, useRastro } from "@iann29/rastro/react";

export function Analytics({ siteId, siteUrl, analyticsId }) {
  const [ready, setReady] = useState(false);
  const rastro = useRastro();

  return (
    <>
      <RastroScript
        siteId={siteId}
        src={`${siteUrl}/rastro/tracker.js`}
        visitorId={analyticsId}
        onLoad={() => setReady(true)}
      />
      <button onClick={() => ready && rastro.track("signup", { plan: "pro" })}>
        Start trial
      </button>
    </>
  );
}

Supply a server-issued visitorId before the first pageview when available. For login after tracking starts, use the identify command described in the identity guide; it closes the previous session before switching IDs. Call reset on logout. Released 0.5.x trackers require a reload to change the initial ID.

5. Verify ingestion

  1. Open an allowed site origin in a fresh browser tab.
  2. In browser developer tools, filter Network requests by rastro/events.
  3. Wait for the normal tracker flush, which is debounced by approximately 1.2 seconds.
  4. Confirm POST /rastro/events returns HTTP 200 and a response containing accepted, duplicates, and rejected counters.
  5. Run analytics:liveVisitors (with siteIds and now) or analytics:sessionJourney for the returned siteId: both reflect the request as soon as it is accepted. analytics:listSessions and the aggregate-backed analytics:overview can trail the live view by a few seconds, and the overview only reports complete UTC hour/day buckets, so an empty result from those two right after the first request is not a failed ingestion.

The tracker intentionally does not interrupt the product UI when transport fails. A loaded tracker.js or successful /health response alone is not proof of ingestion. The usual causes of a rejected or missing request are:

  • using the functions URL instead of the HTTP-actions URL;
  • omitting the exact browser host from the site's domains, including *.vercel.app for preview deployments;
  • including query strings or paths in a configured base URL;
  • querying a partial UTC bucket or a site the authenticated user does not own;
  • reading listSessions or overview seconds after the first request instead of liveVisitors.

Since 0.1.0 a live row also carries the intent facts of its session: entryPath, returning (the visitor had a history before this session; a host id linked to anonymous history at sign-up is a return), utmCampaign, affiliateSlug, conversionCount, and the furthest funnel step the visitor reached ({ funnelId, step, steps }). visitorHeat from the package turns those, plus the pages that complete a goal or a funnel step, into the hot/warm/cold score the dashboard's live view shows; a host may apply the same rules to its own signals. Rows written before the upgrade lack the facts and score from what they have.

Track custom events

window.rastro("event", "signup", { plan: "pro" });

For declarative click capture:

<button data-rastro-event="pricing_cta" data-rastro-label="hero">
  View pricing
</button>

Auto-captured clicks record the control's visible text, bounded to 64 characters and whitespace-normalized, so journeys read "Clicked «View pricing»" without annotation. data-rastro-label replaces that text; use it on controls whose copy may contain personal data. Input values are never read. Outbound links keep their origin and pathname; internal links keep the pathname.

Properties accept bounded string, number, boolean, or null values. Never send emails, names, form values, secrets, or other personal data as analytics properties.

Browser telemetry exact retries are idempotent within (siteId, sessionId, event-minute, eventId). Reusing an event ID in another session or with a timestamp in another minute is allowed. The tracker preserves timestamps when retrying. Trusted payment event IDs remain separately payload-bound by the site's financial ledger.

Attribute campaigns

The tracker reads utm_source, utm_medium, utm_campaign, utm_term, and utm_content from the landing URL, keeps them in sessionStorage for the rest of the tab — a later session the tab opens after a sign-in or 30 idle minutes keeps them too — and sends them as properties of every pageview. Besides those five, the only thing the landing URL ever yields is the name of a click-ID key it carried (below); no other query parameter, and no query value at all, ever leaves the page. A session's source is the lowercase utm_source when present and the referrer host otherwise, so campaign traffic no longer reads as direct. utmSource, utmMedium, and utmCampaign stay on the session for journeys; overview.topCampaigns ranks utm_campaign and overview.topMediums the lowercase utm_medium next to topSources, by sessions once the range is covered by the counters. overview.topOutbound ranks the hosts outbound clicks reached — the host alone, never the path or query — by clicks. Both lists are recorded from metadata.dimensionsSince (each site's first ingest on 0.1.0 plus the rollup delay); a range that starts earlier ranks them from partial data, and the dashboard says so.

A session also keeps where it came from, classified once on its first touch and never guessed: platform (an id from a closed, versioned table of about forty — instagram, google, whatsapp, chatgpt — or unknown for a host or utm_source outside it, or none), channel (direct, organic_search, paid_search, organic_social, paid_social, messaging, email, affiliate, ai, display, referral, offline), and evidence, the rule that decided it. One rule decides and the next only runs when it did not: utm_source through the alias table, then click-ID names, then the referrer host by registrable domain, then the in-app browser, then an affiliate ref, which sets the channel and keeps the platform an earlier rule found. Hosts match by whole label, never by substring, so l.instagram.com, instagram.com and com.instagram.android are one platform while docs.google.com is not Google; utm_medium picks the channel from a closed table that also accepts the channel ids themselves, which is what a tracked link (below) emits, offline included. Nothing at all is direct, the only way to be direct, and a referrer on the site's own domains is an internal hop, never an origin. overview.topPlatforms, topChannels and topEvidence rank them beside topSources; a topPlatforms value is the platform id alone when the session arrived by that platform's default channel and platform@channel when it did not (google@paid_search), so one platform ranks once per channel it was reached by — splitPlatformDimension reads the pair back. dataCoverage's origin dataset says from when a site carries them; sessions older than that are classified on read and marked evidence: "legacy". source keeps the raw utm_source or host for the drill-down, exactly as before.

The tracker reads the click-ID keys of its landing URL and sends them in clid: their names only (fbclid, gclid, ttclid, mc_cid…), never their values, so no advertising identifier leaves the page. It matches the same allowlist the server does, in the same ranking order, and sends at most eight; ingestion drops any name outside the allowlist and keeps at most eight of what is left. The referrer it sends is scheme and host, so a referrer of the form android-app://<package> keeps the package alone and the app that opened the link is a platform (com.google.android.gm is Gmail) instead of nothing.

Tracked links

Some traffic arrives with nothing to read. A link tapped inside WhatsApp, Telegram, an SMS or a native e-mail app, or scanned from a QR code, opens with no referrer and no campaign, and any analytics tool files it as direct. A tracked link closes that gap: the dashboard's Links tab (or the host API) creates /rastro/l/<slug> for one place the link will be published, and every click lands on the site with that place in its utm_*.

GET /rastro/l/<slug> answers 302 with Cache-Control: no-store to the link's destination, after appending:

| Parameter | Value | | -------------- | ------------------------------------------------------------------------------------------------------------------- | | utm_source | the platform: instagram, whatsapp, qr, or any id of 2–24 lowercase letters, digits or underscores | | utm_medium | the channel: organic_social, paid_social, messaging, email, affiliate, display, referral or offline | | utm_campaign | the campaign, or the slug when the link has none | | utm_content | the slug |

The destination's own query stays exactly as it was, and a utm_* key it already carries is never overridden. Unknown and disabled slugs answer 404 in plain text. The tracker needs nothing new: the landing reads the utm_* like any campaign link.

  • Clicks and bots. Each click lands in a daily counter spread over four shards, so a link that goes viral never serializes on one document. A self-declared bot, including the WhatsApp, Facebook and Telegram link previews, is still redirected but counted under bots, never clicks. listTrackedLinks reports both, all time and over the 30 UTC days ending on the day of the now it is given.
  • No open redirect. A destination must be an absolute http(s) URL without credentials on one of the site's domains (exact, or a *. pattern for subdomains). The rule is checked when a link is created or changed and again on every click, so a domain removed from the site stops redirecting at once.
  • Slugs are 3–32 lowercase letters, digits or hyphens, starting and ending with a letter or digit, and unique across the deployment because the route names no site. A site keeps at most 200 links. Deleting a link frees its slug and removes its counters; disabling it keeps both.
  • A short domain. The route lives on the deployment's HTTP origin (https://<deployment>.convex.site/rastro/l/promo). A host that wants a presentable link serves that origin under a short domain of its own, such as go.example.com/rastro/l/promo; nothing in the component changes.
await analytics.createTrackedLink(ctx, {
  siteId,
  slug: "bio-instagram",
  destination: "https://www.example.com/promo",
  platform: "instagram",
  channel: "organic_social",
  campaign: "spring-sale",
  createdBy: userId, // an opaque id, never an e-mail address
});

A federated dashboard reaches the same functions once the host re-exports listTrackedLinks, createTrackedLink, updateTrackedLink, and deleteTrackedLink from convex/rastroFederation.ts. The connection then advertises the trackedLinks capability: any reader lists the links, and changing them needs analytics:configure, like goals and affiliates.

Leads from the host's server

A lead that came from a Click-to-WhatsApp ad never touched the site: the person tapped the ad and landed in a chat. No tracker event exists for it, so no browser-side report can see it. A host that receives those conversations on its server (a CRM, a WhatsApp inbox) reports them itself, and Rastro counts them by where they came from and whether they turned into sales.

  • One lead per conversation. recordLead takes the host's own opaque id for the conversation as leadKey, never a phone number. It is idempotent: the first touch wins, and a repeat call only fills a short label or the web visitorId a tracked link bridged.
  • Where it came from. entryPoint is the channel's raw entry point (WhatsApp's ctwa_ad, click_to_chat_link, global_search_new_chat, status…), and ad the Meta ad the first message carried: adId, sourceApp, sourceUrl (kept as origin and path), ctwaClid and title. Any ad files the lead under the ctwa_ad origin. The click id stays in the host's deployment; no report returns it.
  • Stages. setLeadStage moves a lead to qualified, won (with the sale value in the site's currency; a repeat corrects it), lost, or back open, idempotent by eventId. A lead never recorded returns applied: false rather than being invented. Counting is by cohort: every stage lands on the UTC day the lead first arrived, so a campaign reads "of the leads it brought, how many qualified and bought". Undoing a sale takes it back out.
  • Campaign names. WhatsApp only carries the ad id. upsertAdCatalog stores what the host reads from the Meta Marketing API (ad, ad set and campaign names and ids, a thumbnail), and leadsReport rolls ads up into campaign → ad set → ad. Ads not named yet group under a null campaign.
  • Deletion. removeLead forgets a lead and its history and takes it out of every count, for the host's own deletions.
// In the handler for a new inbound conversation:
await analytics.recordLead(ctx, {
  siteId,
  leadKey: conversationId, // the host's id, never a phone number
  channel: "whatsapp",
  entryPoint: "ctwa_ad",
  ad: { adId, sourceApp: "instagram", sourceUrl, ctwaClid },
  label: "Marina C.",
  occurredAt: message.timestamp,
});

// When the pipeline qualifies it, and when the sale is registered:
await analytics.setLeadStage(ctx, {
  siteId,
  leadKey: conversationId,
  stage: "won",
  revenueCents: 149_000,
  eventId: `sale:${saleId}`,
  occurredAt: Date.now(),
});

A federated dashboard reads leadsReport, listLeads and leadJourney once the host re-exports them from convex/rastroFederation.ts and opts in with leads: true in exposeFederatedAnalyticsApi. The connection then advertises the leads capability and the dashboard shows the Campanhas view; any reader may open it. A host that does not opt in refuses the three reports and advertises nothing, so no empty tab appears.

leadsDaily returns the same counters for every UTC day of a range, for sparklines, and rides the same capability. With metaAds: true the host also serves metaStatus and metaConversions: which Meta ad accounts and pixel each site links, and the conversions sent back to the pixel. From 0.10.0 the lead reports carry what the linked ad accounts spent; until a site links one, spend is null, meaning unknown. See Meta ads.

With metaLeads: true (beside metaAds: true) and a form lead handler registered with setFormLeadHandler, the host also serves the Lead Ads reads and writes behind the metaLeads capability; see Meta ads, Lead Ads.

Identify people with any authentication

Rastro connects names and emails to visits, actions and conversions. Use identifyVisitorMutation with your backend's authenticated-user resolver for Better Auth, custom auth or another Convex JWT provider. Other backends use identifyVisitorHttpAction over authenticated HTTP. No auth provider is a runtime requirement of the SDK.

See the identity integration guide for the complete server/browser flow, login/logout behavior, profile updates, and examples. The dashboard presents profiles in Ao vivo and Jornadas, and the Pessoas directory searches all profiles by name, email, ID or an exact typed attribute. Attributes are optional strings, numbers and booleans defined by your project. Each person opens a history scoped to their site and the selected period. These additions require 0.6.0 or later; see upgrade notes.

Link existing visitor IDs

Anonymous sessions carry a random visitorId until the host supplies a pseudonymous one through data-visitor or the RastroScript visitorId prop. To keep the anonymous history when a visitor creates an account or signs in, link the two ids from the trusted server path, never from the browser:

const { visitorId: anonymousVisitorId } = window.rastro("context");
// Send anonymousVisitorId with the signup or sign-in request.
await analytics.linkVisitor(ctx, {
  siteId,
  visitorId: user.analyticsId, // the value later passed to the tracker
  previousVisitorId: anonymousVisitorId,
});

visitorJourney for either id then returns one chronological journey; every event keeps the id it was recorded with. A session belongs to one identity: the first page rendered with the new data-visitor opens a new session in the same tab, carrying the referral and campaign over, and the link joins both sessions in the journey. Links are idempotent, an anonymous id belongs to at most one identity, a linked id cannot be linked again, chains and identity merges are rejected, and an identity holds at most ten aliases. The tracker still sets no cookie: the host's own session identifies the account and the anonymous ids live in the browser's own storage (the visitor id in localStorage, the session id in sessionStorage). Linking anonymous history to an account turns it into personal data under the host's privacy policy; disclose it and offer an opt-out.

Attribute trusted payments

Browser-created conversion events are untrusted telemetry and never affect financial revenue or affiliate commission. Financial accounting only happens through Rastro.trackConversion in an authenticated server mutation or action.

Capture the session context before starting checkout:

const attribution = window.rastro("context");
// Send attribution.sessionId and attribution.visitorId with checkout metadata.

Then account for the provider event from your verified webhook path:

await analytics.trackConversion(ctx, {
  siteId,
  eventId: stripeEvent.id,
  sessionId: metadata.rastroSessionId,
  visitorId: metadata.rastroVisitorId,
  timestamp: Date.now(),
  revenueCents: invoice.amount_paid,
  currency: invoice.currency.toUpperCase(),
  path: "/checkout/success",
});

The event ID is idempotent. Currency must match the site's canonical currency. Mixed-currency multi-site overviews are rejected instead of summing unrelated cent units.

Affiliate attribution uses the first valid touch until its configured expiry. Amage Rastro stores immutable affiliate identity and timestamps in a bounded visitor attribution record, inherits it into later sessions for the same pseudonymous visitor, and computes trusted commission by affiliate ID rather than a mutable slug. A new valid touch may replace it only after expiry.

Expose dashboard reports securely

exposeAnalyticsApi generates typed host functions, but authorization remains the host application's responsibility. Verify every requested site, not just a caller-provided owner ID.

// convex/analytics.ts
import { components } from "./_generated/api";
import { exposeAnalyticsApi, Rastro } from "@iann29/rastro";

const analytics = new Rastro(components.rastroAnalytics);

export const {
  overview,
  liveVisitors,
  getSession,
  revenueSummary,
  listSessions,
  sessionJourney,
  listConversions,
  visitorJourney,
  goalsReport,
  funnelsReport,
  affiliatesReport,
  siteMap,
} = exposeAnalyticsApi(components.rastroAnalytics, {
  authorize: async (ctx, request) => {
    const identity = await ctx.auth.getUserIdentity();
    if (!identity) throw new Error("Not authenticated");

    for (const siteId of request.siteIds) {
      const site = await analytics.getSite(ctx, siteId);
      if (!site || site.ownerId !== identity.tokenIdentifier) {
        throw new Error("Not authorized for analytics site");
      }
    }

    if (request.ownerId && request.ownerId !== identity.tokenIdentifier) {
      throw new Error("Not authorized for analytics owner");
    }
  },
});

Do not copy the unauthenticated demo boundary into production. The included demo exposes only fixed-scope read reports plus bounded, idempotent seed/live-refresh functions.

Federated dashboard backend

The reference control plane in control-plane/ can connect a central dashboard while telemetry remains in the customer's deployment. It uses exposeFederatedAnalyticsApi with issuer and connection-resolver options. The helper constructs its authorization boundary internally; it cannot accept a permissive host authorizer. Its surface contains a public protocol v1 manifest, authenticated redacted connection status, redacted site summaries, the reactive dashboard report queries, and — behind the optional configure capability — the functions that manage goals, funnels, affiliates, a site's name, domains and timezone, and the retention policy from the dashboard.

The JWT only identifies the requested connection. Site access remains authoritative in a local host record resolved on every query, so revocation is immediate and a token cannot grant itself another site. The same record decides whether the dashboard may configure: a configure function runs only when the token carries analytics:configure (the control plane claims it for organization owners and admins alone) and the host's grant lists it too; otherwise the dashboard shows the same forms but hands over the code the host runs itself. A third permission, analytics:public, lets the organization publish a read-only public link of the granted sites — the overview without revenue, conversions, goals, campaigns, or custom events, and the live visitors without identities — and, like the configure scope, it works only when the host's grant lists it. listSites accepts no site arguments and omits owner, domain, and network configuration. Stable manifest names, capabilities, limits, and authorization error codes are exported for connector clients. The host must already have Convex authentication and a local authoritative grant. Follow docs/federation-setup.md for the executable setup and verification sequence; use docs/federation.md as the protocol reference. The production control plane is available at https://www.amagerastro.com; before connecting a customer deployment, verify the selected package exports the federation helper as shown in the setup runbook.

Backend API

The Rastro class wraps the component boundary for host functions:

  • Sites: createSite, updateSite, getSite, listSites
  • Ingestion: ingest, trackConversion, linkVisitor
  • Reports: overview, liveVisitors, listSessions, sessionJourney, listConversions, visitorJourney, goalsReport, funnelsReport, affiliatesReport, vitalsReport, siteMap, dataCoverage
  • Configuration: upsertGoal, listGoals, removeGoal, upsertFunnel, listFunnels, removeFunnel, upsertAffiliate, listAffiliates, removeAffiliate
  • Tracked links: createTrackedLink, updateTrackedLink, deleteTrackedLink, listTrackedLinks
  • Leads: recordLead, setLeadStage, removeLead, upsertAdCatalog, leadsReport, listLeads, leadJourney, leadsDaily
  • Meta ads: metaStatus, metaConversions
  • Meta Lead Ads: setFormLeadHandler, metaPages, linkPage, unlinkPage, metaForms, refreshForms, setFormRules, formRules, simulateFormRules, formsReport, returnFormLead, metaLinkedPages
  • Retention: cleanup, setRetentionPolicy, disableRetentionPolicy, runRetentionPolicy, retentionStatus

Every public function has argument and return validators. Growing reads are indexed and bounded. Component pagination uses scope-bound opaque keyset cursors because native .paginate() is not supported inside Convex components; page cost stays constant instead of increasing with an offset.

siteMap reads the daily route rollups newest day first and returns per-route totals — pageviews, estimated visitors, entries, derived exits and bounces (exits = entries + transitions in − transitions out, bounces = entries − sessions that continued), and time on page — plus route-to-route transition counts and a per-route daily series. Numeric, UUID, and long hexadecimal path segments collapse to :id in the map only; the package exports normalizeRoute so dashboards bucket live visitors by the same rule, and liveVisitors reports previousPath and movedAt when a visitor moves between routes. Map coverage begins at the first ingest after the upgrade that introduced the rollups.

Since 0.7.0, sites can declare ordered routePatterns, for example:

await analytics.updateSite(ctx, {
  siteId,
  routePatterns: ["/pedido/:codigo"],
});

Patterns match the complete path, with one nonempty segment per :parameter; the first match wins. Up to 32 patterns of 256 characters are accepted. Other paths retain the existing normalization, so /kit/terra and /kit/serra stay separate. normalizeRoute(path, patterns) is shared with the dashboard, and siteMap.routePatterns returns the rules keyed by site for live overlays. The component applies patterns when ingesting map rollups and when reading retained rollups, merging visitor sketches and transition endpoints before calculating metrics. Journeys and other reports keep their original paths. Already grouped rollups cannot be split back into individual URLs by removing a pattern, and data previously discarded by retention or route caps cannot be reconstructed. No historical rewrite is required.

Configure these patterns in the dashboard's site settings with the existing analytics:configure permission. The navigation graph supports background or middle-button dragging, wheel/trackpad panning, Ctrl/Cmd-wheel zoom at the cursor, touch pan/pinch, and arrow/+/-/Home keyboard controls. It has a fixed viewport without scrollbars; Ajustar restores readable automatic framing.

Overview responses label visitor values as either HLL-estimated unique visitors or legacy bucketOccurrences; legacy aggregate rows are never presented as exact uniques. Anonymous visitors carry a persistent pseudonymous id from the tracker's localStorage, so the same person counts once across tabs, days, and returns. When storage is blocked or an old cached tracker reports no id, sessions from the same device and network within one UTC day still fold into one visitor through a salted daily key; runtimes without a client IP fall back to one visitor per session, which /rastro/health reports as uniqueVisitors: "session" instead of "dailyHash". Breakdown metadata says what each ranking counts: pages by pageviews and sources, campaigns, mediums, countries, devices, and browsers by sessions once every bucket in the range carries those counters (recorded since alpha.11), and raw event volume for ranges that reach back before them; events and outbound destinations always rank by volume. Totals carry the engagement counters recorded since the same stamp — summed session duration, sessions that reached a second pageview, and first-ever sessions of a visitor — and metadata.engagement reports counted when the whole range has them, so bounce rate, average duration, pages per session, and the share of new visitors derive from one response; unavailable means the range reaches back before the counters and those four stay blank. Feature status is explicit: outbound-link event volume is supported (with topOutbound naming the destinations), bot detection rejects self-declared agents by User-Agent, and session replay and error insights are currently unsupported.

All report ranges use integer Unix milliseconds with inclusive from and to boundaries, and range metadata reports boundary: "inclusive". Hourly overview queries must contain complete UTC hours. A daily overview query must contain either complete UTC days or, when every requested site has the same timezone, complete calendar days in that zone: a site with a timezone gets one aggregate bucket per local calendar day at ingestion, next to the UTC hour and day buckets, so "yesterday" in São Paulo ends at midnight in São Paulo. Range metadata then reports timezone as the zone the buckets follow, "UTC" otherwise. Local-day coverage starts at the site's first ingest after the upgrade (analyticsCoverage.localDaySince) and restarts when its timezone changes; a range reaching further back is reported as partial coverage, and sites.localDays (federated listSites.localDays) tells a dashboard the zone and the stamp up front. A zone that never leaves UTC keeps no extra buckets. Funnel and affiliate reports must contain complete UTC days. Those aggregate-backed reports fail with REPORT_INCOMPLETE instead of silently including the rest of a partial bucket. Goal reports retain exact millisecond filtering while bounded goal-completion rows are available.

Goal, funnel, and affiliate reports use definition-ID keyed daily rollups when the low-churn rollout control proves that generation is available. Each loader enforces day, row, document, and byte headroom before returning a complete result; it never returns a silently truncated report. Ordinary ingestion never patches or schedules work against a shared coverage document. dataCoverage derives availability through bounded indexed reads of source and rollup records, while the control document stores only rollout generation and durable retention watermarks. Heartbeats can advance session availability but do not claim event or aggregate availability. Hourly and daily overview availability are reported separately as overviewHour and overviewDay because their retention boundaries use different bucket alignment.

Operational limits

  • HTTP body: 64 KiB
  • Events per batch: 50
  • Session-minute groups per batch: 4
  • Active goals per exact matcher: 1
  • Web Vitals pages per site and UTC day: 48 tracked page rows, then (other); device classes are fixed (desktop, mobile, tablet, unknown)
  • Web Vitals report: complete reads bounded to 5,000 rollup rows across at most 90 UTC days; wider saturated ranges fail with REPORT_INCOMPLETE
  • Site map routes per site-day shard: 200 tracked route rows, then (other); 24 outgoing-transition slots per route row
  • Site map report: reads newest day first, bounded to 8,000 rollup rows across at most 90 UTC days; older days beyond the budget are declared as partial coverage instead of failing the report
  • Primary aggregate shards: 256 hourly and 128 daily, derived from stable session identity
  • Funnel and affiliate aggregate shards: 16
  • Report rollups (goal, funnel, affiliate, route, and Web Vitals daily rows) are never written by the ingestion transaction: a batch appends one document to the site's rollup queue and one leased fold job per site merges up to 200 queued batches per run into the rows, so every rollup row has a single writer and concurrent ingestion cannot conflict on it. Runs are debounced: the first batch schedules a run one second out, batches arriving while it is pending neither write the lease nor schedule anything, and the chain keeps running once a second until a run finds the queue empty. Reports see a batch's rollups about a second after ingestion accepts it; a fold chain that dies is restarted by the next batch once its 60-second lease lapses. A run that throws is rolled back and retries its oldest batch alone; a batch that fails three runs in a row is set aside as a logged dead letter (kept 30 days) so the rest of the queue keeps folding
  • Session ingress budget: 120 events and 256 KiB per session per minute
  • Client ingress budget: 1,200 events and 2 MiB per site per minute from one client network, an IPv4 address or an IPv6 /64. The window is keyed by the salted daily hash behind anonymous visitor keys, never by the address, and every visitor behind one office or carrier-grade NAT shares it. It applies when /rastro/health reports clientIpAvailable: true; RASTRO_CLIENT_RATE_LIMIT=off lifts it on a load-test canary
  • Site ingress budget: 256 deterministic per-minute shards, each capped at 1,500 events and 2 MiB, so a site admits at most 384,000 events and 512 MiB a minute. Only evenly spread load (random session IDs) reaches that; a real peak is uneven, and the realistic benchmark's ~100k events/minute tops out near 1,050 events in its hottest shard-minute
  • Rate-window storage: one reusable row per active session, client network and UTC day, and site shard
  • Live session timeout: a leave beacon when the tab hides or the page unloads retires the visitor about ten seconds later, and showing the tab again or a back/forward-cache return revives them immediately; heartbeats fire every 20 seconds only while the tab is visible, so a hidden tab sends nothing; the safety net for a browser that never says goodbye is 90 seconds since the last event or heartbeat, with one bounded five-second expiry sweep per site; legacy per-session jobs hand off to that sweep during the cutover
  • Session: one identity's visit in one tab; a new data-visitor or 30 minutes without an event opens the next one, which starts with a pageview of the current page
  • Clock alignment: the tracker and the vitals module stamp each batch with sentAt; when that clock is more than 30 seconds from the server's, every event in the batch is re-anchored by the offset before ingestion, so a visitor whose device clock is wrong still goes live and is filed under the right hour
  • Dashboard overview: up to ten sites. One overview reads at most 13,000 aggregate rows and a day holds 128 per site, so a daily range covers at most 101 days for one site, 50 for two, and 10 for ten; a wider range fails with LIMIT_EXCEEDED naming its bound. A busy range that would cross the 8 MiB a query may read fails with REPORT_INCOMPLETE first
  • Hourly overview: up to 24 hours (an hour holds 256 rows per site, so 16 hours for three sites and 5 for ten); wider ranges use daily buckets
  • Retention cleanup: bounded batches. setRetentionPolicy automatically starts leased, persisted cleanup chains for every policy-managed dataset; interrupted attempts resume after lease expiry and stale generations cannot mutate the active chain. A completed chain schedules its next cycle after one day and recomputes its cutoff from the then-current policy. Policy updates replace the generation, and disableRetentionPolicy makes every queued attempt stale. runRetentionPolicy remains available for an explicit bounded run. The events cleanup kind removes canonical event-batch documents and processes at most 32 heavy batches per transaction.
  • Retention cutoffs are bucket-safe: event minutes, hourly aggregates, daily aggregates and feature rollups preserve the bucket containing a cutoff and return the exact effective before boundary they applied.

Capacity claims use declared workload profiles. Complete release evidence reports ten-minute lean, realistic, heavy, and browser feature profiles separately. The browser feature profile exercises configured goals, funnels, and affiliate attribution; trusted conversions require a separate server-side driver and certification result.

Certified ingest evidence (2026-08-30)

| Profile | Workload | Certified stored/day | p95 latency | 100M/day target | | --------- | ------------------------------------------------- | -------------------: | ----------: | --------------- | | Lean | Minimal events, maximum batches | 178.95M | 4,167 ms | Pass | | Realistic | Reused sessions, browser mix, context, heartbeats | 142.04M | 4,953 ms | Pass | | Heavy | 62 KiB requests with bounded custom properties | 55.26M | 7,702 ms | Below target | | Feature | Goals, funnels, and affiliate attribution | failed | — | Defect #52 |

Lean, realistic, and heavy each ran at least ten minutes, completed with zero request failures, duplicates, or rejected events, and exactly reconciled the expected stored events (the realistic run's reconciliation was re-executed exactly after a client-side DNS failure — see the report). The realistic row's driver session pool is 4,096: at current speed the historical 2,048-session pool pushes single sessions past the documented 120 events/minute budget. Results came from one client process against the same non-production Synapse DEV deployment and site as the 2026-08-21 certification; they are engineering evidence, not a production SLA. Because that one client process is one address, a certification canary now also sets RASTRO_CLIENT_RATE_LIMIT=off.

Two caveats stand deliberately. The heavy row measured a store already holding the battery's own multi-million-event history; the 2026-08-22 fresh-store result (76.62M/day) remains valid for that state, and neither number overwrites the other. The feature profile is not certified: on alpha.6, affiliate daily-rollup contention lost ~4–5% of batches under concurrent load (#52). Since alpha.11 ingestion no longer writes rollup rows at all (see the operational limits), but no feature-profile capacity is claimed until a new battery certifies it. GeoIP with a provider enabled, trusted server-side conversions, and vitals-bearing traffic remain separate pending certifications.

The full evidence is recorded in docs/benchmarks/2026-08-30-alpha6-recertification.md. Prior results remain in docs/benchmarks/2026-08-21-formal-certification.md and docs/benchmarks/2026-08-20-realistic.md.

From a repository checkout, run a duration-based profile against a confirmed non-production deployment and a dedicated idle benchmark site:

For a deployment-free CI smoke benchmark of write-heavy ingestion plus reactive overview and feature report queries, run:

npm run benchmark:local

The local harness uses convex-test, enforces a 12-document ingest transaction budget, checks reactive totals before and after a write, and queries proven daily rollups with the maximum configured 50 goals, 20 funnels, and 100 affiliates under deterministic 1,000-document and 4 MiB read ceilings. It also drives concurrent ingest calls and asserts that their shared rollout/retention control document is unchanged. It fails when its generous CI latency budgets are exceeded. This is a deterministic local regression guard, not evidence of live deployment throughput or capacity.

npm run --silent benchmark:ingest -- \
  --url https://example.site/rastro/events \
  --convex-url https://example.convex-backend \
  --site-id your_site_id \
  --origin https://your-allowed-origin.example \
  --profile realistic \
  --duration 600 \
  --concurrency 75 \
  --batch-size 50 \
  --certify

Profiles are lean, realistic, heavy, and feature. The realistic profile reuses sessions and sends geographic and user-agent headers so the same load is visible in Live Atlas. Only feature intentionally matches the demo goal, funnel, and affiliate definitions; it does not call the trusted conversion API. Use --dry-run to inspect generated request size without sending traffic. When --convex-url is supplied, it must identify the same deployment as --url and expose this repository's benchmark:reconcileSessionsPage query. Reconciliation generates exact session IDs from a cryptographic run UUID and reads them through bounded index lookups, so unrelated traffic cannot alter the result. The query uses the session counters persisted transactionally with event batches; it certifies ingest accounting, not raw-event availability after later retention. Reports distinguish accepted ingress, heartbeats, and stored events. Shorter completed runs report extrapolated daily estimates only. --certify requires at least ten minutes and successful reconciliation before emitting certified daily capacity fields or exiting successfully as a certification.

The ingestion transaction folds metric/dimension deltas, session rate counters, site-shard counters, session/live state, visitor attribution, and exact funnel progress before writing. Accepted non-heartbeat telemetry from one request is grouped into one immutable raw document per session and event-minute, without duplicating session geo or user-agent context. A 50-event same-session, same-minute baseline stays within 10 document writes plus the rollup queue entry and its fold lease. Each touched aggregate bucket is written at most once per batch, and dimension overflow folds into an explicit (other) bucket instead of relabeling old counts. Goal, funnel, affiliate, route, and Web Vitals rollup rows are written only by the site's fold job, never by ingestion. Click/outbound/browser-conversion telemetry skips goal and funnel work that cannot match those event types.

Raw telemetry is stored only in immutable eventBatches. Migrated events retain their original per-event browser and geo context inside those batches, while new events hydrate context from their session. Journey _id values are stable strings derived from batch and nested-event identity, and session-journey cursors use timestamp, creation time, and ID.

Existing deployments that still contain the retired events table must not upgrade directly to this cutover. Follow the exact intermediate release and verification sequence in docs/upgrading.md. Fresh deployments need no legacy-event migration.

Privacy contract

Amage Rastro's default tracker:

  • sets no cookies;
  • stores a pseudonymous session ID, the visitor ID it opened with, the time of its last event, and the referral slug and campaign in sessionStorage, and a persistent pseudonymous visitor ID in localStorage — scoped to the site's origin, never shared across sites, gone when the visitor clears site data;
  • never persists raw visitor IP addresses in component tables;
  • counts unique visitors by that persistent pseudonymous ID; when storage is blocked or the id is absent, a salted hash of the request IP and User-Agent that rotates every UTC day takes over, derived in the HTTP action from a per-deployment secret; a host-supplied visitorId always takes precedence, and the raw IP never reaches a mutation or a table;
  • never persists the raw User-Agent, only a normalized browser, operating system, and device;
  • processes an IP only when an edge supplies geography or an operator enables a GeoIP provider, which receives that IP under its own privacy terms;
  • stores only country, city, and coordinates rounded to one decimal place;
  • does not record the DOM, screenshots, keystrokes, form values, input contents, or arbitrary page text;
  • captures only bounded paths, explicit properties, the visible label of a clicked link or button (at most 64 characters, replaced by data-rastro-label when present), and outbound link origins and pathnames (the overview ranks destinations by host alone);
  • strips query strings and fragments from stored paths and links; of the landing query it keeps the five utm_* values and the bare names of the advertising click-ID keys it recognizes (fbclid, gclid…), never a click-ID value, and never any other parameter;
  • sends the referrer as scheme and host only — an android-app://<package> included — never its path or query.

The optional Web Vitals module inherits this contract: it reports at most five timing numbers per page load (milliseconds, or CLS scaled by 1000) against the landing path, and nothing else.

Cookieless does not automatically mean consent-exempt in every jurisdiction, and the persistent visitor ID is an identifier under most privacy laws. Review your collection purpose, custom properties, identity strategy, retention policy, and local law.

The example Live Atlas uses MapLibre with OpenFreeMap Positron. Map tiles are requested by the dashboard viewer; Amage Rastro visitor/session data is rendered locally and is never appended to provider requests. Configure VITE_RASTRO_MAP_STYLE_URL to use your own compatible style endpoint.

Development

This repository's dev deployment runs on Synapse:

npm install
synapse select
synapse dev --once
npm run dev:frontend

The root convex.json targets the control plane in control-plane/convex. The reference consumer host in example/ is a separate Convex app; see example/README.md to run, seed, and pair it.

Quality gates:

npm test
npm run typecheck
npm run lint