Does Anyone Actually Use My Bot? Counting It Properly

·5 min read·Updated
botsanalyticsvolumesobservability

"Is anyone using this?" is a reasonable question and surprisingly hard to answer after the fact, because the two places you'd instinctively look can't tell you. Logs are capped and rotate, so anything older than your recent output is gone. A counter in memory resets every time the container is replaced — which is every deploy and every configuration change, not just crashes. And the platform doesn't collect application-level metrics for you, because nothing watches your app at all. The answer is a small table on a volume, written as daily rollups rather than one row per event — about twenty lines of code, and the rollup part is what stops it becoming a disk problem.

Count rollups, not events

The tempting design is a row per invocation, with a timestamp, so you can slice it however you like later. Don't start there.

An events table grows without limit, and volume size is the one resource with an account-wide consequence — exceed your total disk and every app you run stops until you free space. A popular bot writing a row per command is a plausible way to get there, and it'll happen at the moment the bot is most successful.

Daily counters are bounded by days × things you count, which is a number you can predict:

CREATE TABLE usage (
  day     TEXT NOT NULL,     -- '2026-08-08'
  metric  TEXT NOT NULL,     -- 'cmd.weather', 'error.weather', 'guilds'
  count   INTEGER NOT NULL DEFAULT 0,
  PRIMARY KEY (day, metric)
);

And one function:

def bump(metric, n=1):
    db.execute(
        "INSERT INTO usage (day, metric, count) VALUES (?, ?, ?) "
        "ON CONFLICT(day, metric) DO UPDATE SET count = count + ?",
        (date.today().isoformat(), metric, n, n),
    )

Call bump("cmd.weather") in your handler. That's the whole system.

What's worth counting

Resist instrumenting everything. Four things answer nearly every question you'll actually ask:

  • Invocations per command. Which features are used, and which you can delete.
  • Errors per command. A ratio against invocations tells you where things are broken, which raw error counts don't.
  • Distinct users or servers per day, approximately — see below.
  • Server count at startup, which most libraries hand you for free. Its trend is the growth signal.

Not: message content, full user records, or anything you'd have to think about before sharing a screenshot of it.

Counting "distinct users" without storing users

Distinct counts don't aggregate — you can't add yesterday's to today's — so they need a set, and a set of user IDs is personal data that grows forever.

Two proportionate options:

  • Store a hashed ID per day in a small table, count rows, and delete the day's rows after a retention window. You get exact daily numbers without keeping an identity trail.
  • Don't bother. For most bots, invocations per day is a good enough proxy for "is this alive", and it's free.

If your bot serves other people's communities, less data is a feature. You're deciding what you'd be comfortable explaining to a server owner.

Reading it back

Two ways, and the first is enough:

A command only you can run. /stats that prints the last seven days from the table, gated on your own user ID. No new infrastructure, works from your phone.

A tiny HTTP endpoint, if you want a page. Remember that binding a port makes it public — there's no firewall in front of your app — so it needs authentication, not just an unguessable path. For most bots the command is the better trade.

Prune it

Set the retention when you create the table, not when it becomes a problem:

  • Daily rollups: keep a year if you like — it's a few thousand rows, which is nothing.
  • Per-day identity rows: keep days, not months.
  • Anything per-event, if you add it later: keep it deliberately short.

One scheduled delete inside your existing loop covers all of it. Background work belongs in the same process as your bot, not a second app.

Survive the restarts

Two details that make the numbers trustworthy:

Write as you go. Counting in memory and flushing every few minutes loses whatever hadn't been flushed when the container was replaced — and it's replaced routinely. An INSERT ... ON CONFLICT per invocation is cheap on SQLite, and cheaper than being unsure.

Log a startup line with the server count. It survives rotation better than anything else because it appears once per restart, and it gives you a coarse history even if the table is ever lost.

What this tells you

Once you have a few weeks of numbers, the useful questions become answerable:

  • Is it growing? Server count trend, not today's number.
  • What can I remove? Commands with near-zero invocations are maintenance you're paying for.
  • Where's it breaking? Error rate per command, not total errors.
  • Is that feature I built worth keeping? The honest answer, rather than the one you hope for.

That last one is the real value. A bot with three used commands and nine unused ones is easier to maintain once you know which is which.

FAQ

Why can't I just check the logs?

They're capped and rotate, so older activity is gone. They're for debugging what just happened, not for counting.

Why did my counters reset?

They were in memory. The container is recreated on every deploy and configuration change, so counters must live on a volume.

Should I store one row per event?

No. Daily rollups are bounded and predictable; an events table grows forever, and disk overruns stop every app in your account.

How do I count distinct users?

Store hashed IDs for the current day only and delete them after a short window — or skip it, since invocations per day usually answers the same question.

Where do I see the numbers?

An owner-only command is simplest. An HTTP endpoint means a public port, so it needs real authentication.

Does the platform collect usage metrics for me?

No. It observes whether your container is running; anything about your app's behaviour is yours to record.

Wrapping up

The reason this feels harder than it is: both obvious answers are wrong here for the same underlying reason — logs and memory are both transient by design. Once you accept that anything you want to know later has to be written to a volume deliberately, it collapses into one table, one function, and a delete on a schedule. Do it early, because the interesting question is always "how has this changed", and you can only answer that with data you started collecting before you thought to ask.