Skip to content

Diagnostic Slack

The bot joined 4,000 channels and the nightly sweep dies

The job that enumerates the bot's channels used to take four seconds. It now takes eleven minutes, and last Tuesday it stopped taking any time at all because it died on ratelimited before it finished. Nothing in the code changed. Nothing is misconfigured. The bot is a member of every channel it is a member of, legitimately, because an onboarding automation has been inviting it to every new channel for two years.

This is the note in the section where nothing has gone wrong and something has to change anyway.

Read-only token Python and Node.js Tests included
Man in white button up shirt smiling
Photo by LumenSoft Technologies on Unsplash
The short answer

users.conversations and conversations.list are cursor-paginated with a maximum limit of 1000 and both sit on Tier 2, which is roughly twenty requests a minute. Four thousand memberships is four pages, and four pages is nothing. Four pages taken once per incoming message, or once per queue item, is a request rate that exhausts a Tier 2 bucket inside a minute and stays exhausted.

So the number to compute is not the membership count on its own. It is pages multiplied by how often you take the inventory, held against the tier's budget of 1200 requests an hour. The script below counts the footprint with one timed sweep, gets the workspace denominator from a bounded conversations.list, and prints that arithmetic. Then it does the second half, which people forget: membership is also the multiplier on event delivery, because message.channels pays out once for every message in every channel the app is in.

The problem in plain words

Nobody decides to put a bot in four thousand channels. It accumulates. Somebody writes a workflow that invites the standard set of apps to each new project channel, which is a thoroughly good idea at forty channels. Two years later the workspace has four thousand channels, the app is in most of them, and every piece of code that says "list the channels I am in" has quietly become a multi-page paginated walk on a Tier 2 method.

The failure mode is not a permission error, which is what makes it hard to file. The sweep works. It works slower each month. Then one day it works slower than the interval it runs on, two runs overlap, they share the same per-method bucket, and both of them start seeing ratelimited. The stack trace names a rate limit, so the ticket says "rate limiting", and the repair everybody reaches for is backoff, which makes the job take longer and fixes nothing, because the job is not being throttled by bad luck. It is asking for more than the tier allows, every run, by design.

Underneath that is a second cost that no sweep will show you. An app in four thousand channels subscribed to message.channels receives an event for every message posted in any of them. Slack expects an acknowledgement within three seconds, and an app that is spending its time acknowledging traffic from channels it will never act on has a much smaller margin than its author believes. The membership count is the multiplier on both problems at once, which is why the honest finding is the count and its consequences rather than an error string.

Bot invited byhandforty channelsA rule invitesitevery new channelInventory perrunfour pages, Tier 2Runs overlaptwo sweeps, onebucketratelimitedthe sweep cannotfinishand the rule is still adding
Every invitation in this chain succeeded. There is no error to catch and no flag to read, which is why the count has to be asserted by somebody rather than discovered by a handler.

Why it happens

Membership is not a failure, so no error handler will ever mention it. Every one of those channels returned ok: true when the bot was invited. There is no error to catch and no flag to check. The only way this becomes visible is if something asserts a number, which means somebody has to decide what number is too many before it is.

The tier, not the count, is the constraint. Tier 2 is about twenty requests a minute per method per workspace per app. One inventory of four thousand channels is four requests. That is affordable once an hour and ruinous once a minute, and the difference between those two is a scheduling decision buried somewhere in the application, not a property of Slack.

The maximum page size is 1000, and it is a ceiling rather than a promise. Asking for more is rejected outright, so there is no way to make the sweep cheaper by asking for a bigger page. The only lever is asking less often.

Membership multiplies event delivery, and that budget is measured in seconds. A subscription to message.channels scales with the footprint. A subscription to app_mention scales with how often somebody wants something. Narrowing the subscription is usually a much larger win than optimising the sweep, and it is the one repair that shrinks with usage rather than growing with it.

A read-only audit that gets throttled has already answered the question. If a script whose entire job is to count things cannot count them without hitting the tier, then the application, which does this plus its actual work, is over the line too. That is worth recording as a finding rather than as an inconvenience the script retries around.

The fix, as a flow

The only chain in this section with an arrow that goes backwards. Nothing in it is an error until the very end, and the end is not caused by the step before it: it is caused by the loop underneath, which has been adding to the count since long before anybody noticed. The fix branch is arithmetic rather than classification, because the finding here is a number and not a state.

