@fraios/inbox-client
v1.1.6
Published
First-party JavaScript / TypeScript client for the fraios ingestion service. Sends analytics envelopes to the canonical /inbox/{source} path for server-side Semantic Event normalization. Preserves the core @jitsu/js call surface while using fraios routing
Downloads
380
Readme
@fraios/inbox-client
Analytics client for the fraios ingestion service. Sends events from
browsers, Node and edge runtimes to /inbox/{source}, where the service normalizes them into
the canonical Semantic Event structure.
import { FraiosAnalytics } from "@fraios/inbox-client";
const analytics = new FraiosAnalytics({
token: process.env.INBOX_TOKEN,
});
await analytics.track("Order Completed", {
order_id: "ORD-1183",
revenue: 149.5,
currency: "EUR",
});That is the whole integration. In a browser the client also fills page, campaign, screen, user-agent and campaign context on every event — see What gets captured.
- No runtime dependencies.
zodis optional, and only for validation. - Six runtimes — browser, Node, Bun, Deno, Cloudflare Workers, Vercel Edge.
- Credential caching, typed errors and offline queueing handled for you.
- Preserves the
@jitsu/jscall surface, so migration is a constructor change.
Install
npm install @fraios/inbox-client
# or: pnpm add @fraios/inbox-client / yarn add @fraios/inbox-clientOr load it in the browser with no build step:
<script type="module">
import { FraiosAnalytics } from "https://inbox.fraios.dev/p.module.js";
</script><script src="https://inbox.fraios.dev/p.js"></script>
<script>
const analytics = new window.FraiosAnalytics({
token: "eyJhbGciOiJIUzI1NiJ9...", // browser-class Ingress Token, origin-pinned
});
</script>/p.module.js (ESM) and /p.js (classic, global FraiosAnalytics) serve the currently
deployed client and are the supported browser URLs; /s/lib.js is a legacy alias of the classic
build. To pin a version, install from npm and bundle it — the /clients/js/vX.Y.Z/ paths
are aliases, not per-release snapshots.
Content blockers may classify analytics bundles or collector hostnames as tracking and stop the request inside the browser. Test the hosted URLs with the privacy extensions your audience uses; if delivery is business-critical, serve through a first-party path on your own origin.
Public, deploy-matched guide: inbox.fraios.dev/docs/client-js.
Sending events
Every send method takes the same three arguments: a subject, a payload, and optional envelope overrides.
analytics.track(event, properties, options);
// ↑ ↑ ↑
// 1 2 3| # | Argument | What it is |
| --- | ------------ | ------------------------------------------------------------------------- |
| 1 | event | The business fact — "Order Completed". |
| 2 | properties | Your data about this event. |
| 3 | options | Not more data — overrides for fields the client would otherwise fill. |
options has exactly five fields and no others: userId, anonymousId, timestamp,
messageId, context.
userIdbelongs inoptions, notproperties. Passed inpropertiesit is stored as ordinary event data and never fills the identity column.
identify(userId, traits?, options?)
track (event, properties?, options?)
page (name?, properties?, options?)
screen (name, properties?, options?)
group (groupId, traits?, options?)identify and group take traits — attributes of the person or company — where track,
page and screen take properties, facts about the event.
await analytics.identify("user-42", { plan: "pro" });
await analytics.track(
"Order Completed",
{ order_id: "ORD-1183" },
{ userId: "user-42" },
);
await analytics.page("Checkout");
await analytics.group("company-7", { name: "Acme" });
await analytics.reset(); // on logoutCall reset() on logout. identify() makes the id sticky, so without it the next person on
a shared device inherits the previous user's identity.
Event names
Title Case, past tense, naming a business fact: Order Completed, Invoice Settled,
Subscription Cancelled. The naming policy rejects anything else, including interaction noise
like Button Clicked — name the fact, not the click.
Use the typed groups
Typed fields are queryable, joinable and stable across producers; properties is the catch-all
for everything else. Group them inside the payload argument and the client lifts them to the
event root for you:
await analytics.track("ETL Job Completed", {
dimensions: {
job_name: "daily_user_aggregation",
source_system: "production_postgres_db",
destination_system: "analytics_data_warehouse",
status: "success",
},
metrics: {
duration_seconds: 1245.5,
rows_processed: 15782390,
cpu_utilization_percent: 78.5,
},
flags: {
is_full_refresh: true,
triggered_downstream: true,
},
pipeline_version: "4.2.1", // not a typed group — stays in properties
});| Group | Holds |
| ------------ | ---------------------------------------------------------------- |
| dimensions | Low-cardinality strings you group by — status, region, job name. |
| metrics | Numbers you aggregate — durations, counts, percentages. |
| flags | Booleans you filter on. |
Sixteen groups are lifted: involves, sentiment, classification, location,
entity_linking, contextual_awareness, analysis, commerce, products, base_events,
access, metrics, dimensions, content, flags, integrations. Anything else stays in
properties, which is fine — reach for a typed group when you intend to query on the value.
Commerce carries its line items nested inside it, which is where the schema keeps them
(commerce.products[] — there is no root products column; a top-level one is folded in
for you):
await analytics.track("Order Completed", {
commerce: {
order_id: "BC-8891",
revenue: 48900,
currency: "ISK",
products: [
{ entry_type: "Purchased Item", product_id: "CAR-ECON", product: "Economy Car",
units: 1, unit_price: 39900,
starts: "2026-09-20T10:00:00Z", ends: "2026-09-25T10:00:00Z" },
],
},
involves: [
{ role: "RENTED_TO", entity_type: "Person", id: "a3f9c2e8b1d40571", id_type: "BlueCar", label: "Name Redacted" },
{ role: "PICKED_UP_AT", entity_type: "Location", id: "KEF", id_type: "IATA", label: "Keflavik Airport" },
],
});involves is the entity graph, and its fields follow conventions the validator cannot check
for you:
| Field | What it must be |
| --- | --- |
| role | a graph edge type, UPPER_SNAKE — RENTED_TO, SUPPLIED_BY, BOUGHT_BY, VISITED. Not a noun like Customer. |
| entity_type | a Capitalized noun — Person, Organization, Vehicle, Booking. |
| id | a strong issued id: a standard code (IATA, GS1, ISO-4217), a source-system primary key, or a hash of a stable PII attribute. Never a name or anything re-issuable — a licence plate or username belongs in label. Never empty: drop the entry instead of sending a blank id. |
| id_type | who issued the id — IATA, GS1, Stripe, your system's name. Not what the thing is. |
| label | the display name. For a person, the literal "Name Redacted" — the real name belongs in context.traits, which is GDPR-isolated. |
ProductLine carries the booking-shaped fields too — starts, ends, duration, seats,
destination, lead_time — so a rental or a flight is a product line, not a pile of
properties.
Full field reference: Semantic Event schema.
Envelopes handed to
send()/sendBatch()are transmitted verbatim — no lifting, no stamping. Shape those yourself.
What gets captured
In a browser the client fills these on every event. You do not pass them, and there is nothing
to switch on — the pipeline models them as typed columns, and an event without them arrives with
flags.page_missing set.
| Captured | Becomes |
| ------------------------------------- | ----------------------------------------------------------------------------- |
| context.page | url, path, host, title, referrer, referring_domain, search |
| context.campaign | the utm_* set, read from the query string |
| context.screen | width, height, pixel density |
| context.user_agent_raw | one string, which the service expands into the device, os and browser columns |
| context.locale / context.timezone | from the browser |
Page and campaign are re-read per event, so SPA navigation is tracked correctly. Anything you
pass in the call's context option overrides what was captured. Outside a browser — Node,
Workers, Deno, edge — nothing is captured rather than invented. context.ip and location are
resolved by the service from the request, never by the client.
session_id is not one of these. The column exists and the schema assigns it to the client,
but "a session" is a definition, not a fact — a 30-minute window invented here would disagree
with however your warehouse sessionizes, and a column that quietly disagrees is worse than a
null one. Set it yourself via send() if you have a definition you trust.
Validating payloads
Wrong value types in the typed groups are not rejected — they are coerced, and the coercion loses data:
dimensions: { rows: 15782390 } → stored as "15782390"
metrics: { status: "success" } → stored as 0
flags: { on: "yes" } → stored as trueThe schema is generated from the same Avro definitions the pipeline stores, so it cannot drift.
zod is an optional peer dependency, pulled in only by importing this path:
import { PayloadSchema } from "@fraios/inbox-client/schema";
PayloadSchema.parse({
dimensions: { job_name: "daily_user_aggregation", status: "success" },
metrics: { duration_seconds: 1245.5 },
flags: { is_full_refresh: true },
});It describes what you author. Fields the client fills — messageId, timestamp, sentAt,
anonymousId and context — and fields the service owns — partition, event_gid,
received_at — are absent by design, and setting one is an error naming where it belongs.
TrackPayloadSchema, PagePayloadSchema and TraitsPayloadSchema validate a whole call,
including the event name and options. Named types (Involved, Commerce, ProductLine,
Traits and more) are exported alongside, carrying the field documentation into your editor.
Warnings while you build
With debug on, the client reports the mistakes the service accepts and then discards —
identity fields in the payload, a typed group that is not an object, an event name the naming
policy rejects. Warnings only: it never throws and never blocks a send.
new FraiosAnalytics({ token, debug: true });
// [fraios/inbox-client] "Order Complete" — event: not Title Case past tense…
// [fraios/inbox-client] "Order Complete" — properties.userId: belongs in the options argument…Credentials
Pass the platform-issued Ingress Token you were given. That is the normal case:
new FraiosAnalytics({ token: process.env.INBOX_TOKEN });Exactly one of token or refreshCredential is required; passing both is a ConfigError.
Use token for server integrations, and for browser-class tokens — those carry an aud
claim pinning them to your origins, so they are safe to ship in page source, much like a Segment
write key. Never put a server-class token (no aud) in a page.
If your backend mints a short-lived credential per session, supply a hook instead and the client calls it whenever it needs one, caching the result, coalescing concurrent refreshes, and re-invoking after a 401:
new FraiosAnalytics({
refreshCredential: async () => (await fetch("/api/inbox-credential")).json(),
});With a static token there is nothing to re-fetch: a 401 raises AuthError. That is intended —
reissue or revoke the token rather than relying on refresh.
Source
source is the transformer-registered source name. It becomes the /inbox/{source} path
segment and is stamped on every envelope as source.type. It defaults to web.
new FraiosAnalytics({ source: "web", token });
// POST /inbox/web "source": { "type": "web" }
new FraiosAnalytics({ token }); // source omitted
// POST /inbox/web "source": { "type": "Web" }That field is not decoration: the service's source-claim guard reads source.type whenever the
write key carries a source claim, and without it such tokens have every event dropped from the
pipeline while the caller still sees HTTP 200 with event_count: 0.
The URL default is lowercase because the transformer lookup is an exact, case-sensitive match —
/inbox/Web returns 404 where /inbox/web resolves. The claim comparison itself is
case-insensitive, so a stamped "Web" still satisfies a claim of web.
Did it arrive?
HTTP 200 does not mean an event was ingested. The service answers 200 once it has accepted and routed the request; events that fail validation go to the dead-letter queue, and the response body reports how many made it through.
{ "status": "accepted", "messageId": "unknown", "event_count": 0 } // nothing ingested
{ "accepted": 0, "rejected": "validation", "errors": [ … ] } // nothing ingested
{ "status": "accepted", "messageId": "693c3a32-…", "event_count": 1 } // successtrack(), page(), identify(), screen() and group() resolve on transport success and do
not surface the body, so a resolved promise is not proof of ingestion.
Check that your source accepts batches before using
sendBatch(). Whether a JSON array works depends on the transformer registered for your source. A naive JOLT identity spec ([{"operation":"shift","spec":{"*":"&"}}]) matches array indices at the root and shreds the batch: onweb, an event accepted as a single object comes back{"accepted": 0, "errors": ["Event[0]: Missing required field 'type'"]}when wrapped in an array — still under HTTP 200. An array-aware transformer handles batches fine. Send one event as an array once and readevent_countbefore relying on it.
To see what the pipeline did with an event — which fields were promoted to typed columns, which
stayed in properties, what enrichment added — open the activity stream and send one while it is
connected. It is a live feed of your own partition, after processing.
GET https://inbox.fraios.dev/api/activity/stream
x-api-key <your write key>
Accept text/event-streamThe consumer joins a shared Kafka group reading from LATEST, so allow ~25 seconds after connecting before you send — events sent during the group rebalance never reach the stream (they are still ingested). The first frame confirms which tenant your credential resolved to:
event:connection
data:{"status":"connected","partition":"acme.com","limit":50,"topicAvailable":true}Leave that open, send an event from a second client, and it arrives as an event:semantic-event
frame carrying the complete normalized record — the server-assigned event_gid, partition,
received_at, resolved location, and a flags object naming what the pipeline expected and
did not find. Fire one representative event of each kind you intend to send and read back where
every field landed.
| Query parameter | Purpose |
| --------------- | ----------------------------------------------------------------------------------------------------------------- |
| match | Exact matches, comma-separated key:value. Keys: partition, region, eventType, or any path into the event. |
| search | Free-text across the event. |
| limit | Caps the events a connection will deliver. Default 50. No backlog is replayed — the consumer starts at LATEST. |
| pause | Connect without streaming. |
The stream is scoped to the partition your credential resolves to; you never see another tenant's traffic. It is not a dry run — events sent while inspecting are ingested like any other.
Runtimes
| Runtime | Import | Notes |
| ------------------- | ---------------------------------- | ----------------------------------------------- |
| Browser (evergreen) | @fraios/inbox-client | localStorage anonymous-id, full context capture |
| Node 20+ | @fraios/inbox-client | in-memory anonymous-id |
| Cloudflare Workers | @fraios/inbox-client/workers | in-memory; degrades gracefully |
| Vercel Edge | @fraios/inbox-client/vercel-edge | as Workers |
| Bun | @fraios/inbox-client/bun | localStorage shim works out of the box |
| Deno | @fraios/inbox-client/deno | localStorage works out of the box |
The runtime is selected by the exports field in package.json; most callers use the bare
import and let the bundler resolve. All six are exercised in CI on every release.
Constructor options
new FraiosAnalytics({
host?: string; // default https://inbox.fraios.dev
source?: string; // default "web"; also stamped as source.type
// exactly one of:
token?: string; // a platform-issued Ingress Token
refreshCredential?: () => Promise<{ token: string; expiresAtMs?: number }>;
debug?: boolean; // request logging + payload warnings
privacy?: { ipPolicy?, userAgentPolicy?, referrerPolicy? };
refreshSkewMs?: number; // default 60_000
refreshTimeoutMs?: number; // default 5_000
fetchTimeoutMs?: number; // default 30_000
maxBatchSize?: number; // default 1000 (server cap)
maxPayloadBytes?: number; // default 31_457_280 (30 MiB)
legacySegmentMode?: boolean; // opt-in /api/s/{type} migration path
});The full typed contract is specs/037-upgrade-client-sdks/contracts/client-js.d.ts in the
monorepo. partition is rejected: the service resolves the tenant boundary from the verified
write key.
Privacy
These cover what the visitor brings rather than what your site owns. Each takes "keep"
(default), "strip" or "mask", fixed at construction — applications layer their own consent
logic on top by choosing the policy.
| Option | strip | mask |
| ----------------- | -------------------------------------------------------- | ----------------------- |
| ipPolicy | removes context.ip | sets "0.0.0.0" |
| userAgentPolicy | removes context.user_agent_raw and context.userAgent | sets both to "masked" |
| referrerPolicy | removes context.page.referrer | sets "masked" |
These govern envelope contents only. The network-layer IP arriving via X-Forwarded-For is
outside their reach — the service reads it from proxy headers regardless. Suppressing that is
operator-side proxy configuration.
Errors
Every error extends InboxError and carries a klass string, so instanceof and
e.klass === "…" both work.
| Class | When |
| -------------------------------------- | --------------------------------------------------------------------------------------- |
| ConfigError | Construction-time validation, or 404 — usually no transformer registered for the source |
| AuthError | 401 — credential missing, invalid, expired, or lacking a claim |
| PartitionDenyError | 403 — partition or scope denied |
| ValidationError | 400/422, or a batch exceeding maxBatchSize / maxPayloadBytes |
| InboxError with klass: "transient" | Network failure or 5xx. A CORS block lands here with no HTTP status. |
Auth, validation and partition-deny errors are not retried. Transient ones are queued.
Offline behaviour
- Calls made before the first credential arrives queue and drain once it does.
- Calls made while
navigator.onLine === falsebuffer in memory and drain on the nextonlineevent. - No localStorage spillover, no persistence across tab close, no unload flush — events still queued when a tab closes are lost. For revenue-critical events, send from your server, or flush in your own page-lifecycle handler before unload.
Migrating from @jitsu/js
- import { jitsuAnalytics } from "@jitsu/js";
+ import { FraiosAnalytics } from "@fraios/inbox-client";
const analytics = new FraiosAnalytics({
- writeKey: "STATIC_WRITE_KEY",
+ token: process.env.INBOX_TOKEN,
});Method names and argument shapes are preserved: identify, track, page, screen, group,
setAnonymousId, reset. jitsuAnalytics() and inboxClient() remain as factory aliases.
What differs: the static write key is replaced by a scoped, revocable credential, and routing is
/inbox/{source} rather than destination plugins. Do not set partition — the client
rejects that removed option. Jitsu's destination-plugin system is not included.
This is fraios-owned source, not a republished Jitsu bundle; @jitsu/[email protected] is the audited
compatibility reference.
Contributing
This package lives in the fraios monorepo under
apps/ingression/clients/js/. Releases are tagged clients/js/v<MAJOR>.<MINOR>.<PATCH> and
published to npm with provenance attestation.
npm install
npm run generate:schema # regenerate src/schema.generated.ts from shared/schemas/avro
npm run build # rollup → dist/
npm run sync:static # rebuild the Spring-hosted classic + ESM bundles
npm test # unit + Vitest runtime tests
npm run test:browser # Playwright Chromium / Firefox / WebKitcheck:schema and check:static run on publish and fail if either generated artifact has
drifted from its source.
License
MIT. The compatibility reference is @jitsu/[email protected] from the Jitsu v2 line (MIT, © Jitsu
Labs, Inc.) — see src/jitsu-vendor/ATTRIBUTION.md for provenance and deviations.
