Server
Sending it somewhere
Storing the report is one thing; seeing it is another. Eleven sinks live in
bugbottle/server, ten of them a formatter over one fetch call and the
eleventh a small SMTP client, none with a dependency of its own. None of them reads your environment: the key, the URL
and the token are arguments, so it is visible at the call site where the secret
came from — and so nothing can drift into a browser bundle.
The eleven at a glance, before the prose walks through them one by one:
| Sink | Export | What you need | The picture | Self-hosted | One report becomes | Server bundle |
|---|---|---|---|---|---|---|
| Resend | sendReportEmail |
An API key and a verified sender | Attached as screenshot.png, from the bytes you pass |
No | An email | 5.6 kB |
| SMTP | smtpSink, sendReportSmtp |
A host, a port and an account | A link, from screenshotUrl |
Yes — any mail server you can reach | An email | 7.8 kB |
| Webhook | sendReportWebhook |
A URL, and headers if it wants them | Whatever the report carried: json passes it through untouched |
Yes — the endpoint is yours | A POST of the report plus its Markdown | 4.5 kB |
| Slack | slackSink |
An incoming-webhook URL | A link, in an image block |
No | A Block Kit message | 2.4 kB |
| Discord | discordSink |
A webhook URL | A link, as the embed's image | No | One embed, coloured by report type | 2.4 kB |
| Teams | teamsSink |
A Workflows webhook URL | A link, in an Image element |
No | An Adaptive Card | 2.7 kB |
| GitHub | createGithubIssue |
A fine-grained token with issues write | A link from the body | No — github.com only | An issue | 4.7 kB |
| GitLab | gitlabSink |
A token with api scope and a project id |
A link from the facts table | Yes — pass host |
An issue | 4.9 kB |
| Jira | jiraSink |
A site, the account email, an API token and a project key | A link, as a fact | No — Jira Cloud's v3 API | An issue | 2.9 kB |
| Linear | createLinearIssue |
An API key and the team's UUID | A link from the description | No | An issue | 4.8 kB |
| Sentry | sentrySink |
A DSN | Attached, in the same envelope | Yes — GlitchTip and Bugsink speak the same protocol | An event, or a feedback item | 4.2 kB |
The sizes are a server bundle that imports that one export and nothing else,
minified and gzipped: node scripts/measure-sinks.mjs reproduces them, and
these were measured on 8 September 2026 with esbuild 0.24.0. The four small
ones build their own structure — blocks, an embed, a card, a node tree — while
the rest render the report with toMarkdown, which is most of the difference.
None of the numbers is a budget CI enforces; they are here so the cost of a
sink is known before it is imported, and every one of them is dwarfed by the
framework already in a server bundle.
The picture, once for all eleven. Only Resend and Sentry can carry the bytes; every other sink links to an address you stored the picture at. That address reaches a sink in one of two ways, spelled the same in all of them since 1.0:
screenshotUrl?: string— the address you already have.screenshotUrlFrom?: (report) => string | undefined— read it out of the report, for a signed URL built per report.
screenshotUrlFrom wins where both are given, because it is the one that saw
the report; and where neither is, a sink run by handleReport falls back to
the address the screenshot function stored. A data: URL is never used: the
services fetch the address themselves, so a data URL is silently dropped rather
than sent. Read "Please read this part" before that address becomes a public
one.
sendReportEmail posts to Resend. It renders the report with toMarkdown,
attaches the decoded screenshot as screenshot.png when you pass the bytes,
and returns the message id:
import { decodeScreenshotDataUrl, sendReportEmail } from "bugbottle/server";
import { da } from "bugbottle/locales";
export async function POST(req: Request) {
const payload = await req.json();
// …validate as above, then store what you keep…
await sendReportEmail(payload, {
apiKey: process.env.RESEND_API_KEY!, // your configuration, not the library's
from: "[email protected]",
to: "[email protected]",
screenshot: screenshot ?? undefined, // from decodeScreenshotDataUrl
locale: da, // subject and intro in Danish
});
return Response.json({ id }, { status: 201 });
}The subject comes from the report's title and the locale, unless you pass
subject yourself. The body is the Markdown, with a minimal HTML version
beside it.
When the report carries a contact line that looks like an email address, it
becomes the mail's reply_to, so answering the report answers the person who
wrote it. A line that is not an address — "call me on 12345678" — is left in
the body as a fact row and no reply_to is sent, because Resend refuses the
whole send rather than ignoring one. Pass replyTo to override the address,
or replyTo: false to send none at all.
sendReportWebhook posts to anything with a URL. json sends the report as it
arrived plus a markdown field, which is what Make, n8n and your own intake
endpoint want; slack sends { text } and discord sends { content },
clipped to the 2000 characters Discord accepts:
import { sendReportWebhook } from "bugbottle/server";
await sendReportWebhook(payload, {
endpoint: process.env.SLACK_WEBHOOK_URL!,
format: "slack",
});
await sendReportWebhook(payload, {
endpoint: process.env.DISCORD_WEBHOOK_URL!,
format: "discord",
});
await sendReportWebhook(payload, {
endpoint: process.env.INTAKE_URL!,
headers: { "X-Token": process.env.INTAKE_TOKEN! },
});The address is endpoint, the word everything else in the package uses for
somewhere you POST a report. A vendor's own address keeps the vendor's own
word — webhookUrl for the Slack, Discord and Teams sinks below, host for
GitLab, site for Jira, dsn for Sentry.
Your own SMTP server #
smtpSink is the same email without Resend. Most people running their own
endpoint already have an SMTP account — their host's, Postmark's, Mailgun's, a
Postfix or Stalwart box on the same machine — and no reason to sign up for
anything to send one message a day. It speaks the protocol itself over
node:net and node:tls: EHLO, STARTTLS when the server offers it, AUTH,
one message, QUIT. Zero dependencies, like everything else here, and Node-only
— it is the one sink that needs those two modules, and nothing else in
bugbottle/server imports it, so a worker runtime is unaffected until you ask
for it by name.
import { handleReport, smtpSink } from "bugbottle/server";
export async function POST(req: Request) {
return handleReport(req, {
sinks: [
smtpSink({
host: "smtp.example.com", // your account, your configuration
port: 587, // 465 for implicit TLS, 587 for STARTTLS
user: process.env.SMTP_USER!,
pass: process.env.SMTP_PASS!,
from: "[email protected]",
to: ["[email protected]", "[email protected]"],
timeoutMs: 10_000, // per phase, not for the whole conversation
}),
],
});
}The subject, the intro and the Reply-To work exactly as they do for Resend:
the subject comes from the report's title and the locale unless you pass
subject, and a contact line that looks like an address becomes the
Reply-To so answering the mail answers the person who wrote the report.
replyTo overrides it and replyTo: false sends none. The body is a
multipart/alternative of the report as plain text and the same report
labelled text/markdown, so a mail client shows the readable half and a script
that fetches the mailbox back out gets the Markdown with its tables intact.
There are no attachments: store the screenshot yourself and pass
screenshotUrl, which is linked from the facts table.
port decides the rest. Left unset it is 587, and secure follows it: true
for 465, where TLS starts with the first byte, and false otherwise, where the
connection upgrades itself as soon as the server advertises STARTTLS. tls
is handed to tls.connect, for a self-signed certificate on a box you run
(tls: { rejectUnauthorized: false }) or a pinned CA. A refusal throws
SinkError with the server's own reply code and line — 550 5.1.1 … Recipient address rejected reaches your log as it was said — and a failure
that never got a reply, such as a hang or a refused connection, throws one with
a status of 0.
AUTH is refused over a connection that is not encrypted. If the server
offers no STARTTLS and you did not connect on an implicit-TLS port, the
password would go to the wire in base64, which is not encryption: every hop
between you and the mail server could read it, and one of those hops is
whatever else runs on the network. So the sink throws before sending anything
rather than authenticating in the clear. allowInsecureAuth: true switches
that off, and it is meant for exactly one case — a mail server on the same
host, reached over the loopback interface, that wants a password anyway. If you
find yourself setting it for a server somewhere else, the answer is a port that
does TLS, not the flag.
The report itself is refused in the clear too, on the ports that carry mail
across a network. STARTTLS is advertised in an EHLO reply nothing has
authenticated yet, so anything on the path can strip it out of the list and the
conversation carries on unencrypted — with the whole report in it. Where no
credentials are set, the AUTH refusal above never fires and nothing else would
notice. So requireTls gives up before MAIL FROM when the connection never
became encrypted. Left unset it is true on the submission port (587) and
whenever user and pass are set, and false otherwise, which leaves the relay
on localhost:25 working as it did; allowInsecureAuth lowers the default
with it, because it already names a server you decided to trust. Set
requireTls: true on any other port that leaves the machine, and
requireTls: false only for a server you can see from where you are standing.
Credentials never reach a log or an error message: an AUTH failure is reported with the server's reply, never with what was sent, because the base64 of an AUTH LOGIN step is the password in a thin disguise.
timeoutMs is a deadline on every phase, and that includes the writes: a
server that stops reading closes its TCP window rather than saying anything,
and without a deadline there the message body would stall for ever. Nothing in
the conversation can now block longer than one phase's worth.
Slack and Discord #
A report that lands in the team's chat within a second is a report that gets
read. slackSink and discordSink are the richer version of the two webhook
formats above: instead of a wall of Markdown they post one structured message
per report — the title, the message, the facts in columns, the last five
console entries, the picture when there is a URL for it, and a button to the
full report. Both are factories, so they go straight into sinks:
import { handleReport, slackSink, discordSink } from "bugbottle/server";
export const POST = (req: Request) =>
handleReport(req, {
screenshot: async (bytes) => await putPrivate(bytes), // returns a URL
store: async (report) => await db.reports.insert(report),
sinks: [
slackSink({
webhookUrl: process.env.SLACK_WEBHOOK_URL!, // the URL is the credential
username: "bugbottle",
iconEmoji: ":beetle:",
// All optional, and the link to the full report takes the same pair of
// shapes as the picture: `screenshotUrlFrom` and `reportUrlFrom` are
// functions of the report, so the address can be built from whatever
// you stored; pass the plain `screenshotUrl` or `reportUrl` string
// instead when you already have it.
screenshotUrlFrom: (r) => signedUrlFor(r),
reportUrlFrom: (r) => `https://app.acme.com/reports/${idOf(r)}`,
}),
discordSink({
webhookUrl: process.env.DISCORD_WEBHOOK_URL!,
reportUrlFrom: (r) => `https://app.acme.com/reports/${idOf(r)}`,
}),
],
});Slack gets a Block Kit message: a header, the message as mrkdwn with &,
< and > escaped, a section of up to ten fields, the console in a fenced
block, an image block, a context line with the time and the selector the
reporter pointed at, and an actions button. Discord gets one embed, coloured
by report type — red for a bug, green for an idea, grey for anything else —
with the same facts as fields, the screenshot as image, the report link as
the embed's url, and the selector in the footer.
Both services cap everything they are given: 50 blocks and 3000 characters per text object on Slack, 6000 characters across an embed on Discord. Every one of those is a clip rather than a failure — a report that arrives truncated is still read, and a report that 400s because a stack trace was one character too long is not. On Discord the description is what gives way first, because the facts are what somebody triages from.
Neither service will fetch a data URL, so a screenshot only appears when you have stored the picture and can hand back an address. Read "Please read this part" before that address becomes a public one: a link in a channel is only as private as what it points at, and a chat workspace is a wider audience than an issue tracker.
Both take an injected fetch, so a test asserts the payload without a network,
and both use the AbortSignal handleReport hands them, so sinkTimeoutMs
really does end the request. If you would rather post the message yourself —
through a bot token, into a thread — buildSlackMessage(report, options) and
buildDiscordMessage(report, options) return the body without sending it.
Microsoft Teams #
teamsSink is the same idea for a Teams channel, with one wrinkle: the Office
365 connector webhooks that used to take a card are retired. The way in now is
a Workflows webhook — in the channel menu, Workflows → "Post to a channel
when a webhook request is received" — which gives you a URL that expects a Bot
Framework message with an Adaptive Card attached. The sink builds both:
import { handleReport, teamsSink } from "bugbottle/server";
export const POST = (req: Request) =>
handleReport(req, {
screenshot: async (bytes) => await putPrivate(bytes), // returns a URL
store: async (report) => await db.reports.insert(report),
sinks: [
teamsSink({
webhookUrl: process.env.TEAMS_WEBHOOK_URL!, // the URL is the credential
// All optional, and the link to the full report takes the same pair of
// shapes as the picture: `screenshotUrlFrom` and `reportUrlFrom` are
// functions of the report, so the address can be built from whatever
// you stored; pass the plain `screenshotUrl` or `reportUrl` string
// instead when you already have it.
screenshotUrlFrom: (r) => signedUrlFor(r),
reportUrlFrom: (r) => `https://app.acme.com/reports/${idOf(r)}`,
buttonText: "Open report", // the default
}),
],
respond: ({ id }) => Response.json({ id }, { status: 201 }),
});The card is schema 1.5: a bold title, the message as a wrapping TextBlock,
the facts as a FactSet, the last five console entries in a monospace block,
an Image when there is a URL to fetch, a subtle line with the time and the
selector, and an Action.OpenUrl when you give a reportUrl or a
reportUrlFrom. A TextBlock
renders a subset of Markdown, so every string is escaped into plain text first
— *.tsx stays *.tsx rather than turning half the card italic. There are no
inputs and no Action.Submit: a webhook has nowhere to send an answer.
Workflows replies 202 Accepted with an empty body, so the sink treats every
2xx as success. That 202 means the flow was queued and not that the card
rendered — if nothing appears in the channel, look at the flow's run history in
Power Automate rather than at the status code. The one exception is the retired
connector webhooks, which are still out there and answer 200 for a refusal
with the reason in the body: a body that opens with Webhook message delivery failed is a SinkError carrying that line, whatever the status said, because
a lost report must not be logged as a delivered one.
webhookUrl is checked with new URL when you build the sink, so a mistyped
address fails where it was configured rather than half an hour later inside a
fetch error that would have quoted your URL — which is the credential — into
a log.
A Workflows message is capped at 28 kB, and Teams refuses a larger one outright rather than clipping it for you. No report can reach that on its own — report-core has already clipped the message to 4000 characters, the page and the user agent to 500, and five console entries to 500 each — but a screenshot address is whatever your storage hands back, and a long enough one leaves no room. So the card is measured before it is sent and, while it is over, the console goes first, then the facts from the back, then the reporter's own words: the console is in the stored report in full, and a truncated sentence still says what went wrong.
Teams fetches the picture itself, so a data URL is ignored here too, and "Please
read this part" applies with more force than anywhere else in this section: a
channel is the widest audience a report gets. buildTeamsMessage(report, options) returns the message without sending it, if you would rather post it
through a bot.
Sentry, GlitchTip and Bugsink #
If your team already runs Sentry, bugbottle can be the feedback layer over it
rather than a second place to look. sentrySink posts one envelope per report
to the DSN's ingest endpoint — the same protocol GlitchTip and Bugsink speak,
so a self-hosted install works with nothing changed but the DSN. There is no
Sentry SDK behind it: one fetch and a formatter, like every other sink.
import { handleReport, sentrySink } from "bugbottle/server";
export const POST = (req: Request) =>
handleReport(req, {
// The bytes are handed to the sink, so the picture travels in the envelope
// rather than as a link to storage you had to arrange first.
screenshot: "keep",
store: async (report) => await db.reports.insert(report),
sinks: [
sentrySink({
dsn: process.env.SENTRY_DSN!,
release: process.env.BUILD_SHA,
environment: "production",
// Optional, and functions of the report, so they come from whatever
// your application knew about the person who reported.
contactEmail: (r) => emailOf(r),
contactName: (r) => nameOf(r),
}),
],
});What arrives is an event with the report's message, level error for a bug
and info for an idea or anything else, tags for the type, the page and the
viewport, and a contexts.feedback carrying the message, the page and the
contact details — which is what Sentry ≥ 24.x shows as User Feedback. The
evidence travels as breadcrumbs, in one timeline sorted oldest first: the
console buffer as console breadcrumbs with warn respelt as Sentry's
warning, the recorded clicks, submits, navigations and visibility changes as
ui.* and navigation, and the failed and slow requests as http breadcrumbs
with url, method, status_code and duration. The pointed-at elements and
the optional context facts go in extra. The screenshot is an attachment item
in the same envelope, screenshot.png, which is the one delivery in this
library that carries the picture itself.
Everything is capped, and every cap is a clip rather than a failure: a hundred
breadcrumbs, 8 kB of message (4096 characters in the feedback context, which is
that spec's own limit) and a megabyte of envelope. When the envelope is over,
the attachment goes first and the breadcrumbs second, and what went is written
on the event as a bugbottle_truncated tag so the reader knows the event is
not the whole report.
Two things are worth knowing before you wire it up. The first is that the sink
sends an event item by default rather than the feedback item Sentry's
feedback specification describes: the feedback item is what puts a report in
Sentry's own User Feedback list, but GlitchTip and Bugsink do not know the type
and drop what they cannot parse. Pass itemType: "feedback" on a real Sentry
to opt into it — where it is also a separate rate-limit category from your
errors. The second is that a 429 is answered honestly: SentrySinkError
carries retryAfter in seconds (sixty when the server sent no usable header,
which is what Sentry's transport specification says to assume) and the raw
X-Sentry-Rate-Limits, so a caller can back off with a number rather than a
guess. It is still a SinkError, so a handler that catches those catches this.
A DSN with a typo in it throws when the sink is built rather than on the first
report, so a mistake fails where it was written down. And if you would rather
send the envelope yourself — through a proxy, or with a Sentry SDK already on
the server — buildSentryEvent(report, options) returns the event for
captureEvent and buildSentryEnvelope(report, options) returns the bytes.
createGithubIssue files the report as an issue, which for a small team is
the whole backend: the report lands in the same list as everything else that is
broken, with the same labels and the same search. A fine-grained token with
issues write on the one repository is enough:
import { createGithubIssue } from "bugbottle/server";
const { number, url } = await createGithubIssue(payload, {
token: process.env.GITHUB_TOKEN!,
owner: "acme",
repo: "app",
labels: ["bug", "from-bugbottle"],
screenshotUrl, // where you stored the picture
});The title is the report's type and its first line — Bug: The save button does nothing — unless you pass title yourself, and the body is the Markdown.
The GitHub API cannot take an attachment: pictures in an issue body are
uploads made by the web editor, and there is no public endpoint for that. So
the screenshot has to be stored by you first, and screenshotUrl links to it
from the body. Anyone who can read the issue then follows that link, which
means the storage decision below is the one that matters — a link out of an
issue is only as private as the address it points at.
createLinearIssue does the same for Linear, which is where a lot of small
teams already track what is broken. Linear takes ids rather than names, so the
team, the project and the labels are UUIDs from your workspace, and the API key
goes in the header as it is — no Bearer prefix:
import { createLinearIssue } from "bugbottle/server";
const { identifier, url } = await createLinearIssue(payload, {
apiKey: process.env.LINEAR_API_KEY!,
teamId: "6f0a…", // required
projectId: "b21c…", // optional
labelIds: ["9d4e…"], // optional
screenshotUrl, // where you stored the picture
});The title and the body follow the same rules as the GitHub sink, and Linear
cannot take an attachment either, so screenshotUrl is again a link to storage
you control.
Linear answers over GraphQL, which fails differently from the rest: a rejected
mutation still comes back with a 200 and puts the reason in an errors
array. The sink reads it and throws SinkError anyway, so a mistyped team id
is a failure you can see rather than an issue that was never created.
Jira Cloud #
jiraSink files the report in a Jira project. It is a factory like the chat
sinks, so it goes straight into sinks, and it is the one sink here that does
not send Markdown: Jira Cloud's REST v3 takes the Atlassian Document Format in
description, a JSON node tree rather than text. The conversion is built from
the report and kept to three shapes — a paragraph for the reporter's own words,
a bullet list for the facts and the element, and a code block for the last
twenty console entries. Where the reporter pressed return, the paragraph gets a
hardBreak node, because ADF has no newline inside a text node and a message
that carried one would be collapsed onto a single line or refused outright:
import { handleReport, jiraSink } from "bugbottle/server";
export const POST = (req: Request) =>
handleReport(req, {
screenshot: async (bytes) => await putPrivate(bytes), // returns a URL
store: async (report) => await db.reports.insert(report),
sinks: [
jiraSink({
site: "acme", // or acme.atlassian.net, or the full URL
email: process.env.JIRA_EMAIL!, // the account the token belongs to
apiToken: process.env.JIRA_API_TOKEN!,
projectKey: "SUP",
issueType: "Bug", // default; must exist in the project
}),
],
respond: ({ id }) => Response.json({ id }, { status: 201 }),
});The two credentials are the basic auth pair Jira wants, base64-encoded here and
UTF-8 safe, so a token with an accent in it does not throw on the way out. The
summary is the report's type and its first line — Bug: The save button does nothing — clipped to the 255 characters Jira keeps, unless you pass title.
facts adds bullets of your own, and maxConsoleEntries shortens the code
block, which Jira renders in full with no way to collapse it. A contact line
on the report is the bullet directly under the type, where the Markdown sinks
put it too.
A refused create names the field: Jira answers with an errorMessages list and
an errors object keyed by field, and both are joined into the SinkError
message, so "issuetype: Specify an issue type" is what you read rather than
"status 400". buildJiraDescription(report) returns the document on its own if
you would rather send it yourself.
Jira cannot take the picture in the create request either — attachments are a
second, multipart request against the new issue — so the screenshot is stored
by you first and screenshotUrl becomes one of the facts. handleReport
passes the URL its screenshot function returned, so the option is only needed
when you send the report yourself.
GitLab #
gitlabSink is the simplest of the issue sinks, because a GitLab description
is Markdown and toMarkdown already produces it: the body goes over verbatim,
facts table and collapsed console block and all. Self-hosted GitLab is the same
API on another host, so host is an option and defaults to gitlab.com:
import { handleReport, gitlabSink } from "bugbottle/server";
export const POST = (req: Request) =>
handleReport(req, {
screenshot: async (bytes) => await putPrivate(bytes), // returns a URL
store: async (report) => await db.reports.insert(report),
sinks: [
gitlabSink({
host: "https://gitlab.example.com", // omit for gitlab.com
projectId: "acme/app", // or the numeric id
token: process.env.GITLAB_TOKEN!, // personal, group or project, `api` scope
labels: ["bug", "from-bugbottle"],
}),
],
respond: ({ id }) => Response.json({ id }, { status: 201 }),
});The token travels in GitLab's own PRIVATE-TOKEN header rather than in
Authorization. A namespaced projectId is URL-encoded into the one path
segment, so acme/app reaches the API as acme%2Fapp instead of being read as
two segments. Labels are joined with commas, which is the shape the API takes,
and GitLab creates the ones that do not exist yet.
One thing to know when a report does not arrive: GitLab answers 404 rather
than 403 for a project the token cannot see, so a wrong project and a
too-narrow scope look alike from the outside. The SinkError message is
GitLab's own — 404 Project Not Found, or title: can't be blank for a
validation failure, which arrives as an object keyed by field.
GitLab cannot take the picture in the create request either: an upload is a
separate request whose answer you then reference from the Markdown. So the
screenshot is stored by you first and screenshotUrl is linked from the facts
table, the same as for GitHub and Linear.
All eleven throw SinkError, carrying the HTTP status and the response body —
or, for SMTP, the reply code and the server's own line — when the service
answers with anything but success. Catch it around the sink
rather than around the whole handler: a report you have already stored should
not be lost to a chat webhook that was revoked last week.