@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.
Maintainers
Readme
Amage Rastro
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.

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.0or 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/rastroThe 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 ingestionOPTIONS /rastro/events— CORS preflightGET /rastro/tracker.js— the pre-gzipped trackerGET /rastro/vitals.js— the pre-gzipped, opt-in Web Vitals moduleGET /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 --onceFor 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/healthA 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 1000Binding 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-Agentby 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: ?0on the Android platform is the only available tablet signal, because the explicitSec-CH-UA-Form-Factorshint is high entropy and would require anAccept-CHopt-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 withrejectedset and addsX-Rastro-Rejected: bot; overview metadata reportsbotDetectionasuserAgent. A scripted browser that spoofs a stockUser-Agentis 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.jsThe 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
- Open an allowed site origin in a fresh browser tab.
- In browser developer tools, filter Network requests by
rastro/events. - Wait for the normal tracker flush, which is debounced by approximately 1.2 seconds.
- Confirm
POST /rastro/eventsreturns HTTP 200 and a response containingaccepted,duplicates, andrejectedcounters. - Run
analytics:liveVisitors(withsiteIdsandnow) oranalytics:sessionJourneyfor the returnedsiteId: both reflect the request as soon as it is accepted.analytics:listSessionsand the aggregate-backedanalytics:overviewcan 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.appfor 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
listSessionsoroverviewseconds after the first request instead ofliveVisitors.
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, neverclicks.listTrackedLinksreports both, all time and over the 30 UTC days ending on the day of thenowit 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 asgo.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.
recordLeadtakes the host's own opaque id for the conversation asleadKey, never a phone number. It is idempotent: the first touch wins, and a repeat call only fills a shortlabelor the webvisitorIda tracked link bridged. - Where it came from.
entryPointis the channel's raw entry point (WhatsApp'sctwa_ad,click_to_chat_link,global_search_new_chat,status…), andadthe Meta ad the first message carried:adId,sourceApp,sourceUrl(kept as origin and path),ctwaClidandtitle. Any ad files the lead under thectwa_adorigin. The click id stays in the host's deployment; no report returns it. - Stages.
setLeadStagemoves a lead toqualified,won(with the sale value in the site's currency; a repeat corrects it),lost, or backopen, idempotent byeventId. A lead never recorded returnsapplied: falserather 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.
upsertAdCatalogstores what the host reads from the Meta Marketing API (ad, ad set and campaign names and ids, a thumbnail), andleadsReportrolls ads up into campaign → ad set → ad. Ads not named yet group under a null campaign. - Deletion.
removeLeadforgets 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/healthreportsclientIpAvailable: true;RASTRO_CLIENT_RATE_LIMIT=offlifts 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
leavebeacon 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-visitoror 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_EXCEEDEDnaming its bound. A busy range that would cross the 8 MiB a query may read fails withREPORT_INCOMPLETEfirst - 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.
setRetentionPolicyautomatically 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, anddisableRetentionPolicymakes every queued attempt stale.runRetentionPolicyremains available for an explicit bounded run. Theeventscleanup 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
beforeboundary 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:localThe 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 \
--certifyProfiles 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 inlocalStorage— 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-Agentthat rotates every UTC day takes over, derived in the HTTP action from a per-deployment secret; a host-suppliedvisitorIdalways 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-labelwhen 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:frontendThe 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