Hookway

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]