Pages times frequencyagainst 20 a minuteOver the Tier 2 budget1,440 asked of 1,200Footprint tracks the workspacea rule, not a choicemessage.channels subscribedmultiplied by the footprintThe sweep did not finishno count is claimedCached with a TTLfour requests an hour
Pages multiplied by frequency, against twenty requests a minute. The same footprint is affordable hourly and ruinous per queue item, so the count on its own is not the finding.

How to fix it

Time the sweep rather than estimating it

The script starts a clock, follows every cursor on users.conversations at limit=1000, and reports pages and wall time. An estimate derived from the count would be tidier and would miss the thing you want to know, which is whether this workspace's sweep is already slow enough to overlap the interval it runs on.

Get a denominator before you judge the numerator

Two thousand channels means one thing in a workspace of two thousand one hundred and something else in a workspace of forty thousand. A bounded conversations.list gives the total, and the share is what tells you whether the bot was invited selectively or by a rule.

Price one inventory against Tier 2, then against how often you take it

Pages times runs per hour, against a budget of twenty a minute. This is the arithmetic that turns "the bot is in a lot of channels" into "this job asks for 1,440 requests an hour out of 1,200", which is a sentence somebody can act on this afternoon.

Sort the event subscriptions into the ones that scale with membership

message.channels, message.groups, member_joined_channel and reaction_added all pay out per channel. app_mention and message.im pay out per request from a human. The script labels each subscription you name and says which side of that line it is on.

Cache the inventory instead of retaking it

The printed repair is a TTL and an event-driven refresh: keep the channel list, refresh it from channel_created, channel_deleted and member_joined_channel rather than by re-enumerating. A cache with a one-hour TTL turns 2,400 requests an hour into four.

Stop the automation that is still adding to the count

Every repair above is undone by the workflow that invites the bot to each new channel. The script prints the footprint growth as a share of the workspace precisely so that the conversation includes the thing generating it, rather than only the job that suffers from it.

How to check it worked

Re-run after the cache is in place with the real run frequency. The footprint will not have moved, and the request arithmetic should be the part that changed.

python3 slack_channel_footprint.py --runs-per-hour 1
# identity   U0APPBOT11 in acme
# footprint  broad          3,914 of 4,102 channel(s), 95.4% of the workspace
# sweep      4 page(s) in 1.9s, no cursor left unfollowed
# cost       affordable     4 request(s)/hour against a Tier 2 budget of 1200
# events     membership     message.channels scales with the footprint
# events     demand         app_mention scales with how often somebody asks

The full code

Three pure functions and two paginated GETs. sweep_cost is the whole note in nine lines: pages, multiplied by how often you take the inventory, against the tier's hourly budget. footprint_verdict holds the count against the workspace so the number has a denominator. event_load is the half that has nothing to do with the Web API at all, sorting the subscriptions that scale with membership away from the ones that scale with demand.

Get this script on GitHub Follow @allanninal Python and Node.js, with tests. Read only, it never writes. One of 108 Slack fixes, free and open source.
slack_channel_footprint.py
"""Measure the bot's channel footprint and price the sweep that reads it.

Read only. One paginated users.conversations counts the memberships and times
itself, one bounded conversations.list supplies the workspace denominator.
Nothing joins, leaves or invites: this reports how large the footprint is, what
one inventory costs against the method's tier, and which event subscriptions
grow with membership rather than with demand.
"""
import argparse
import logging
import os
import sys
import time

import requests

logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_channel_footprint")

API = "https://slack.com/api/"

# users.conversations and conversations.list are cursor paginated with a
# maximum limit of 1000, and both sit on Tier 2: roughly 20 requests a minute
# per method, per workspace, per app. Those two numbers are the entire
# arithmetic of this script.
PAGE_MAX = 1000
TIER2_PER_MINUTE = 20

