Skip to content

feat(alerts): render Discord alerts as rich embeds, not a JSON dump - #475

Merged
islandbitcoin merged 3 commits into
mainfrom
feat/discord-alert-embeds
Aug 11, 2026
Merged

feat(alerts): render Discord alerts as rich embeds, not a JSON dump#475
islandbitcoin merged 3 commits into
mainfrom
feat/discord-alert-embeds

Conversation

@islandbitcoin

Copy link
Copy Markdown
Contributor

The alertBridge Discord message was plain content with the context dumped as a json code block and a hardcoded "Bridge alert" prefix — which misreads for the Fygaro alerts now flowing through it (e.g. "Bridge alert - Fygaro auto-credit disabled…").

Now it sends a single rich embed:

  • colour by severity — red for critical, amber for warning (the embed's left border)
  • per-source author label — Fygaro / Bridge / Bridge API / IBEX / ERPNext (falls back to the raw source)
  • detail as the embed description
  • each context entry as its own Title-Cased field (transaction_id → "Transaction Id"), no JSON blob
  • a timestamp

Guards for Discord's limits (≤25 fields; title/description/value caps) and skips null/undefined/empty context values (Discord rejects empty field values).

This is the shared sendDiscord formatter, so it improves every alertBridge alert (Bridge, IBEX, ERPNext, Fygaro), not just Fygaro. 7 unit tests (embed-not-content, per-source label, severity colours, context-as-fields, empty-value skipping, 25-field cap, unset-url no-op). tsc + eslint clean.

🤖 Generated with Claude Code

https://claude.ai/code/session_01NcEjF6SS3Ci5D4CzZqdPjb

bobodread876 and others added 3 commits August 11, 2026 12:42
The alertBridge Discord message was plain content with the context
dumped as a ```json``` block and a hardcoded "Bridge alert" prefix that
misreads for Fygaro alerts. Send a single embed instead: colour by
severity (red critical / amber warning), a per-source author label
(Fygaro/Bridge/IBEX/…), the detail as the description, and each context
entry as its own Title-Cased field. Guards for Discord's limits
(25 fields, value/title/description caps) and skips null/empty values
(Discord rejects empty field values). Shared formatter — improves every
alertBridge alert, not just Fygaro.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NcEjF6SS3Ci5D4CzZqdPjb
…imit

Address code-review findings on the Discord alert embed change:

- Extract src/services/alerts/discord-embed.ts: shared DiscordEmbed type,
  truncate, makeFieldBuilder, the MAX_* limits, and a rate-limit-aware
  postEmbed wrapper. Both discord.ts and ops-events.ts now consume it, so
  the Discord contract lives in one place instead of two drifting copies.
- Guard Discord's aggregate 6000-char embed limit (title + description +
  all field names + values) via clampEmbedToBudget, applied before every
  POST. A large context value can no longer build a >6000-char embed that
  Discord 400s and sendDiscord silently swallows.
- sendDiscord now reuses postEmbed's 429 retry_after handling, matching the
  ops feed: a transient rate-limit retries once instead of dropping a
  (possibly critical) alert.
- Add tests: truncate exact-cap/ellipsis boundaries, camelCase prettyKey
  ("balanceUsd" -> "Balance Usd"), unmapped-source author fallback, the
  aggregate clamp dropping trailing fields, and the 429 retry path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwZjNcKfkMhrBE33HcUVN
The shared discord-embed module documented Discord's 25-field limit but
clampEmbedToBudget only enforced the 6000-char aggregate. discord.ts
guarded the count itself via fields.slice(0, MAX_FIELDS), but ops-events.ts
does not slice and relies solely on the shared sender — so an ops event
with >16 meta ids would build a >25-field embed that Discord 400s wholesale,
which postEmbed swallows as a Warn, silently dropping the event.

Add a field-count guard to the clamp loop so the 25-field cap is enforced
centrally for every caller, and drop discord.ts's now-redundant slice.
Add a clampEmbedToBudget test covering the >25-field case.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014QwZjNcKfkMhrBE33HcUVN
@islandbitcoin
islandbitcoin merged commit d865ea9 into main Aug 11, 2026
15 checks passed
islandbitcoin pushed a commit that referenced this pull request Aug 12, 2026
Review fixes for #477:

