@yaebal/analytics

typed event tracking and funnels, straight from middleware: ctx.track("purchase", { amount: 9 }) fans out to whatever sinks you configure — posthog, plausible, your own sqlite/clickhouse table, a generic http collector, or a console log for local dev. event names and their properties are checked against a catalog you declare once, the same way @yaebal/feature-flags types flag keys — a typo'd event name or a missing required property is a compile error, not a silent gap in your funnel three months later.

install

terminal
pnpm add @yaebal/analytics

usage

install analytics() with bot.install(). it adds ctx.track(name, properties?) and ctx.identify(properties) to every handler's context — each call resolves userId/chatId from the current ctx before handing the event to your adapters. skip the events catalog entirely to keep ctx.track fully untyped (any string name, any properties).

bot.ts
import { createBot } from "yaebal";
import { analytics, consoleAdapter, p } from "@yaebal/analytics";

// swap consoleAdapter() for postHogAdapter / plausibleAdapter / sqliteAdapter / clickhouseAdapter
const bot = createBot(process.env.BOT_TOKEN!).install(
  analytics({
    // event names + properties are checked against this catalog — a typo'd name or a
    // missing required property is a compile error, not a silent gap in your funnel
    events: {
      start: true, // declared, untyped — any properties allowed
      purchase: { props: p.object({ amount: p.number() }) },
    },
    adapters: [consoleAdapter()],
  }),
);

bot.command("start", (ctx) => {
  ctx.track("start", { source: "deeplink" });
  return ctx.reply("hello!");
});

bot.command("buy", (ctx) => {
  ctx.track("purchase", { amount: 9 });
  return ctx.reply("thanks!");
});

bot.start();

the typed catalog

p is a tiny, zero-dependency runtime validator — p.string() / p.number() / p.boolean() / p.optional(field) / p.object({...}). it exists to give ctx.track both compile-time property checking and a runtime guard against a malformed call; reach for zod/valibot and hand-write a PropsSchema ({ parse(value) }) if you need more than that.

events.ts
import { p } from "@yaebal/analytics";