# Which subscriptions pay out once per channel the app belongs to, and which
# pay out once per person who wanted something. The first group is what turns a
# large footprint into an event volume problem; the second is what a narrowed
# app should be left holding.
EVENT_SCALING = {
    "message.channels": ("membership",
                         "one delivery per message in every public channel the app is in"),
    "message.groups": ("membership", "the same, for private channels"),
    "message.mpim": ("membership", "the same, for group DMs"),
    "member_joined_channel": ("membership",
                              "one delivery per join across the whole footprint"),
    "member_left_channel": ("membership", "one delivery per leave, likewise"),
    "reaction_added": ("membership",
                       "one delivery per reaction across the whole footprint"),
    "channel_created": ("workspace",
                        "one per new channel, whether or not the app is in it"),
    "channel_deleted": ("workspace", "one per deletion, likewise"),
    "app_mention": ("demand", "one delivery when somebody asks for the app by name"),
    "message.im": ("demand", "one delivery per direct message to the app"),
    "app_home_opened": ("demand", "one delivery when somebody opens the app"),
}


def sweep_cost(count, runs_per_hour=1, page_size=PAGE_MAX,
               per_minute=TIER2_PER_MINUTE):
    """Price one inventory, and then the habit of taking it. Pure.

    The membership count on its own is not a finding. Four pages is nothing
    once an hour and is over the tier once a minute, so the number that
    matters is pages multiplied by frequency, held against the budget.
    """
    size = max(int(page_size or 0), 1)
    n = max(int(count or 0), 0)
    pages = max(1, (n + size - 1) // size)
    budget = max(int(per_minute or 0), 1) * 60
    per_hour = pages * max(int(runs_per_hour or 0), 0)
    share = round(per_hour * 100.0 / budget, 1)
    if per_hour > budget:
        verdict = "over-budget"
    elif share >= 50.0:
        verdict = "tight"
    else:
        verdict = "affordable"
    return {"pages": pages, "requests_per_hour": per_hour,
            "budget_per_hour": budget, "share_percent": share,
            "verdict": verdict}


def footprint_verdict(member_of, workspace_total=None, budget=200):
    """Hold the membership count against the size of the workspace. Pure.

    Returns (verdict, detail). A count without a denominator is a number
    somebody will argue with; a share is a number somebody will act on.
    """
    if member_of is None or int(member_of) < 0:
        return ("unmeasured",
                "the sweep did not complete, so there is no footprint to judge. "
                "That failure is itself worth reading: a read-only count that "
                "cannot finish describes an application that asks for more.")

    n = int(member_of)
    if not workspace_total:
        share_text = "no workspace total available"
        share = None
    else:
        share = round(n * 100.0 / int(workspace_total), 1)
        share_text = "%s%% of the %d channel(s) this token can enumerate" % (
            share, int(workspace_total))

    if share is not None and share >= 90.0:
        return ("near-total",
                "%d channel(s), %s. A footprint that tracks the workspace is a "
                "rule inviting the app rather than people choosing it." % (n, share_text))
    if n > int(budget):
        return ("broad",
                "%d channel(s), over the budget of %d, %s. Every paginated read "
                "of this list is multi-page and every membership scaled "
                "subscription is multiplied by it." % (n, int(budget), share_text))
    return ("narrow", "%d channel(s), %s" % (n, share_text))


def event_load(member_of, subscriptions):
    """Sort subscriptions by what their delivery volume is a function of. Pure.

    Membership scaled events are the ones a large footprint multiplies. Demand
    scaled events are the ones that stay the same size when the app is invited
    to another thousand channels, which is why narrowing to them is the repair
    that does not need revisiting.
    """
    n = max(int(member_of or 0), 0)
    order = {"membership": 0, "workspace": 1, "demand": 2, "unknown": 3}
    out = []
    for name in subscriptions or []:
        key = str(name or "").strip()
        if not key:
            continue
        scaling, detail = EVENT_SCALING.get(
            key, ("unknown", "not a subscription this script knows how to place"))
        if scaling == "membership":
            detail = "%s, so %d channel(s) of it" % (detail, n)
        out.append((key, scaling, detail))
    out.sort(key=lambda row: (order.get(row[1], 3), row[0]))
    return out


def paged_count(session, method, params, key, max_pages):
    """Count everything the method returns, following every cursor. GET only.

    Returns (count, pages, seconds, note). The sweep times itself rather than
    estimating from the count, because whether this workspace's inventory
    already takes longer than the interval it runs on is the finding.
    """
    total, pages, cursor, note = 0, 0, "", ""
    started = time.monotonic()
    while pages < max_pages:
        page = dict(params, limit=str(PAGE_MAX))
        if cursor:
            page["cursor"] = cursor
        r = session.get(API + method, params=page, timeout=30)
        body = r.json()
        pages += 1
        if body.get("ok") is not True:
            err = body.get("error")
            note = "stopped on %s after %d page(s)" % (err, pages)
            if err == "ratelimited":
                note += ("; Retry-After was %s, and a read-only count that gets "
                         "throttled has already proved the point"
                         % r.headers.get("Retry-After", "absent"))
            return (-1, pages, time.monotonic() - started, note)
        total += len(body.get(key) or [])
        cursor = (body.get("response_metadata") or {}).get("next_cursor") or ""
        if not cursor:
            return (total, pages, time.monotonic() - started,
                    "no cursor left unfollowed")
    return (total, pages, time.monotonic() - started,
            "stopped at the %d page cap, so the real count is higher" % max_pages)


def main():
    ap = argparse.ArgumentParser(description=__doc__)
    ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
                    help="environment variable holding the bot token")
    ap.add_argument("--user", default="",
                    help="bot user ID; auth.test supplies it when omitted")
    ap.add_argument("--budget", type=int, default=200,
                    help="channel count above which the footprint is called broad")
    ap.add_argument("--runs-per-hour", type=int, default=1,
                    help="how often the application takes this inventory")
    ap.add_argument("--subscription", action="append", default=[],
                    help="an event your app subscribes to; repeatable")
    ap.add_argument("--max-pages", type=int, default=40,
                    help="cap on both sweeps, so an audit cannot become the problem")
    args = ap.parse_args()

    token = os.environ.get(args.token_env)
    if not token:
        log.error("set %s (channels:read, groups:read and users:read are enough)",
                  args.token_env)
        return 2

    s = requests.Session()
    s.headers.update({"Authorization": "Bearer " + token})

    who = s.get(API + "auth.test", timeout=30).json()
    if who.get("ok") is not True:
        log.error("auth.test answered 200 with ok: false, error=%s", who.get("error"))
        return 2
    user = args.user or who.get("user_id") or ""
    log.info("identity   %s in %s", user, who.get("team"))

    mine, pages, seconds, note = paged_count(
        s, "users.conversations",
        {"user": user, "types": "public_channel,private_channel"},
        "channels", args.max_pages)
    total, _, _, _ = paged_count(
        s, "conversations.list",
        {"types": "public_channel,private_channel", "exclude_archived": "true"},
        "channels", args.max_pages)

    verdict, detail = footprint_verdict(None if mine < 0 else mine, total, args.budget)
    log.info("sweep      %d page(s) in %.1fs, %s", pages, seconds, note)
    (log.info if verdict == "narrow" else log.warning)(
        "footprint  %-14s %s", verdict, detail)

    cost = sweep_cost(max(mine, 0), args.runs_per_hour)
    (log.info if cost["verdict"] == "affordable" else log.warning)(
        "cost       %-14s %d request(s)/hour against a Tier 2 budget of %d (%.1f%%)",
        cost["verdict"], cost["requests_per_hour"], cost["budget_per_hour"],
        cost["share_percent"])

    for name, scaling, why in event_load(max(mine, 0), args.subscription):
        (log.warning if scaling == "membership" else log.info)(
            "events     %-14s %s: %s", scaling, name, why)

    if verdict in ("broad", "near-total") or cost["verdict"] != "affordable":
        log.warning("  repair: cache the channel inventory with a TTL and refresh "
                    "it from channel_created, channel_deleted and "
                    "member_joined_channel rather than re-enumerating per run")
        log.warning("  repair: narrow membership scaled subscriptions to "
                    "app_mention and message.im, so event volume tracks demand "
                    "instead of the footprint")
        log.warning("  repair: stop the automation that invites this app to every "
                    "new channel; every other repair is undone by it")
        return 1
    return 0


