@analytiics/sdk-node
v0.2.0
Published
Analytiics server SDK: revenue attribution and identity from your backend.
Maintainers
Readme
@analytiics/sdk-node
Authenticated server events: revenue, identity links, and backend activity.
The browser tracker posts to a public, unauthenticated endpoint — it has to, since the snippet ships to every visitor. That endpoint therefore ignores revenue: if it didn't, anyone could fill a customer's dashboard with fake sales. Revenue arrives here instead, over an authenticated path.
Setup
Each project gets a server write key. It is shown once, when it is issued, and cannot be recovered afterwards — only its hash is stored.
# on the app that sends server events
ANALYTIICS_SITE=liinks.co
ANALYTIICS_WRITE_KEY=sk_live_...Keys are bound to a project, so a leaked key can only write to the project it was issued for, and a key can be revoked without redeploying anything. Keep it server-side — it is not a publishable key.
import { createClient } from "@analytiics/sdk-node";
export const analytiics = createClient({
site: "liinks",
writeKey: process.env.ANALYTIICS_WRITE_KEY!,
api: "https://in.analytiics.co",
});Attributing revenue to a channel
A Stripe webhook knows a customer id and an amount. It has no cookie, no referrer, and no session — nothing that says the customer arrived from Google three days ago. The link is the user id, in two steps:
1. In the browser, once the user is known:
window.analytiics.identify(user.id);That ties the anonymous visitor to a user id, which is what lets a later server-side event find the session — and therefore the source — that earned it. Call it at signup and on login.
2. In the webhook:
export async function POST(req: Request) {
const event = stripe.webhooks.constructEvent(/* … */);
const delivery = createClient({
site: "liinks.co",
writeKey: process.env.ANALYTIICS_WRITE_KEY!,
});
if (event.type === "checkout.session.completed") {
const session = event.data.object;
delivery.revenue({
name: "subscription_started",
eventId: event.id, // same provider ID on every webhook replay
ts: new Date(event.created * 1000).toISOString(),
userId: session.client_reference_id!, // the same id you passed to identify()
revenueCents: session.amount_total!,
currency: session.currency!.toUpperCase(),
props: { plan: "pro" },
});
}
// Serverless runtimes can freeze the process the moment this resolves.
try {
await delivery.flush({ throwOnError: true });
} catch {
return new Response("Retry this webhook", { status: 503 });
}
return new Response(null, { status: 200 });
}revenue_by_source then resolves userId → the session that user was first
seen in → that session's entry source, and credits the revenue there. Revenue
from a user who was never seen in a browser session is reported as
Unattributed rather than dropped. Revenue with no user ID is also included
there, without inventing a customer identity.
Credit goes to the first session, not the last: the channel that found the customer is the one worth spending on.
What this can't do
Visitor ids rotate daily (they're a hash of IP + user agent, not a cookie), so a user who first visits on Monday and signs up on Wednesday can only be linked back to Wednesday's session. Attribution uses the first observed identified session. A visit on a previous day that never carried an identity cannot reliably be linked to that user. Known-user funnel steps use the user ID across days and devices.
With the generated helpers
If the project has an analytics.yaml, prefer the typed helpers over raw event
names — that's the point of the manifest:
import { createClient, toAnalytiicsClient } from "@analytiics/sdk-node";
import { createTrack } from "./analytics.gen.js";
const analytiics = createClient({ site: "thiings", writeKey: process.env.ANALYTIICS_WRITE_KEY! });
// Inside the request handler after resolving its user:
const track = createTrack(toAnalytiicsClient(analytiics, { userId: session.userId }));
// `revenue: true` in the manifest makes the amount part of the signature —
// recording the conversion without the money won't compile.
track.purchase(
{ type: "personal_indie" },
{ userId, revenueCents: 4900, currency: "USD" },
);
await analytiics.flush();The generated track() signature has nowhere to put a user id; it's shaped for
the browser, where the tracker already knows who it is. On a server there's no
ambient user, so toAnalytiicsClient takes one and attaches it to every
non-revenue event. Regenerate with analytiics codegen to get createTrack.
Never call the shared browser configure() with request-specific identity.
API
client.track({ name, eventId?, userId?, props?, url?, ts? });
client.identify(userId);
client.revenue({ name, eventId?, userId?, revenueCents, currency, props?, ts? });
await client.flush(); // send now — required before a serverless handler returns
await client.close(); // flush and stop the timer
await client.flush({ throwOnError: true }); // require confirmed deliveryThe default flush reports failures through onError and resolves, preserving
best-effort analytics for application traffic. Strict flush rejects if any event
on this client failed delivery, including an earlier automatic flush. That
failure stays on the client: a later successful batch cannot repair a lost event.
Use a separate client per webhook handler when requiring strict delivery.
Batches retry twice on network errors, HTTP 429 and 5xx. Each attempt has a
25-second timeout (requestTimeoutMs configures it). Other 4xx errors are
reported immediately. Each queued event gets a UUID and timestamp once, before
any retry; properties are copied at enqueue time. Batches never exceed 200 events.
For provider webhooks, supply eventId: event.id and the original ts so a new
handler invocation reuses the logical event's identity. If one provider event
produces multiple analytics events, suffix its ID with each event's purpose.
The collector returns 409 for conflicting reuse. Server event IDs are required
on the wire; this SDK supplies them automatically. Older SDKs must be upgraded
before switching to the new collector contract. $ event names are reserved
except $identify.
The production collector persists a receipt and waits for Tinybird's database commit before reporting acceptance. Retries reuse the first payload/timestamp; queries and aggregate states deduplicate IDs after uncertain writes. Without an acknowledged response, retry using the same IDs. This is not an autonomous outbox: the provider or caller must retry a failed delivery.
revenueCents may be negative — refunds and chargebacks are real revenue
events. A currency is required whenever an amount is set: an amount with no
unit can't be summed, and defaulting to USD would quietly misreport every
non-USD customer.
Serverless
For best-effort application events a module-scoped client is fine. For payment webhooks use the request-local client and strict flush shown above. Always await the flush before returning. The default batching (20 events or 5 seconds) exists for long-running servers; in a function that freezes between invocations, an unflushed batch is a lost sale.
