Diagnostic Slack
Block Kit field ceilings: some reject, some truncate
The alert works for months. Then a service produces a stack trace instead of a one-line error, the formatter interpolates it into a section, and the message is refused with invalid_blocks. The block is the right shape. The message is nowhere near 50 blocks. One field is 3,140 characters long and the ceiling is 3,000.
The confusing part comes next: some fields behave completely differently. A message text of 41,000 characters is not refused, it is cut, and nobody is told. So the same class of mistake produces a loud failure in one field and a silent one in another, which is why this never quite feels like one problem.
Block Kit enforces a character ceiling on every text-bearing field, and exceeding one invalidates the whole message. The ones that bite are section.text and each entry of section.fields at 3,000, header.text at 150, a button label and a context element at 75, an option label at 75 and its value at 150, an input label at 2,000, and block_id and action_id at 255.
The message-level text field is the exception that causes the confusion: at around 40,000 characters it is truncated rather than rejected. So the behaviour on overflow is per field, not global, and the two behaviours want two different responses. A rejection is an incident you find immediately. A truncation is a message that looks complete and is not, which nobody finds at all.
This is a measurement, not an inspection, so it runs with no token: hand the script a payload and it walks every text-bearing field, holds it against its ceiling, and says which fields are over, which are within ten percent, and for each one whether Slack rejects or cuts.
The problem in plain words
What makes this expensive is that it fires on content rather than on code. The formatter is correct. The template is correct. The tests pass, because the fixtures in the tests are short. The message breaks the day something real is longer than the thing you wrote the test with, and the things that get long are exactly the things you most want in the alert: a stack trace, a diff, a query plan, a list of the forty failing checks.
Then the two behaviours pull the investigation in opposite directions. When a section is over, the failure is loud and immediate and lands in the same invalid_blocks bucket as every structural fault, so the first hour goes into checking the shape of a payload whose shape is perfect. When a message text is over, there is no failure at all: the message posts, the last third is gone, and the report reads as complete because the part that would have said otherwise is the part that was removed.
The small ceilings catch people the other way round. Nobody expects a header to be limited to 150 characters, because headers are short by convention, so the one place a header is built from a user-supplied string is the one place it is unbounded. A context element at 75 characters is smaller than a single sentence. And action_id at 255 catches teams who encode state into the identifier, which works beautifully until the encoded state is a long one.
None of this is visible from a message that worked. A field sitting at 2,900 of 3,000 characters renders identically to one at 300. The distance to the ceiling is invisible in the product and only exists as a number somebody has to go and measure.
Why it happens
Reject and truncate are the same mistake with opposite symptoms, and the report has to say which. A finding that says "over the limit" and stops sends a team looking for an error that, for half these fields, was never raised. Every row this script prints carries the behaviour beside the number.
It is a measurement, so it needs nothing from Slack. The ceilings are published and the payload is yours. Running this against a file is a unit test for your message builder, and that is the right place for it: the check that runs before the send costs nothing, and the one that runs after costs a message.
Proximity is the finding, not just overflow. A field at 2,950 of 3,000 has already broken for somebody whose stack trace was fifty characters longer than the one you sampled. The default flags anything inside ten percent of its ceiling, because the interesting report is the one delivered before the incident.
The path matters more than the number. blocks[4].fields[7].text tells you which interpolation to bound. "A field is too long" tells you to go and read a payload. The walker keeps the full path on every row for exactly that reason.
Truncation must be defensive and visible. Cutting at the ceiling produces a message that ends mid-word and claims to be whole. Cutting below it and appending an explicit marker plus a link to the full content produces a message that is honest about being a summary, and the difference is one line in the builder.
Never interpolate unbounded output into a block. That is the rule the whole note reduces to. A log line, a stack trace, a user-supplied name or an LLM response has no length you control, and a block field does. Bound it where the two meet.
The fix, as a flow
This chain goes red in the middle and then carries on, because the rejection is only half of it: the same class of mistake in a different field posts successfully and quietly loses the end of the message. That split is why this never feels like one problem. The fix branch sorts by what Slack does on overflow rather than by how far over the field is.
How to fix it
Measure the payload, with no token and no call
--payload takes the JSON your builder produced and walks it. This belongs in the test suite for the message builder, run against the longest fixture you can find rather than the tidiest one.
Keep the full path on every row
measure_blocks reports blocks[4].fields[7].text rather than "a section field". The path is what tells you which interpolation to bound, and it is the difference between a fix and a search.
Read the behaviour column, not only the number
ceiling_verdict returns over-rejects or over-truncates, never a bare "over". One of those explains an error you saw; the other explains a message that looked fine and was not, and going looking for an error that was never raised is a wasted afternoon.
Treat ten percent of headroom as a finding
Anything inside the margin comes back at-risk. That is the row worth acting on, because it is the one that has not cost anything yet. A payload with no at-risk rows is a payload with genuine slack in it.
Let the message-level text be the exception it is
message.text is measured at about 40,000 characters with a truncate behaviour, and the report says so explicitly. It is the one field where being over does not stop the send, and the only one where the damage is invisible.
Bound at the builder, with a marker and a link
Cut below the ceiling, append something that says it was cut, and link to the whole thing. Uploading long content as a file and referencing it from a short block is the version of this that scales, and it is the only version that keeps the full text available at all.
How to check it worked
Bound the interpolation, run the measurement again on the same fixture, and the worst row should move from over-rejects to ok without anything being sent.
python3 slack_block_field_limits.py --payload alert-with-trace.json
# payload 6 block(s), 14 measured field(s)
# worst reject blocks[2].text is 3140 of 3000 (section.text): Slack refuses
# the entire message on this field alone
# at-risk blocks[0].text 142 of 150 (header.text), 94.7% of the ceiling
# summary section.text max 3140/3000, header.text max 142/150, action_id max 31/255
# repair: truncate below the ceiling in the builder, append an explicit marker, and
# link to the full content rather than inlining it
The full code
One table and three pure functions. The table is the block reference expressed as ceilings and, for each ceiling, what Slack does when you cross it. measure_blocks walks a payload and keeps the full path on every row. ceiling_verdict refuses to say a bare "over": it says over-rejects or over-truncates, because those are two different afternoons. worst picks the field that decides the message's fate, preferring a rejection over a truncation when both are present.
"""Hold every text-bearing field in a Block Kit payload against its ceiling.
Read only, and with --payload it holds no token and calls nothing. Nothing is
sent from here: discovering which field is too long by posting the message is
the loop this replaces. The ceilings are published and the payload is yours, so
this is a measurement rather than a request.
"""
import argparse
import json
import logging
import os
import sys
import requests
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(levelname)s %(message)s")
log = logging.getLogger("slack_block_field_limits")
API = "https://slack.com/api/"
# field kind -> (ceiling, what Slack does when you cross it).
#
# The behaviour column is the reason this table exists rather than a set of
# constants. Every reject row is an invalid_blocks you will see within seconds.
# The one truncate row is a message that posts, looks complete, and is not, and
# nobody is told. Reporting those two as one thing sends half the readers
# looking for an error that was never raised.
LIMITS = {
"message.text": (40000, "truncate"),
"section.text": (3000, "reject"),
"section.field": (3000, "reject"),
"header.text": (150, "reject"),
"context.text": (75, "reject"),
"button.text": (75, "reject"),
"button.url": (3000, "reject"),
"button.value": (2000, "reject"),
"option.text": (75, "reject"),
"option.value": (150, "reject"),
"placeholder.text": (150, "reject"),
"input.label": (2000, "reject"),
"input.hint": (2000, "reject"),
"image.alt_text": (2000, "reject"),
"image.title": (2000, "reject"),
"block_id": (255, "reject"),
"action_id": (255, "reject"),
}
TEXT_TYPES = {"plain_text", "mrkdwn"}
# Inside this share of a ceiling, a field has not failed yet and will. The
# report that is worth reading is the one delivered before the incident.
MARGIN = 0.1
def _length(value):
"""The measured length of a text object or a bare string. Pure."""
if isinstance(value, dict):
return len(str(value.get("text") or ""))
if isinstance(value, str):
return len(value)
return None
def _add(rows, path, kind, value):
n = _length(value)
if n is None:
return
limit, behaviour = LIMITS[kind]
rows.append((path, kind, n, limit, behaviour))
def _measure_option(opt, path, rows):
if not isinstance(opt, dict):
return
_add(rows, path + ".text", "option.text", opt.get("text"))
if opt.get("value") is not None:
_add(rows, path + ".value", "option.value", opt.get("value"))
def _measure_element(el, path, rows):
"""Everything measurable inside one interactive element. Pure."""
if not isinstance(el, dict):
return
etype = str(el.get("type") or "")
if el.get("action_id") is not None:
_add(rows, path + ".action_id", "action_id", el.get("action_id"))
if etype == "button":
_add(rows, path + ".text", "button.text", el.get("text"))
if el.get("url") is not None:
_add(rows, path + ".url", "button.url", el.get("url"))
if el.get("value") is not None:
_add(rows, path + ".value", "button.value", el.get("value"))
elif etype == "image":
_add(rows, path + ".alt_text", "image.alt_text", el.get("alt_text"))
elif etype in TEXT_TYPES:
_add(rows, path + ".text", "context.text", el)
if el.get("placeholder") is not None:
_add(rows, path + ".placeholder", "placeholder.text", el.get("placeholder"))
for i, opt in enumerate(el.get("options") or []):
_measure_option(opt, "%s.options[%d]" % (path, i), rows)
for gi, group in enumerate(el.get("option_groups") or []):
if not isinstance(group, dict):
continue
_add(rows, "%s.option_groups[%d].label" % (path, gi), "placeholder.text",
group.get("label"))
for i, opt in enumerate(group.get("options") or []):
_measure_option(opt, "%s.option_groups[%d].options[%d]" % (path, gi, i), rows)
def _measure_block(block, path, rows):
if not isinstance(block, dict):
return
if block.get("block_id") is not None:
_add(rows, path + ".block_id", "block_id", block.get("block_id"))
btype = str(block.get("type") or "")
if btype == "section":
_add(rows, path + ".text", "section.text", block.get("text"))
for i, field in enumerate(block.get("fields") or []):
_add(rows, "%s.fields[%d].text" % (path, i), "section.field", field)
if block.get("accessory") is not None:
_measure_element(block["accessory"], path + ".accessory", rows)
elif btype == "header":
_add(rows, path + ".text", "header.text", block.get("text"))
elif btype == "image":
_add(rows, path + ".alt_text", "image.alt_text", block.get("alt_text"))
if block.get("title") is not None:
_add(rows, path + ".title", "image.title", block.get("title"))
elif btype == "context":
for i, el in enumerate(block.get("elements") or []):
_measure_element(el, "%s.elements[%d]" % (path, i), rows)
elif btype == "input":
_add(rows, path + ".label", "input.label", block.get("label"))
if block.get("hint") is not None:
_add(rows, path + ".hint", "input.hint", block.get("hint"))
if block.get("element") is not None:
_measure_element(block["element"], path + ".element", rows)
else:
for i, el in enumerate(block.get("elements") or []):
_measure_element(el, "%s.elements[%d]" % (path, i), rows)
if block.get("accessory") is not None:
_measure_element(block["accessory"], path + ".accessory", rows)
def measure_blocks(blocks, text=None):
"""Every length-limited field in a payload, with its full path. Pure.
Rows are (path, kind, length, limit, behaviour). The path is the point:
blocks[4].fields[7].text names the interpolation to bound, while "a section
field is too long" only tells you to go and read a payload.
"""
rows = []
if text is not None:
_add(rows, "message.text", "message.text", text)
for i, block in enumerate(blocks or []):
_measure_block(block, "blocks[%d]" % i, rows)
return rows
def ceiling_verdict(length, kind, margin=MARGIN):
"""Where one field stands against its ceiling. Pure.
Returns (verdict, detail) and never a bare "over": over-rejects and
over-truncates are two different afternoons, and a report that merges them
sends half its readers hunting for an error that was never raised.
"""
known = LIMITS.get(kind)
if known is None:
return ("unmeasured", "%s has no ceiling in this table; check the block "
"reference by hand" % kind)
limit, behaviour = known
n = int(length or 0)
if n > limit and behaviour == "reject":
return ("over-rejects", "%d of %d characters: Slack refuses the entire message "
"on this field alone, with invalid_blocks and no "
"indication of which field" % (n, limit))
if n > limit:
return ("over-truncates", "%d of %d characters: this is not rejected, it is "
"cut. The message posts, looks complete, and the end "
"is gone with nobody told" % (n, limit))
if limit and n >= limit * (1 - margin):
return ("at-risk", "%d of %d characters, %.1f%% of the ceiling. This has not "
"failed yet and will, for somebody whose input is slightly "
"longer than yours" % (n, limit, 100.0 * n / limit))
return ("ok", "%d of %d characters" % (n, limit))
def worst(rows, margin=MARGIN):
"""The field that decides the message's fate. Pure.
A rejection outranks a truncation when both are present, because one of
them stops the send and the other only spoils it.
"""
order = {"over-rejects": 0, "over-truncates": 1, "at-risk": 2, "unmeasured": 3,
"ok": 4}
best = None
for row in rows or []:
path, kind, length, _, _ = row
verdict, detail = ceiling_verdict(length, kind, margin)
rank = (order.get(verdict, 5), -float(length or 0))
if best is None or rank < best[0]:
best = (rank, verdict, row, detail)
if best is None:
return ("clear", None, "no length-limited fields in this payload")
_, verdict, row, detail = best
if verdict == "ok":
return ("clear", row, "every measured field has room; the longest is %s at %s"
% (row[0], detail))
return (verdict, row, "%s (%s): %s" % (row[0], row[1], detail))
def summarise(rows):
"""The longest field observed per kind, for the report. Pure."""
out = {}
for path, kind, length, limit, behaviour in rows or []:
current = out.get(kind)
if current is None or length > current["length"]:
out[kind] = {"length": length, "limit": limit, "behaviour": behaviour,
"path": path}
return out
def audit(blocks, text, margin, label):
rows = measure_blocks(blocks, text)
log.info("payload %s: %d block(s), %d measured field(s)", label,
len(blocks or []), len(rows))
verdict, row, detail = worst(rows, margin)
(log.info if verdict == "clear" else log.warning)(
"worst %-16s %s", verdict, detail)
at_risk = 0
for path, kind, length, _, _ in rows:
state, why = ceiling_verdict(length, kind, margin)
if state == "at-risk":
at_risk += 1
log.warning("at-risk %-16s %s (%s): %s", "", path, kind, why)
for kind, info in sorted(summarise(rows).items()):
log.info("summary %-18s max %d of %d (%s), at %s", kind, info["length"],
info["limit"], info["behaviour"], info["path"])
return verdict, at_risk
def main():
ap = argparse.ArgumentParser(description=__doc__)
ap.add_argument("--payload", default="",
help="a JSON file holding a blocks array or a whole message; "
"with this the script needs no Slack token at all")
ap.add_argument("--token-env", default="SLACK_BOT_TOKEN",
help="environment variable holding the bot token")
ap.add_argument("--channel", action="append", default=[],
help="a channel your app posts into; repeatable")
ap.add_argument("--limit", type=int, default=200, help="messages read per channel")
ap.add_argument("--margin", type=float, default=MARGIN,
help="share of a ceiling within which a field counts as at risk")
args = ap.parse_args()
if args.payload:
raw = json.loads(open(args.payload, encoding="utf-8").read())
blocks = raw if isinstance(raw, list) else (raw or {}).get("blocks")
text = None if isinstance(raw, list) else (raw or {}).get("text")
verdict, at_risk = audit(blocks, text, args.margin, args.payload)
if verdict.startswith("over") or at_risk:
log.warning(" repair: truncate below the ceiling in the builder, append an "
"explicit marker so the cut is visible, and link to the full "
"content rather than inlining it")
log.warning(" repair: never interpolate unbounded output into a block; a "
"stack trace, a diff or a user supplied name has no length you "
"control and a block field does")
return 1
return 0
token = os.environ.get(args.token_env)
if not token:
log.error("set %s, or pass --payload to measure a file with no token at all",
args.token_env)
return 2
if not args.channel:
log.error("pass at least one --channel, or --payload")
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 unavailable %s", who.get("error"))
return 2
bot_id = who.get("bot_id") or ""
log.info("identity %s in %s", who.get("user_id"), who.get("team"))
findings = 0
for channel in args.channel:
body = s.get(API + "conversations.history", timeout=30,
params={"channel": channel, "limit": str(args.limit)}).json()
if body.get("ok") is not True:
log.warning("history unavailable %s: %s", channel, body.get("error"))
continue
for m in body.get("messages") or []:
if not m.get("blocks"):
continue
if bot_id and m.get("bot_id") != bot_id:
continue
verdict, at_risk = audit(m.get("blocks"), m.get("text"), args.margin,
"%s ts=%s" % (channel, m.get("ts")))
if verdict.startswith("over") or at_risk:
findings += 1
if findings:
log.warning(" repair: bound every interpolation in the message builder, at a "
"length below the ceiling, with a visible marker on the cut")
log.warning(" repair: for genuinely long content, upload it and reference it "
"from a short block instead of inlining it")
return 1
log.info("verdict clear every measured field has room left")
return 0
if __name__ == "__main__":
sys.exit(main())
/**
* Hold every text-bearing field in a Block Kit payload against its ceiling.
*
* Read only, and with --payload it holds no token and calls nothing. Nothing is
* sent from here: discovering which field is too long by posting the message is
* the loop this replaces. The ceilings are published and the payload is yours,
* so this is a measurement rather than a request.
*/
import { readFile } from 'node:fs/promises';
const API = 'https://slack.com/api/';
// field kind -> [ceiling, what Slack does when you cross it].
// The behaviour column is the reason this is a table rather than a set of
// constants: every reject row is an invalid_blocks you see in seconds, and the
// one truncate row is a message that posts, looks complete, and is not.
const LIMITS = new Map([
['message.text', [40000, 'truncate']],
['section.text', [3000, 'reject']],
['section.field', [3000, 'reject']],
['header.text', [150, 'reject']],
['context.text', [75, 'reject']],
['button.text', [75, 'reject']],
['button.url', [3000, 'reject']],
['button.value', [2000, 'reject']],
['option.text', [75, 'reject']],
['option.value', [150, 'reject']],
['placeholder.text', [150, 'reject']],
['input.label', [2000, 'reject']],
['input.hint', [2000, 'reject']],
['image.alt_text', [2000, 'reject']],
['image.title', [2000, 'reject']],
['block_id', [255, 'reject']],
['action_id', [255, 'reject']],
]);
const TEXT_TYPES = new Set(['plain_text', 'mrkdwn']);
const MARGIN = 0.1;
function lengthOf(value) {
if (value && typeof value === 'object' && !Array.isArray(value)) {
return String(value.text ?? '').length;
}
if (typeof value === 'string') return value.length;
return null;
}
function add(rows, path, kind, value) {
const n = lengthOf(value);
if (n === null) return;
const [limit, behaviour] = LIMITS.get(kind);
rows.push([path, kind, n, limit, behaviour]);
}
function measureOption(opt, path, rows) {
if (!opt || typeof opt !== 'object') return;
add(rows, `${path}.text`, 'option.text', opt.text);
if (opt.value !== undefined && opt.value !== null) {
add(rows, `${path}.value`, 'option.value', opt.value);
}
}
function measureElement(el, path, rows) {
if (!el || typeof el !== 'object' || Array.isArray(el)) return;
const etype = String(el.type ?? '');
if (el.action_id !== undefined && el.action_id !== null) {
add(rows, `${path}.action_id`, 'action_id', el.action_id);
}
if (etype === 'button') {
add(rows, `${path}.text`, 'button.text', el.text);
if (el.url !== undefined && el.url !== null) add(rows, `${path}.url`, 'button.url', el.url);
if (el.value !== undefined && el.value !== null) {
add(rows, `${path}.value`, 'button.value', el.value);
}
} else if (etype === 'image') {
add(rows, `${path}.alt_text`, 'image.alt_text', el.alt_text);
} else if (TEXT_TYPES.has(etype)) {
add(rows, `${path}.text`, 'context.text', el);
}
if (el.placeholder !== undefined && el.placeholder !== null) {
add(rows, `${path}.placeholder`, 'placeholder.text', el.placeholder);
}
(el.options ?? []).forEach((opt, i) => measureOption(opt, `${path}.options[${i}]`, rows));
(el.option_groups ?? []).forEach((group, gi) => {
if (!group || typeof group !== 'object') return;
add(rows, `${path}.option_groups[${gi}].label`, 'placeholder.text', group.label);
(group.options ?? []).forEach((opt, i) => measureOption(
opt, `${path}.option_groups[${gi}].options[${i}]`, rows));
});
}
function measureBlock(block, path, rows) {
if (!block || typeof block !== 'object' || Array.isArray(block)) return;
if (block.block_id !== undefined && block.block_id !== null) {
add(rows, `${path}.block_id`, 'block_id', block.block_id);
}
const btype = String(block.type ?? '');
if (btype === 'section') {
add(rows, `${path}.text`, 'section.text', block.text);
(block.fields ?? []).forEach((field, i) => add(
rows, `${path}.fields[${i}].text`, 'section.field', field));
if (block.accessory) measureElement(block.accessory, `${path}.accessory`, rows);
} else if (btype === 'header') {
add(rows, `${path}.text`, 'header.text', block.text);
} else if (btype === 'image') {
add(rows, `${path}.alt_text`, 'image.alt_text', block.alt_text);
if (block.title) add(rows, `${path}.title`, 'image.title', block.title);
} else if (btype === 'context') {
(block.elements ?? []).forEach((el, i) => measureElement(
el, `${path}.elements[${i}]`, rows));
} else if (btype === 'input') {
add(rows, `${path}.label`, 'input.label', block.label);
if (block.hint) add(rows, `${path}.hint`, 'input.hint', block.hint);
if (block.element) measureElement(block.element, `${path}.element`, rows);
} else {
(block.elements ?? []).forEach((el, i) => measureElement(
el, `${path}.elements[${i}]`, rows));
if (block.accessory) measureElement(block.accessory, `${path}.accessory`, rows);
}
}
/**
* Every length-limited field in a payload, with its full path. Pure.
* Rows are [path, kind, length, limit, behaviour].
*/
export function measureBlocks(blocks, text = null) {
const rows = [];
if (text !== null && text !== undefined) add(rows, 'message.text', 'message.text', text);
(blocks ?? []).forEach((block, i) => measureBlock(block, `blocks[${i}]`, rows));
return rows;
}
/**
* Where one field stands against its ceiling. Pure.
* Never returns a bare "over": over-rejects and over-truncates are two
* different afternoons.
*/
export function ceilingVerdict(length, kind, margin = MARGIN) {
const known = LIMITS.get(kind);
if (!known) {
return ['unmeasured',
`${kind} has no ceiling in this table; check the block reference by hand`];
}
const [limit, behaviour] = known;
const n = Number(length ?? 0);
if (n > limit && behaviour === 'reject') {
return ['over-rejects', `${n} of ${limit} characters: Slack refuses the entire ` +
'message on this field alone, with invalid_blocks and no indication of which field'];
}
if (n > limit) {
return ['over-truncates', `${n} of ${limit} characters: this is not rejected, it ` +
'is cut. The message posts, looks complete, and the end is gone with nobody told'];
}
if (limit && n >= limit * (1 - margin)) {
return ['at-risk', `${n} of ${limit} characters, ${(100 * n / limit).toFixed(1)}% ` +
'of the ceiling. This has not failed yet and will, for somebody whose input is ' +
'slightly longer than yours'];
}
return ['ok', `${n} of ${limit} characters`];
}
/**
* The field that decides the message's fate. Pure.
* A rejection outranks a truncation: one stops the send, the other spoils it.
*/
export function worst(rows, margin = MARGIN) {
const order = { 'over-rejects': 0, 'over-truncates': 1, 'at-risk': 2, unmeasured: 3, ok: 4 };
let best = null;
for (const row of rows ?? []) {
const [, kind, length] = row;
const [verdict, detail] = ceilingVerdict(length, kind, margin);
const rank = [order[verdict] ?? 5, -Number(length ?? 0)];
if (best === null || rank[0] < best.rank[0]
|| (rank[0] === best.rank[0] && rank[1] < best.rank[1])) {
best = { rank, verdict, row, detail };
}
}
if (best === null) return ['clear', null, 'no length-limited fields in this payload'];
if (best.verdict === 'ok') {
return ['clear', best.row,
`every measured field has room; the longest is ${best.row[0]} at ${best.detail}`];
}
return [best.verdict, best.row, `${best.row[0]} (${best.row[1]}): ${best.detail}`];
}
/** The longest field observed per kind, for the report. Pure. */
export function summarise(rows) {
const out = new Map();
for (const [path, kind, length, limit, behaviour] of rows ?? []) {
const current = out.get(kind);
if (!current || length > current.length) {
out.set(kind, { length, limit, behaviour, path });
}
}
return out;
}
function auditPayload(blocks, text, margin, label) {
const rows = measureBlocks(blocks, text);
console.log(`payload ${label}: ${(blocks ?? []).length} block(s), ` +
`${rows.length} measured field(s)`);
const [verdict, , detail] = worst(rows, margin);
(verdict === 'clear' ? console.log : console.warn)(
`worst ${verdict.padEnd(16)} ${detail}`);
let atRisk = 0;
for (const [path, kind, length] of rows) {
const [state, why] = ceilingVerdict(length, kind, margin);
if (state === 'at-risk') {
atRisk += 1;
console.warn(`at-risk ${''.padEnd(16)} ${path} (${kind}): ${why}`);
}
}
for (const [kind, info] of [...summarise(rows).entries()].sort()) {
console.log(`summary ${kind.padEnd(18)} max ${info.length} of ${info.limit} ` +
`(${info.behaviour}), at ${info.path}`);
}
return [verdict, atRisk];
}
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 margin = Number(arg(args, '--margin', String(MARGIN)));
const payload = arg(args, '--payload', '');
if (payload) {
const raw = JSON.parse(await readFile(payload, 'utf8'));
const blocks = Array.isArray(raw) ? raw : (raw ?? {}).blocks;
const text = Array.isArray(raw) ? null : (raw ?? {}).text;
const [verdict, atRisk] = auditPayload(blocks, text, margin, payload);
if (verdict.startsWith('over') || atRisk) {
console.warn(' repair: truncate below the ceiling in the builder, append an ' +
'explicit marker so the cut is visible, and link to the full content rather ' +
'than inlining it');
console.warn(' repair: never interpolate unbounded output into a block; a stack ' +
'trace, a diff or a user supplied name has no length you control and a block ' +
'field does');
process.exitCode = 1;
}
return;
}
const tokenEnv = arg(args, '--token-env', 'SLACK_BOT_TOKEN');
const token = process.env[tokenEnv];
if (!token) {
console.error(`set ${tokenEnv}, or pass --payload to measure a file with no token`);
process.exitCode = 2;
return;
}
const channels = argAll(args, '--channel');
if (!channels.length) {
console.error('pass at least one --channel, or --payload');
process.exitCode = 2;
return;
}
const limit = arg(args, '--limit', '200');
const headers = { Authorization: `Bearer ${token}` };
const who = await (await fetch(`${API}auth.test`, { headers })).json();
if (who.ok !== true) {
console.error(`auth.test unavailable ${who.error}`);
process.exitCode = 2;
return;
}
const botId = who.bot_id ?? '';
console.log(`identity ${who.user_id} in ${who.team}`);
let findings = 0;
for (const channel of channels) {
const url = `${API}conversations.history?channel=${encodeURIComponent(channel)}` +
`&limit=${encodeURIComponent(limit)}`;
const body = await (await fetch(url, { headers })).json();
if (body.ok !== true) {
console.warn(`history unavailable ${channel}: ${body.error}`);
continue;
}
for (const m of body.messages ?? []) {
if (!m.blocks) continue;
if (botId && m.bot_id !== botId) continue;
const [verdict, atRisk] = auditPayload(m.blocks, m.text, margin,
`${channel} ts=${m.ts}`);
if (verdict.startsWith('over') || atRisk) findings += 1;
}
}
if (findings) {
console.warn(' repair: bound every interpolation in the message builder, at a ' +
'length below the ceiling, with a visible marker on the cut');
console.warn(' repair: for genuinely long content, upload it and reference it from ' +
'a short block instead of inlining it');
process.exitCode = 1;
} else {
console.log('verdict clear every measured field has room left');
}
}
// 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 assertion the whole note rests on is that message.text at 41,000 characters comes back over-truncates while a section at 3,001 comes back over-rejects. Those are the two behaviours that make this feel like two unrelated problems, and a checker that reports both as "over" is why. The rest keep the walker honest: a field nested three levels down inside an option group still arrives with a path you can act on, and a payload with nothing measurable is reported as such rather than as clean.
from slack_block_field_limits import (ceiling_verdict, measure_blocks, summarise,
worst)
def section(text):
return {"type": "section", "text": {"type": "mrkdwn", "text": text}}
def test_a_section_over_three_thousand_rejects_the_whole_message():
verdict, detail = ceiling_verdict(3001, "section.text")
assert verdict == "over-rejects"
assert "entire message" in detail
def test_the_message_text_field_is_cut_rather_than_refused():
verdict, detail = ceiling_verdict(41000, "message.text")
assert verdict == "over-truncates"
assert "nobody told" in detail
def test_a_field_inside_the_margin_is_at_risk_before_it_fails():
verdict, detail = ceiling_verdict(2950, "section.text")
assert verdict == "at-risk"
assert "has not failed yet" in detail
assert ceiling_verdict(2000, "section.text")[0] == "ok"
def test_the_margin_is_adjustable():
assert ceiling_verdict(2500, "section.text", margin=0.2)[0] == "at-risk"
assert ceiling_verdict(2500, "section.text", margin=0.1)[0] == "ok"
def test_the_small_ceilings_are_the_ones_nobody_expects():
assert ceiling_verdict(151, "header.text")[0] == "over-rejects"
assert ceiling_verdict(76, "button.text")[0] == "over-rejects"
assert ceiling_verdict(76, "context.text")[0] == "over-rejects"
assert ceiling_verdict(256, "action_id")[0] == "over-rejects"
def test_a_kind_with_no_ceiling_is_unmeasured_rather_than_ok():
verdict, detail = ceiling_verdict(10, "video.description")
assert verdict == "unmeasured"
assert "by hand" in detail
def test_measure_keeps_the_full_path_on_every_row():
rows = measure_blocks([{"type": "section",
"fields": [{"type": "mrkdwn", "text": "a"},
{"type": "mrkdwn", "text": "bb"}]}])
assert [r[0] for r in rows] == ["blocks[0].fields[0].text", "blocks[0].fields[1].text"]
assert rows[1][2] == 2
assert rows[0][1] == "section.field"
def test_measure_reaches_a_button_inside_an_actions_block():
blocks = [{"type": "actions", "block_id": "b1", "elements": [
{"type": "button", "action_id": "go", "value": "x",
"text": {"type": "plain_text", "text": "Approve"}}]}]
rows = dict((r[0], r) for r in measure_blocks(blocks))
assert rows["blocks[0].block_id"][1] == "block_id"
assert rows["blocks[0].elements[0].action_id"][2] == 2
assert rows["blocks[0].elements[0].text"][1] == "button.text"
assert rows["blocks[0].elements[0].value"][3] == 2000
def test_measure_reaches_an_option_inside_an_option_group():
blocks = [{"type": "actions", "elements": [
{"type": "static_select",
"placeholder": {"type": "plain_text", "text": "pick"},
"option_groups": [{"label": {"type": "plain_text", "text": "g"},
"options": [{"text": {"type": "plain_text", "text": "one"},
"value": "1"}]}]}]}]
paths = [r[0] for r in measure_blocks(blocks)]
assert "blocks[0].elements[0].option_groups[0].options[0].text" in paths
assert "blocks[0].elements[0].placeholder" in paths
def test_context_elements_are_measured_at_seventy_five():
blocks = [{"type": "context", "elements": [{"type": "mrkdwn", "text": "x" * 80}]}]
row = measure_blocks(blocks)[0]
assert row[1] == "context.text"
assert row[3] == 75
assert ceiling_verdict(row[2], row[1])[0] == "over-rejects"
def test_the_message_text_is_measured_when_it_is_supplied():
rows = measure_blocks([section("hi")], text="a summary")
assert rows[0][0] == "message.text"
assert rows[0][4] == "truncate"
def test_worst_prefers_a_rejection_over_a_truncation():
rows = measure_blocks([section("x" * 3100)], text="y" * 41000)
verdict, row, detail = worst(rows)
assert verdict == "over-rejects"
assert row[0] == "blocks[0].text"
assert "section.text" in detail
def test_worst_reports_a_lone_truncation_as_a_truncation():
assert worst(measure_blocks([section("ok")], text="y" * 41000))[0] == "over-truncates"
def test_a_payload_with_room_is_clear_and_names_its_longest_field():
verdict, row, detail = worst(measure_blocks([section("hello")]))
assert verdict == "clear"
assert row[0] == "blocks[0].text"
assert "every measured field has room" in detail
def test_a_payload_with_nothing_measurable_says_so():
verdict, row, detail = worst(measure_blocks([{"type": "divider"}]))
assert verdict == "clear"
assert row is None
assert "no length-limited fields" in detail
def test_summarise_reports_the_longest_of_each_kind():
rows = measure_blocks([section("x" * 10), section("y" * 40)])
got = summarise(rows)
assert got["section.text"]["length"] == 40
assert got["section.text"]["path"] == "blocks[1].text"
assert got["section.text"]["behaviour"] == "reject"
import { test } from 'node:test';
import assert from 'node:assert/strict';
import {
ceilingVerdict, measureBlocks, summarise, worst,
} from './slack-block-field-limits.mjs';
const section = (text) => ({ type: 'section', text: { type: 'mrkdwn', text } });
test('a section over three thousand rejects the whole message', () => {
const [verdict, detail] = ceilingVerdict(3001, 'section.text');
assert.equal(verdict, 'over-rejects');
assert.match(detail, /entire message/);
});
test('the message text field is cut rather than refused', () => {
const [verdict, detail] = ceilingVerdict(41000, 'message.text');
assert.equal(verdict, 'over-truncates');
assert.match(detail, /nobody told/);
});
test('a field inside the margin is at risk before it fails', () => {
const [verdict, detail] = ceilingVerdict(2950, 'section.text');
assert.equal(verdict, 'at-risk');
assert.match(detail, /has not failed yet/);
assert.equal(ceilingVerdict(2000, 'section.text')[0], 'ok');
});
test('the margin is adjustable', () => {
assert.equal(ceilingVerdict(2500, 'section.text', 0.2)[0], 'at-risk');
assert.equal(ceilingVerdict(2500, 'section.text', 0.1)[0], 'ok');
});
test('the small ceilings are the ones nobody expects', () => {
assert.equal(ceilingVerdict(151, 'header.text')[0], 'over-rejects');
assert.equal(ceilingVerdict(76, 'button.text')[0], 'over-rejects');
assert.equal(ceilingVerdict(76, 'context.text')[0], 'over-rejects');
assert.equal(ceilingVerdict(256, 'action_id')[0], 'over-rejects');
});
test('a kind with no ceiling is unmeasured rather than ok', () => {
const [verdict, detail] = ceilingVerdict(10, 'video.description');
assert.equal(verdict, 'unmeasured');
assert.match(detail, /by hand/);
});
test('measure keeps the full path on every row', () => {
const rows = measureBlocks([{ type: 'section', fields: [
{ type: 'mrkdwn', text: 'a' }, { type: 'mrkdwn', text: 'bb' }] }]);
assert.deepEqual(rows.map((r) => r[0]),
['blocks[0].fields[0].text', 'blocks[0].fields[1].text']);
assert.equal(rows[1][2], 2);
assert.equal(rows[0][1], 'section.field');
});
test('measure reaches a button inside an actions block', () => {
const blocks = [{ type: 'actions', block_id: 'b1', elements: [{ type: 'button',
action_id: 'go', value: 'x', text: { type: 'plain_text', text: 'Approve' } }] }];
const rows = new Map(measureBlocks(blocks).map((r) => [r[0], r]));
assert.equal(rows.get('blocks[0].block_id')[1], 'block_id');
assert.equal(rows.get('blocks[0].elements[0].action_id')[2], 2);
assert.equal(rows.get('blocks[0].elements[0].text')[1], 'button.text');
assert.equal(rows.get('blocks[0].elements[0].value')[3], 2000);
});
test('measure reaches an option inside an option group', () => {
const blocks = [{ type: 'actions', elements: [{ type: 'static_select',
placeholder: { type: 'plain_text', text: 'pick' },
option_groups: [{ label: { type: 'plain_text', text: 'g' },
options: [{ text: { type: 'plain_text', text: 'one' }, value: '1' }] }] }] }];
const paths = measureBlocks(blocks).map((r) => r[0]);
assert.ok(paths.includes('blocks[0].elements[0].option_groups[0].options[0].text'));
assert.ok(paths.includes('blocks[0].elements[0].placeholder'));
});
test('context elements are measured at seventy five', () => {
const blocks = [{ type: 'context',
elements: [{ type: 'mrkdwn', text: 'x'.repeat(80) }] }];
const row = measureBlocks(blocks)[0];
assert.equal(row[1], 'context.text');
assert.equal(row[3], 75);
assert.equal(ceilingVerdict(row[2], row[1])[0], 'over-rejects');
});
test('the message text is measured when it is supplied', () => {
const rows = measureBlocks([section('hi')], 'a summary');
assert.equal(rows[0][0], 'message.text');
assert.equal(rows[0][4], 'truncate');
});
test('worst prefers a rejection over a truncation', () => {
const rows = measureBlocks([section('x'.repeat(3100))], 'y'.repeat(41000));
const [verdict, row, detail] = worst(rows);
assert.equal(verdict, 'over-rejects');
assert.equal(row[0], 'blocks[0].text');
assert.match(detail, /section\.text/);
});
test('worst reports a lone truncation as a truncation', () => {
assert.equal(worst(measureBlocks([section('ok')], 'y'.repeat(41000)))[0],
'over-truncates');
});
test('a payload with room is clear and names its longest field', () => {
const [verdict, row, detail] = worst(measureBlocks([section('hello')]));
assert.equal(verdict, 'clear');
assert.equal(row[0], 'blocks[0].text');
assert.match(detail, /every measured field has room/);
});
test('a payload with nothing measurable says so', () => {
const [verdict, row, detail] = worst(measureBlocks([{ type: 'divider' }]));
assert.equal(verdict, 'clear');
assert.equal(row, null);
assert.match(detail, /no length-limited fields/);
});
test('summarise reports the longest of each kind', () => {
const got = summarise(measureBlocks([section('x'.repeat(10)), section('y'.repeat(40))]));
assert.equal(got.get('section.text').length, 40);
assert.equal(got.get('section.text').path, 'blocks[1].text');
assert.equal(got.get('section.text').behaviour, 'reject');
});
FAQ
Is the section limit 3,000 characters or 3,000 bytes?
It is characters, and for anything outside the Latin alphabet that distinction is worth holding onto: a message of Japanese text or emoji is well under the ceiling by character count and much larger on the wire. The character measurement is the one the ceiling uses, so it is the one this script reports, but if you are close to the limit with such content, leave more room than the number suggests.
Why does the message text field truncate when everything else rejects?
Because it predates Block Kit and behaves like the field it always was. It is the fallback string, and Slack would rather show a cut summary on a notification than refuse the whole message over one. The practical consequence is the one worth remembering: a section that is too long tells you immediately, and a fallback that is too long tells you nothing at all.
Should I truncate at exactly the ceiling?
No, cut below it and say that you did. Truncating at exactly 3,000 characters produces a message that ends mid-word and gives a reader no way to tell whether anything is missing. Cutting at 2,900 with an explicit marker and a link to the full content costs one line in the builder and makes the message honest about what it is.
Which field catches teams out most often?
Two, and for opposite reasons. Section text, because that is where log output and stack traces are interpolated, and header text at 150, because headers are short by convention so nobody thinks to bound the one that is built from a user-supplied string. The 255 on action_id is a distant third and only bites teams who encode state into it.
Can I check this without a Slack token?
Yes, and that is how it is meant to be used. Pass --payload with the JSON your builder produced and nothing here talks to Slack at all. The ceilings are published, the payload is yours, and the whole check belongs in your test suite pointed at the longest fixture you have rather than the tidiest.
Related field notes
- the same error string, from a structural rule instead
- the ceiling one level up, on the whole message
- the other field on this payload nobody sets
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.
- Composition objects - Slack Docs
- Blocks reference - Slack Docs
- chat.postMessage method reference - Slack Docs
- Web API - Slack Docs
If your setup is misbehaving in a way this note does not cover, message me on LinkedIn with what you are seeing.