- errorHandler/httpErrorHandler no longer prepend the IBEX body detail
  when the message already contains it — ibex-client@3.3.0's ApiError
  embeds "IBEX response (<code>): <detail>" itself, so the unclassified
  carries were logging every unrecognized IBEX error's detail twice
  (and the duplicate reached Discord alert embeds via #475). Tests
  extended to pin the detail appearing exactly once (verified failing
  against the pre-fix code).
- Rewrote the payInvoice raw-fetch seam comment: the 3.2.0
  body-discarding rationale no longer holds on 3.3.0; the seam is
  belt-and-braces only, follow-up to collapse it filed as #478.
- Rewrote httpErrorHandler's docblock and errorHandler's classification
  comment to describe 3.3.0 behavior (ApiError carries the body;
  httpErrorHandler stays as defense-in-depth for the raw-fetch seam).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH
islandbitcoin added a commit that referenced this pull request Aug 12, 2026
…477)

* chore(deps): bump ibex-client to ^3.3.0 for structured error details

ibex-client 3.3.0 (lnflash/ibex-client#12) populates httpCode,
ibexResponse, and ibexMessage on ApiError at construction, so every
IBEX call site now gets the response detail #476 could only extract
at the payInvoice raw-fetch seam under pinned 3.2.0.

No code changes needed: errors.ts already duck-types ibexMessage.
New integration tests construct the real 3.3.0 ApiError (no simulated
fields) and pin extraction, insufficient-balance classification with
account-id stripping, and the unclassified fall-through detail.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

* fix(ibex): dedupe error detail carry, refresh stale 3.2.0 comments

Review fixes for #477:

- errorHandler/httpErrorHandler no longer prepend the IBEX body detail
  when the message already contains it — ibex-client@3.3.0's ApiError
  embeds "IBEX response (<code>): <detail>" itself, so the unclassified
  carries were logging every unrecognized IBEX error's detail twice
  (and the duplicate reached Discord alert embeds via #475). Tests
  extended to pin the detail appearing exactly once (verified failing
  against the pre-fix code).
- Rewrote the payInvoice raw-fetch seam comment: the 3.2.0
  body-discarding rationale no longer holds on 3.3.0; the seam is
  belt-and-braces only, follow-up to collapse it filed as #478.
- Rewrote httpErrorHandler's docblock and errorHandler's classification
  comment to describe 3.3.0 behavior (ApiError carries the body;
  httpErrorHandler stays as defense-in-depth for the raw-fetch seam).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

* fix(ibex): derive dedupe detail from the capped ApiError extraction

httpErrorHandler extracted the detail from the raw FetchError's .data
(uncapped) but compared it against the ApiError-wrapped message, which
embeds only ibex-client's MAX_IBEX_MESSAGE_LENGTH-truncated copy. For
any body over the cap — the Cloudflare-HTML-error-page outage the cap
exists for — the includes() guard always missed, prepending the full
multi-KB body onto every failing call's message with the first 500
chars duplicated (truncated + full), defeating the upstream cap in
logs and Discord alert embeds.

Construct the ApiError first and derive the detail from it: both sides
of the dedupe comparison now use the same bounded string. When the raw
error is already an IbexClientError, wrapped === raw and the extraction
is unchanged. The uncapped body remains available on ibexResponse.

Adds a regression test feeding a body 4x over MAX_IBEX_MESSAGE_LENGTH
through httpErrorHandler, asserting the message stays bounded and the
truncated detail appears exactly once (fails against the previous
implementation).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

* docs(ibex): correct httpErrorHandler comment — ibexResponse does not survive onto the returned error

Round-3 review fix for PR #477: the comment claimed the uncapped body
stays reachable on wrapped.ibexResponse, but wrapped is function-local
and IbexError's constructor copies only name/httpCode/message — during
a Cloudflare-outage scenario a maintainer would search for ibexResponse
and find nothing. State reality: the uncapped body is deliberately
dropped so pino never serializes a multi-KB blob.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015fAxSGEsL2LsMx1uCjAzHH

---------

Co-authored-by: Dread <bobodread@bobodread.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants