@dontdoit/platform-analytics
v0.1.2
Published
Server-side product analytics: job types, event fan-out, and PostHog / Zaraz / warehouse senders
Readme
@dontdoit/platform-analytics
Server-side product analytics: job types for queueing capture/identify events, and senders
for PostHog, Cloudflare Zaraz, and the self-hosted Postgres warehouse behind Grafana. No
client-side SDK, no framework dependency — just fetch.
Each consuming app owns its own event vocabulary, dimension allowlist, auth/context shape, and queue binding; this package provides only the pieces that are identical everywhere.
Install
npm install @dontdoit/platform-analyticsUsage
Declare the app's vocabulary and wiring in one place:
import type { AnalyticsDestination } from "@dontdoit/platform-analytics";
export const analyticsEvents = {
questCompleted: "quest_completed", // snake_case — the warehouse rejects anything else
} as const;
export const destinations: readonly AnalyticsDestination[] = ["posthog", "warehouse"];
// Only these property keys leave for shared destinations. Everything else stays in PostHog.
export const allowedDimensions = ["quest_id", "kind"] as const;Enqueue from wherever the app already persists the action:
import { createAnalyticsJobs, createIdentifyJobs } from "@dontdoit/platform-analytics";
await env.MY_QUEUE.sendBatch(
createAnalyticsJobs("quest_completed", destinations, user.id, { quest_id: quest.id })
.map((body) => ({ body })),
);
// For user traits:
await env.MY_QUEUE.sendBatch(
createIdentifyJobs(destinations, user.id, { plan: "pro" })
.map((body) => ({ body }))
);Fan out in the queue consumer:
switch (job.destination) {
case "posthog":
if (!env.POSTHOG_API_KEY || !env.POSTHOG_HOST) return;
return sendToPostHog(job, { apiKey: env.POSTHOG_API_KEY, host: env.POSTHOG_HOST });
case "warehouse":
if (job.type !== "platform_analytics.capture") return; // Warehouse only supports capture
if (!env.ANALYTICS_WEBHOOK_URL || !env.ANALYTICS_WEBHOOK_TOKEN) return;
return sendToWarehouse(job, {
url: env.ANALYTICS_WEBHOOK_URL, // project lives in the path
token: env.ANALYTICS_WEBHOOK_TOKEN,
subjectSalt: env.ANALYTICS_SUBJECT_SALT,
allowedDimensions,
});
}Design notes
- One job per destination.
createAnalyticsJobsfans out so a warehouse outage retries only the warehouse job instead of duplicating the event into PostHog on every attempt. All jobs for one logical event share aneventIdandoccurredAt, so they can be reconciled across destinations and deduplicated on retry. - The warehouse never receives a raw user id.
sendToWarehousesendsSHA-256(salt:userId)assubjectId. Use a distinct salt per app — a shared salt makes the same person resolve to the same subject across apps, which is a cross-app identity graph. No salt configured means no subject at all; it never falls back to the raw id. - The project is never in the request body. It lives in the ingest URL path, and the receiver derives it from there, so a token holder cannot write events attributed to another project.
safeDimensionsis defence in depth, not the boundary. It does not exist for browser-side producers like Zaraz pageviews, so the ingest endpoint enforces its own cap.
API
CaptureAnalyticsJob/IdentifyAnalyticsJob/PlatformAnalyticsJob/AnalyticsDestination— job shapes.createAnalyticsJobs(event, destinations, userId?, properties?)— fans one event out into one job per destination.createIdentifyJobs(destinations, userId, properties?)— fans one identify event out into one job per destination.sendToPostHog(job, { apiKey, host })— POSTs to PostHog's/capture/. Anonymous events set$process_person_profile: falseso they don't mint throwaway person profiles.sendToZaraz(job, { url, allowedDimensions })— POSTs to the Zaraz HTTP Events API.sendToWarehouse(job, { url, token, subjectSalt?, allowedDimensions })— POSTs to the Postgres ingest endpoint.hashSubject(userId, salt)— the salted subject hash, exposed for testing.safeDimensions(properties, allowedKeys)— narrows to allowlisted scalar keys.cleanProperties(properties)— dropsundefinedvalues.assertSnakeCaseEvents(events)— throws on any name the ingest endpoint would reject.
All senders throw on a non-OK response so the queue retries.
License
MIT
