Sentry discord — sentry-discord
# Sentry issue alert -> Discord. Setup: docs/guides/sentry-discord.md
# Requires SENTRY_CLIENT_SECRET, DISCORD_WEBHOOK_URL, HOOKWAY_API_KEY.
# Acknowledges the existing async memory queue before Discord responds.
# A crash can lose accepted work; memory duplicate history resets on restart.
# Duplicate raw bodies within 24h return 409 and do not enqueue another message.
# Only selected summary fields leave this flow; mentions are disabled.
state_stores:
local:
namespace: hookway
memory: {} # Per process; select Redis or PostgreSQL for shared protection.
server:
addr: ":8080"
read_timeout: "5s"
write_timeout: "10s"
idle_timeout: "60s"
shutdown_timeout: "10s"
publish_timeout: "500ms"
max_body_bytes: 1048576
max_inflight_requests: 256
fanout_concurrency: 8
metrics:
enabled: true
path: /metrics
addr: ":9090"
security:
api_key: "${HOOKWAY_API_KEY}"
forwarded_header_allowlist:
- Content-Type
- Sentry-Hook-Resource
destinations:
discord:
type: webhook
webhook:
url: "${DISCORD_WEBHOOK_URL}"
delivery_mode: async
timeout: 5s
max_queue: 1000
retry: {max_attempts: 5, initial_backoff: 1s, max_backoff: 30s}
# The URL contains Discord's credential; do not forward Sentry credentials.
auth: {type: none}
flows:
sentry_discord:
responses:
dropped: {status: 200, body: ignored}
nodes:
- {id: in, type: http_trigger, description: "Sentry posts notification deliveries here.", path: /webhooks/sentry, methods: [POST]}
- {id: hops, type: loop_guard, description: "Rejects a request that already looped through Hookway.", max_hops: 3}
- {id: rl, type: rate_limit, store: local, description: "Caps inbound Sentry retries per source IP in this process.", rpm: 60, burst: 20, key_by: source_ip}
- {id: body, type: read_body, description: "Buffers the raw body so the signature can be verified.", max_bytes: 1048576}
- id: auth
type: auth
description: "Verifies Sentry-Hook-Signature (unprefixed HMAC-SHA256)."
scheme: hmac_sha256_raw_hex
secret_ref: {provider: env, name: SENTRY_CLIENT_SECRET, refresh: 15m, overlap: 5m}
signature_header: Sentry-Hook-Signature
- {id: replay, type: replay_guard, store: local, description: "Drops a delivery Sentry already succeeded within the TTL.", ttl: 24h, max_entries: 100000}
- {id: env, type: envelope, description: "Decodes the JSON event (`action`, `data`, …).", body_format: json}
- id: selected
type: filter
conditions:
- {source: header, path: Sentry-Hook-Resource, operator: eq, value: event_alert}
- {source: body, path: action, operator: eq, value: triggered}
on_no_match: drop
drop_reason: dropped_unused_event_type
- id: fmt
type: code
timeout: 50ms
script: |
const data = (input.body && input.body.data) || {};
const event = data.event || {};
const issue = data.issue || {};
function meaningful(value) {
if (typeof value !== 'string') return '';
const result = value.trim();
return /^(?:\?+|<?untitled>?|unknown|undefined|null|\(no title\))?$/i.test(result) ? '' : result;
}
function first() {
for (let i = 0; i < arguments.length; i++) {
const result = meaningful(arguments[i]);
if (result) return result;
}
return '';
}
function text(value, fallback, limit) {
if (value === '' || (typeof value !== 'string' && typeof value !== 'number')) value = fallback;
return String(value).replace(/[\u0000-\u001f\u007f]/g, ' ')
.slice(0, limit).replace(/[\\*_`~|<>]/g, '\\#x26;');
}
function exceptionTitle(value) {
if (!value || typeof value !== 'object') return '';
const kind = meaningful(value.type);
const detail = meaningful(value.value);
return kind && detail && detail.indexOf(kind + ':') !== 0 ? kind + ': ' + detail : detail || kind;
}
const entries = Array.isArray(event.entries) ? event.entries : [];
const exceptionEntry = entries.find(entry => entry && entry.type === 'exception');
const exceptions = event.exception && Array.isArray(event.exception.values)
? event.exception.values : exceptionEntry && exceptionEntry.data && exceptionEntry.data.values;
let exception = '';
if (Array.isArray(exceptions)) {
for (let i = exceptions.length - 1; i >= 0 && !exception; i--) exception = exceptionTitle(exceptions[i]);
}
const message = event.message && typeof event.message === 'object' ? event.message : {};
const logentry = event.logentry || {};
const title = text(first(event.title, event.message, message.formatted, message.message,
logentry.formatted, logentry.message, exception, exceptionTitle(event.metadata), issue.title),
'Issue alert triggered', 250);
const rule = text(first(data.triggered_rule, data.issue_alert && data.issue_alert.title), 'Sentry issue alert', 100);
function namedProject(value) {
if (value && typeof value === 'object') return first(value.name, value.slug);
const result = meaningful(value);
return /^\d+$/.test(result) ? '' : result;
}
function tag(name) {
if (!Array.isArray(event.tags)) return '';
const found = event.tags.find(t => Array.isArray(t) && t[0] === name);
return found ? meaningful(found[1]) : '';
}
// Sentry's event API URL contains the project slug even when project is an ID.
let urlProject = '';
if (typeof event.url === 'string') {
const match = /^https:\/\/[^/]+\/api\/0\/projects\/[^/]+\/([^/?#]+)\/events\//.exec(event.url);
if (match) {
try { urlProject = decodeURIComponent(match[1]); } catch (_) { urlProject = match[1]; }
}
}
const project = text(first(namedProject(event.project), namedProject(data.project), namedProject(issue.project),
event.project_name, event.project_slug, tag('project'), tag('project.slug'), urlProject), 'unknown project', 60);
const env = text(first(event.environment, tag('environment')), 'unknown', 60);
const level = text(event.level, 'unknown', 30);
let content = '**Sentry: ' + title + '**\nRule: ' + rule +
'\nProject: ' + project + ' · Environment: ' + env + ' · Level: ' + level;
if (typeof event.web_url === 'string' && event.web_url.length <= 800 &&
/^https:\/\/[^\s<>]+$/.test(event.web_url)) content += '\n<' + event.web_url + '>';
return {body: {content: content, allowed_mentions: {parse: []}}, vars: input.vars};
# Empty target preserves the complete Discord webhook URL (including query).
- {id: out, type: publish, destination: discord, target: "", wire_body: json}
edges: [in>hops, hops>rl, rl>body, body>auth, auth>replay, replay>env, env>selected, selected>fmt, fmt>out]