if __name__ == "__main__":
    sys.exit(main())
slack-channel-footprint.mjs
/**
 * Measure the bot's channel footprint and price the sweep that reads it.
 *
 * Read only. One paginated users.conversations counts the memberships and
 * times itself, one bounded conversations.list supplies the workspace
 * denominator. Nothing joins, leaves or invites: this reports how large the
 * footprint is, what one inventory costs against the method's tier, and which
 * event subscriptions grow with membership rather than with demand.
 */

const API = 'https://slack.com/api/';

// Cursor paginated with a maximum limit of 1000, and Tier 2: roughly 20
// requests a minute per method, per workspace, per app.
const PAGE_MAX = 1000;
const TIER2_PER_MINUTE = 20;

// Which subscriptions pay out once per channel the app belongs to, and which
// pay out once per person who wanted something.
const EVENT_SCALING = new Map([
  ['message.channels', ['membership',
    'one delivery per message in every public channel the app is in']],
  ['message.groups', ['membership', 'the same, for private channels']],
  ['message.mpim', ['membership', 'the same, for group DMs']],
  ['member_joined_channel', ['membership',
    'one delivery per join across the whole footprint']],
  ['member_left_channel', ['membership', 'one delivery per leave, likewise']],
  ['reaction_added', ['membership',
    'one delivery per reaction across the whole footprint']],
  ['channel_created', ['workspace',
    'one per new channel, whether or not the app is in it']],
  ['channel_deleted', ['workspace', 'one per deletion, likewise']],
  ['app_mention', ['demand', 'one delivery when somebody asks for the app by name']],
  ['message.im', ['demand', 'one delivery per direct message to the app']],
  ['app_home_opened', ['demand', 'one delivery when somebody opens the app']],
]);

/**
 * Price one inventory, and then the habit of taking it. Pure.
 * Four pages is nothing once an hour and over the tier once a minute, so the
 * number that matters is pages multiplied by frequency.
 */
export function sweepCost(count, runsPerHour = 1, pageSize = PAGE_MAX,
  perMinute = TIER2_PER_MINUTE) {
  const size = Math.max(Number(pageSize) || 0, 1);
  const n = Math.max(Number(count) || 0, 0);
  const pages = Math.max(1, Math.ceil(n / size));
  const budget = Math.max(Number(perMinute) || 0, 1) * 60;
  const perHour = pages * Math.max(Number(runsPerHour) || 0, 0);
  const share = Math.round((perHour * 1000.0) / budget) / 10;
  let verdict = 'affordable';
  if (perHour > budget) verdict = 'over-budget';
  else if (share >= 50.0) verdict = 'tight';
  return {
    pages, requestsPerHour: perHour, budgetPerHour: budget,
    sharePercent: share, verdict,
  };
}

/**
 * Hold the membership count against the size of the workspace. Pure.
 * A count without a denominator is a number somebody will argue with.
 */
export function footprintVerdict(memberOf, workspaceTotal = null, budget = 200) {
  if (memberOf === null || memberOf === undefined || Number(memberOf) < 0) {
    return ['unmeasured',
      'the sweep did not complete, so there is no footprint to judge. That ' +
      'failure is itself worth reading: a read-only count that cannot finish ' +
      'describes an application that asks for more.'];
  }

  const n = Number(memberOf);
  let share = null;
  let shareText = 'no workspace total available';
  if (workspaceTotal) {
    share = Math.round((n * 1000.0) / Number(workspaceTotal)) / 10;
    shareText = `${share}% of the ${Number(workspaceTotal)} channel(s) this token can enumerate`;
  }

  if (share !== null && share >= 90.0) {
    return ['near-total',
      `${n} channel(s), ${shareText}. A footprint that tracks the workspace is ` +
      'a rule inviting the app rather than people choosing it.'];
  }
  if (n > Number(budget)) {
    return ['broad',
      `${n} channel(s), over the budget of ${Number(budget)}, ${shareText}. ` +
      'Every paginated read of this list is multi-page and every membership ' +
      'scaled subscription is multiplied by it.'];
  }
  return ['narrow', `${n} channel(s), ${shareText}`];
}

/**
 * Sort subscriptions by what their delivery volume is a function of. Pure.
 * Demand scaled events stay the same size when the app is invited to another
 * thousand channels, which is why narrowing to them is the durable repair.
 */
export function eventLoad(memberOf, subscriptions) {
  const n = Math.max(Number(memberOf) || 0, 0);
  const order = { membership: 0, workspace: 1, demand: 2, unknown: 3 };
  const out = [];
  for (const name of subscriptions ?? []) {
    const key = String(name ?? '').trim();
    if (!key) continue;
    const [scaling, base] = EVENT_SCALING.get(key)
      ?? ['unknown', 'not a subscription this script knows how to place'];
    const detail = scaling === 'membership'
      ? `${base}, so ${n} channel(s) of it` : base;
    out.push([key, scaling, detail]);
  }
  out.sort((a, b) => (order[a[1]] ?? 3) - (order[b[1]] ?? 3)
    || a[0].localeCompare(b[0]));
  return out;
}

async function pagedCount(token, method, params, key, maxPages) {
  let total = 0;
  let pages = 0;
  let cursor = '';
  const started = Date.now();
  while (pages < maxPages) {
    const page = new URLSearchParams({ ...params, limit: String(PAGE_MAX) });
    if (cursor) page.set('cursor', cursor);
    const res = await fetch(`${API}${method}?${page}`, {
      headers: { Authorization: `Bearer ${token}` },
    });
    const body = await res.json();
    pages += 1;
    if (body.ok !== true) {
      let note = `stopped on ${body.error} after ${pages} page(s)`;
      if (body.error === 'ratelimited') {
        note += `; Retry-After was ${res.headers.get('retry-after') ?? 'absent'}, ` +
          'and a read-only count that gets throttled has already proved the point';
      }
      return [-1, pages, (Date.now() - started) / 1000, note];
    }
    total += (body.channels ?? []).length;
    cursor = body.response_metadata?.next_cursor ?? '';
    if (!cursor) {
      return [total, pages, (Date.now() - started) / 1000, 'no cursor left unfollowed'];
    }
  }
  return [total, pages, (Date.now() - started) / 1000,
    `stopped at the ${maxPages} page cap, so the real count is higher`];
}

function arg(args, name, fallback = null) {
  const i = args.indexOf(name);
  return i === -1 ? fallback : args[i + 1];
}

function argAll(args, name) {
  const out = [];
  for (let i = 0; i < args.length; i += 1) {
    if (args[i] === name && args[i + 1] !== undefined) out.push(args[i + 1]);
  }
  return out;
}

async function main() {
  const args = process.argv.slice(2);
  const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
  const token = process.env[tokenEnv];
  if (!token) {
    console.error(`set ${tokenEnv} (channels:read, groups:read and users:read are enough)`);
    process.exitCode = 2;
    return;
  }
  const budget = Number(arg(args, '--budget', 200));
  const runsPerHour = Number(arg(args, '--runs-per-hour', 1));
  const maxPages = Number(arg(args, '--max-pages', 40));

  const whoRes = await fetch(`${API}auth.test`, {
    headers: { Authorization: `Bearer ${token}` },
  });
  const who = await whoRes.json();
  if (who.ok !== true) {
    console.error(`auth.test answered 200 with ok: false, error=${who.error}`);
    process.exitCode = 2;
    return;
  }
  const user = arg(args, '--user', '') || who.user_id || '';
  console.log(`identity   ${user} in ${who.team}`);

  const [mine, pages, seconds, note] = await pagedCount(token, 'users.conversations',
    { user, types: 'public_channel,private_channel' }, 'channels', maxPages);
  const [total] = await pagedCount(token, 'conversations.list',
    { types: 'public_channel,private_channel', exclude_archived: 'true' },
    'channels', maxPages);

  const [verdict, detail] = footprintVerdict(mine < 0 ? null : mine, total, budget);
  console.log(`sweep      ${pages} page(s) in ${seconds.toFixed(1)}s, ${note}`);
  (verdict === 'narrow' ? console.log : console.warn)(
    `footprint  ${verdict.padEnd(14)} ${detail}`);

  const cost = sweepCost(Math.max(mine, 0), runsPerHour);
  (cost.verdict === 'affordable' ? console.log : console.warn)(
    `cost       ${cost.verdict.padEnd(14)} ${cost.requestsPerHour} request(s)/hour ` +
    `against a Tier 2 budget of ${cost.budgetPerHour} (${cost.sharePercent}%)`);

  for (const [name, scaling, why] of eventLoad(Math.max(mine, 0),
    argAll(args, '--subscription'))) {
    (scaling === 'membership' ? console.warn : console.log)(
      `events     ${scaling.padEnd(14)} ${name}: ${why}`);
  }

  if (verdict === 'broad' || verdict === 'near-total' || cost.verdict !== 'affordable') {
    console.warn('  repair: cache the channel inventory with a TTL and refresh it ' +
      'from channel_created, channel_deleted and member_joined_channel rather ' +
      'than re-enumerating per run');
    console.warn('  repair: narrow membership scaled subscriptions to app_mention ' +
      'and message.im, so event volume tracks demand instead of the footprint');
    console.warn('  repair: stop the automation that invites this app to every new ' +
      'channel; every other repair is undone by it');
    process.exitCode = 1;
  }
}

// Only run when invoked directly, so importing this module in the tests does
// not execute main() and fail the file on a missing token.
if (import.meta.url === `file://${process.argv[1]}`) {
  main().catch((err) => { console.error(err.message); process.exitCode = 2; });
}

Add a test

The tests pin the arithmetic rather than the wording, because the arithmetic is the argument. Four thousand memberships is four pages, four pages once an hour is affordable and four pages a hundred times an hour is over a Tier 2 budget, and the same footprint produces all three verdicts depending only on how often somebody takes the inventory. A sweep that did not finish is asserted to return unmeasured rather than a small number, which is the one place this script could lie confidently.

test_slack_channel_footprint.py
from slack_channel_footprint import event_load, footprint_verdict, sweep_cost


def test_one_inventory_of_four_thousand_channels_is_four_pages():
    assert sweep_cost(4000)["pages"] == 4
    assert sweep_cost(1)["pages"] == 1
    assert sweep_cost(0)["pages"] == 1


def test_the_same_footprint_is_cheap_hourly_and_ruinous_per_minute():
    assert sweep_cost(4000, runs_per_hour=1)["verdict"] == "affordable"
    assert sweep_cost(4000, runs_per_hour=360)["verdict"] == "over-budget"


def test_the_tier_2_budget_is_twelve_hundred_an_hour():
    cost = sweep_cost(4000, runs_per_hour=1)
    assert cost["budget_per_hour"] == 1200
    assert cost["requests_per_hour"] == 4


def test_the_halfway_mark_is_reported_as_tight_rather_than_fine():
    cost = sweep_cost(4000, runs_per_hour=150)
    assert cost["requests_per_hour"] == 600
    assert cost["verdict"] == "tight"


def test_a_footprint_that_tracks_the_workspace_is_its_own_verdict():
    verdict, detail = footprint_verdict(3914, 4102)
    assert verdict == "near-total"
    assert "95.4%" in detail


def test_a_large_footprint_in_a_much_larger_workspace_is_merely_broad():
    verdict, detail = footprint_verdict(3914, 40000)
    assert verdict == "broad"
    assert "3914" in detail


def test_a_small_footprint_is_left_alone():
    assert footprint_verdict(12, 4102)[0] == "narrow"
    assert footprint_verdict(200, 4102)[0] == "narrow"
    assert footprint_verdict(201, 4102)[0] == "broad"


def test_a_sweep_that_did_not_finish_is_never_reported_as_a_count():
    verdict, detail = footprint_verdict(None, 4102)
    assert verdict == "unmeasured"
    assert "did not complete" in detail
    assert footprint_verdict(-1, 4102)[0] == "unmeasured"


def test_a_missing_denominator_is_said_out_loud():
    assert "no workspace total" in footprint_verdict(12, None)[1]


def test_membership_scaled_events_sort_first_and_carry_the_multiplier():
    rows = event_load(3914, ["app_mention", "message.channels", "channel_created"])
    assert [r[0] for r in rows] == ["message.channels", "channel_created", "app_mention"]
    assert rows[0][1] == "membership"
    assert "3914" in rows[0][2]


def test_demand_scaled_events_are_not_multiplied_by_the_footprint():
    rows = event_load(3914, ["app_mention"])
    assert rows[0][1] == "demand"
    assert "3914" not in rows[0][2]


def test_an_unrecognised_subscription_is_placed_last_and_not_guessed_at():
    rows = event_load(10, ["app_mention", "team_join_or_something"])
    assert rows[-1][1] == "unknown"
    assert event_load(10, ["", None]) == []
slack-channel-footprint.test.mjs
import { test } from 'node:test';
import assert from 'node:assert/strict';
import { eventLoad, footprintVerdict, sweepCost } from './slack-channel-footprint.mjs';

test('one inventory of four thousand channels is four pages', () => {
  assert.equal(sweepCost(4000).pages, 4);
  assert.equal(sweepCost(1).pages, 1);
  assert.equal(sweepCost(0).pages, 1);
});

test('the same footprint is cheap hourly and ruinous per minute', () => {
  assert.equal(sweepCost(4000, 1).verdict, 'affordable');
  assert.equal(sweepCost(4000, 360).verdict, 'over-budget');
});