const events = {
  // "true" — declared, but any properties allowed (no schema to enforce)
  onboarding_completed: true,
  // a schema — required/optional fields checked at both compile time and runtime
  purchase: {
    props: p.object({ amount: p.number(), currency: p.optional(p.string()) }),
    sample: 0.5,          // only half of these reach adapters — see "privacy & sampling"
    redact: ["email"],    // stripped from this event's properties specifically
    description: "a completed checkout",
  },
};
a malformed track() call (wrong property type, missing required property, or — when a catalog was declared — an event name that isn't in it) is reported to onError and dropped, never thrown into your handler and never silently forwarded to adapters.

auto-capture

emit events for common update kinds with no manual ctx.track call: autoTrack: ["commands", "callback_queries", "messages"]. each kind emits a fixed event name with the dynamic bit as a propertycommand_used + { command: "start" }, callback_query + { data: "..." }, message_received + { contentType: "text" } — never an event name per command/callback payload, which would blow up your adapters' event schemas. a command message is only counted once even with both "commands" and "messages" enabled.

bot.ts
import { createBot } from "yaebal";
import { analytics, consoleAdapter } from "@yaebal/analytics";

// each kind emits a FIXED event name with the dynamic bit as a property —
// command_used + { command }, message_received + { contentType } — never an
// event name per command/message, which would blow up your adapters' schemas
const bot = createBot(process.env.BOT_TOKEN!).install(
  analytics({ adapters: [consoleAdapter()], autoTrack: ["commands", "messages"] }),
);

bot.command("start", (ctx) => ctx.reply("hi! no ctx.track() call here — autoTrack did it"));
bot.on("message:text", (ctx) => ctx.reply(`you said: ${ctx.text}`));

bot.start();

adapters

adapters take an already-constructed client and type it structurally, so this package depends on nothing and never dictates a driver version.

analytics.ts
import { clickhouseAdapter, httpAdapter, plausibleAdapter, postHogAdapter, sqliteAdapter } from "@yaebal/analytics";
import { PostHog } from "posthog-node";
import { DatabaseSync } from "node:sqlite";

analytics({
  events,
  adapters: [
    postHogAdapter(new PostHog(process.env.POSTHOG_KEY!)),
    plausibleAdapter({ domain: "mybot.example" }),
    sqliteAdapter(new DatabaseSync("analytics.db")),
    clickhouseAdapter(clickhouseClient, { batchSize: 50 }),
    httpAdapter("https://collector.example/events", { headers: { authorization: "Bearer ..." } }),
  ],
  onError: (error, event) => console.error("analytics failed", event.name, error),
});
  • postHogAdapter(client, options?)client is anything shaped like posthog-node's PostHog (structural capture() + optional identify()/flush()). a custom distinctId() keeps userId reachable as a property, since it's no longer posthog's own identity.
  • plausibleAdapter(options) — posts straight to plausible's events api, no client library. plausible dedupes "unique visitors" by hashing IP + user-agent; without a real per-user IP, every event from a bot process would look like the same visitor, so this adapter sends a deterministic synthetic X-Forwarded-For derived from userId/chatId instead. only scalar properties reach plausible — an object/array value is dropped rather than corrupting the breakdown.
  • sqliteAdapter(db, options?)db is node:sqlite's DatabaseSync, better-sqlite3, or anything with exec/prepare. creates its table (and a (name, created_at) index) on first use — see sqliteSchema() to run the DDL yourself. supports query(), so analyticsAdmin() works against it directly.
  • clickhouseAdapter(client, options?)client is anything shaped like @clickhouse/client (structural insert()). buffers and batch-inserts (batchSize, intervalMs, maxRetries, maxBuffered, onDrop) — a failed insert retries with backoff instead of losing the batch. clickhouse has no auto-migration like sqlite's db.exec, so run clickhouseSchema()'s DDL yourself once.
  • httpAdapter(url, options?) — POST batches of events as JSON to any HTTP collector (umami, a mixpanel-compatible batch endpoint, your own). same batching/retry/backpressure as clickhouseAdapter.
  • memoryAdapter() — an in-memory sink: for tests and small bots that want /stats without a database. supports query().
  • consoleAdapter() — zero-config sink for local development.
migrate.ts
import { clickhouseSchema } from "@yaebal/analytics";

await clickhouseClient.command({ query: clickhouseSchema() });
adapter failures never break tracking: track() is fire-and-forget, and a rejected/throwing adapter is routed to onError instead of interrupting the update. onError itself is never allowed to break tracking either — if it throws, the error is swallowed after one console.warn rather than propagating out of ctx.track()/flush().

privacy & sampling

userId/chatId are personal data the moment they leave your process — a cloud posthog/plausible instance is a third party. analytics() has first-class controls instead of leaving this to you.

analytics.ts
analytics({
  events,
  adapters: [postHogAdapter(posthog)],
  // hash userId/chatId before any adapter sees them — stable per id (funnels still group
  // correctly), not reversible by casual inspection, NOT a security control (small id spaces
  // are brute-forceable). pass a function instead (e.g. HMAC with a secret) for anything stronger.
  anonymize: "hash",
  // strip a property from every event, on top of any catalog entry's own `redact`
  redact: ["ip"],
  // consulted with the RAW (pre-anonymize) ids — an opt-out list keyed by real telegram ids
  // still works. a throwing/rejecting predicate fails OPEN, so a buggy check can't blackhole
  // every event.
  shouldTrack: (event) => !optedOut.has(event.userId),
  // load-shedding, not a stable per-user rollout — 10% of calls reach adapters, decided
  // fresh per call. a catalog entry's own `sample` overrides this for that event.
  sample: 0.1,
});

an in-chat admin surface

analyticsAdmin({ isAdmin, adapter }) — a telegram-native ops surface for events analytics() tracks, the same pattern as @yaebal/feature-flags's flagsAdmin. /analytics reports total events and top event names over the last 24h; /analytics 1h | 7d | 30d switch the window. pair it with memoryAdapter, sqliteAdapter, or clickhouseAdapterposthogAdapter/plausibleAdapter don't implement query(), so use their own dashboards instead.

bot.ts
import { createBot } from "yaebal";
import { analytics, analyticsAdmin, memoryAdapter } from "@yaebal/analytics";

// memoryAdapter/sqliteAdapter/clickhouseAdapter all support query() — analyticsAdmin
// reads through it. posthogAdapter/plausibleAdapter don't (use their own dashboards).
const store = memoryAdapter();

const bot = createBot(process.env.BOT_TOKEN!)
  .install(analytics({ adapters: [store], autoTrack: ["commands"] }))
  .install(analyticsAdmin({ isAdmin: () => true, adapter: store }));

bot.command("start", (ctx) => ctx.reply("hi!"));

bot.start();

flushing on shutdown

buffered adapters (clickhouseAdapter, httpAdapter) hold events in memory between batches. analytics() wires flush() to bot.onStop automatically when installed on a Botbot.stop() won't resolve until the drain completes. on a bare Composer or a webhook/serverless deployment where bot.onStop never fires, call .flush() yourself.

shutdown.ts
const events = analytics({ adapters: [clickhouseAdapter(client)] });
bot.install(events); // flush() is now wired to bot.onStop automatically

// only needed off a real Bot (a bare Composer, or bot.onStop never firing in a
// webhook/serverless deployment):
someOtherShutdownHook(() => events.flush());

a unified collection point

plugins like @yaebal/broadcast already emit their own event stream (onEvent). build a standalone client with createAnalytics() and feed both ctx.track and other plugins' events into the same adapters with fromEvent.

events.ts
import { analytics, createAnalytics, fromEvent } from "@yaebal/analytics";

const events = createAnalytics({ adapters: [postHogAdapter(posthog)] });

bot.install(analytics(events)); // ctx.track(...) now shares events's adapters

const jobs = new Broadcast({
  // ...
  onEvent: (event) => events.track(fromEvent("broadcast", event)),
});
events/context/autoTrack only apply when analytics() builds its own client from an AnalyticsOptions config — a shared client passed in already has its own (or no) catalog, and has no ctx for context/autoTrack to hook into.

api

exportsignaturedescription
analytics(source: AnalyticsOptions | Analytics) => AnalyticsPlugininstalls ctx.track/ctx.identify; the returned function also carries .flush()
createAnalytics(options: AnalyticsOptions) => Analyticsstandalone collection point, independent of any bot or ctx
analyticsAdmin(options: AnalyticsAdminOptions) => (composer) => composertelegram-native /analytics reports, gated by isAdmin
fromEvent(prefix, event: { type: string }) => TrackInputshapes a foreign { type } event stream into a trackable input
p{ string, number, boolean, optional, object }tiny runtime validator for a catalog entry's props schema
batched(send, options) => Batchedthe batch/retry/backpressure primitive clickhouseAdapter/httpAdapter share
postHogAdapter(client, options?) => AnalyticsAdapterforwards events to a posthog-node-shaped client
plausibleAdapter(options) => AnalyticsAdapterposts to plausible's events api
sqliteAdapter(db, options?) => AnalyticsAdapterappends events as rows to sqlite; sqliteSchema() exports the DDL
clickhouseAdapter(client, options?) => AnalyticsAdapterbuffers and batch-inserts into clickhouse; clickhouseSchema() exports the DDL
httpAdapter(url, options?) => AnalyticsAdapterPOSTs batches of events as JSON to any collector
memoryAdapter() => MemoryAdapterin-memory sink with events/identities/query()/clear()
consoleAdapter() => AnalyticsAdapterlogs events to console.log

AnalyticsControl interface

methodreturnsdescription
track(name, properties?)voidfire-and-forget event, tagged with the current userId/chatId; name/properties checked against the catalog when one was given
identify(properties)voidperson-level properties for the current update's user; no-op without a ctx.from

AnalyticsOptions

fieldtypedefaultdescription
eventsEventsCatalogevent names + property schemas; omit for a fully untyped ctx.track
adaptersAnalyticsAdapter[]every configured sink
onError(error, event) => unknownobserve adapter/validation failures without breaking tracking
now() => numberDate.nowclock override, mainly for tests
samplenumber1fraction of calls that reach adapters (load-shedding, not a stable rollout)
shouldTrack(event) => MaybePromise<boolean>consent/opt-out gate; fails open
anonymize"hash" | (id, kind) => string | numberreplace userId/chatId before adapters see them
redactstring[]property keys stripped from every event
context(ctx) => MaybePromise<object>extra properties merged onto every ctx.track call
autoTrackAutoTrackKind[]"commands" | "callback_queries" | "messages"

testing

memoryAdapter() replaces hand-rolling a { track: (e) => events.push(e) } fake in every test. drive the bot with @yaebal/test as usual.

analytics.test.ts
import { createTestEnv } from "@yaebal/test";
import { analytics, memoryAdapter } from "@yaebal/analytics";

const store = memoryAdapter();

const bot = new Composer<Context>()
  .install(analytics({ adapters: [store] }))
  .command("start", (ctx) => {
    ctx.track("start");
    return ctx.reply("hi");
  });

await createTestEnv(bot).createUser().sendCommand("start");
// store.events => [{ name: "start", userId: ..., timestamp: ..., ... }]
pairs with broadcast. @yaebal/broadcast's onEvent and ctx.track can both feed the same Analytics client — see @yaebal/broadcast and the bridge example above.