test('the tier 2 budget is twelve hundred an hour', () => {
  const cost = sweepCost(4000, 1);
  assert.equal(cost.budgetPerHour, 1200);
  assert.equal(cost.requestsPerHour, 4);
});

test('the halfway mark is reported as tight rather than fine', () => {
  const cost = sweepCost(4000, 150);
  assert.equal(cost.requestsPerHour, 600);
  assert.equal(cost.verdict, 'tight');
});

test('a footprint that tracks the workspace is its own verdict', () => {
  const [verdict, detail] = footprintVerdict(3914, 4102);
  assert.equal(verdict, 'near-total');
  assert.match(detail, /95\.4%/);
});

test('a large footprint in a much larger workspace is merely broad', () => {
  const [verdict, detail] = footprintVerdict(3914, 40000);
  assert.equal(verdict, 'broad');
  assert.match(detail, /3914/);
});

test('a small footprint is left alone', () => {
  assert.equal(footprintVerdict(12, 4102)[0], 'narrow');
  assert.equal(footprintVerdict(200, 4102)[0], 'narrow');
  assert.equal(footprintVerdict(201, 4102)[0], 'broad');
});

test('a sweep that did not finish is never reported as a count', () => {
  const [verdict, detail] = footprintVerdict(null, 4102);
  assert.equal(verdict, 'unmeasured');
  assert.match(detail, /did not complete/);
  assert.equal(footprintVerdict(-1, 4102)[0], 'unmeasured');
});

test('a missing denominator is said out loud', () => {
  assert.match(footprintVerdict(12, null)[1], /no workspace total/);
});

test('membership scaled events sort first and carry the multiplier', () => {
  const rows = eventLoad(3914, ['app_mention', 'message.channels', 'channel_created']);
  assert.deepEqual(rows.map((r) => r[0]),
    ['message.channels', 'channel_created', 'app_mention']);
  assert.equal(rows[0][1], 'membership');
  assert.match(rows[0][2], /3914/);
});

test('demand scaled events are not multiplied by the footprint', () => {
  const rows = eventLoad(3914, ['app_mention']);
  assert.equal(rows[0][1], 'demand');
  assert.doesNotMatch(rows[0][2], /3914/);
});

test('an unrecognised subscription is placed last and not guessed at', () => {
  const rows = eventLoad(10, ['app_mention', 'team_join_or_something']);
  assert.equal(rows[rows.length - 1][1], 'unknown');
  assert.deepEqual(eventLoad(10, ['', null]), []);
});

FAQ

How many channels is too many for a bot?

There is no number in the documentation, because the ceiling is not on membership. It is on what membership costs you: pages per inventory against a Tier 2 budget of about twenty requests a minute, and event deliveries per minute against a three second acknowledgement window. The script defaults to flagging above two hundred, which is a prompt to do the arithmetic rather than a limit Slack enforces.

Can I make the sweep cheaper by asking for a bigger page?

No. The maximum limit on these methods is 1000 and asking for more is rejected outright with invalid_limit rather than clamped, so the page count is fixed by the membership count. The only lever left is frequency, which is why the repair is a cache with a TTL and an event-driven refresh rather than a tuning parameter.

The bot needs to be in all those channels. What then?

Then keep the membership and change what it costs. Narrowing event subscriptions from message.channels to app_mention is the largest single win available, because it converts a bill that scales with the footprint into one that scales with how often somebody actually wants the app to do something. Membership stops being a multiplier the moment nothing subscribes per channel.

Why does the script count the workspace as well as the bot?

Because the same count means different things in different workspaces. Three thousand channels out of four thousand is a rule inviting the app automatically, and the conversation is with whoever wrote that rule. Three thousand out of forty thousand is a large but deliberate footprint, and the conversation is about caching. One number cannot tell those apart.

The audit itself got rate limited. Is that a bug in the script?

It is the finding. The script does the cheapest possible version of what your application does, once, with two paginated reads and a page cap so it cannot become the problem it is describing. If that gets throttled, the application, which does this plus its real work on a shared per-method bucket, is over the line by a wider margin than the audit is.

Related field notes

Sources

Every figure in this note is traced to one of these. Prices are list rates and change — check them for your own region before acting.

Stuck on a tricky one?

If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.