Loading...
본문 바로가기
👥
총 방문자
📖
0개 이상
총 포스팅
🧑
오늘 방문자 수
📅
0일째
블로그 운영

여러분의 방문을 환영해요! 🎉

다양한 개발 지식을 쉽고 재미있게 알려드리는 블로그가 될게요. 함께 성장해요! 😊

코딩 정보/NextJs

[Nextjs] sentry 디스코드 웹훅을 연결해보자

by 꽁이꽁설꽁돌 2025. 9. 12.
728x90
반응형
     

목차

     

     

    오류가 발생했을 때 빠르게 인지하고 수정할 수 있도록 센트리에 디스코드 훅을 도입하고자 했다.

    센트리에서 제공해주는 것을 쓰면 유료기 때문에 직접 세팅해서 사용하는 방법을 공유한다.

     

    웹훅 url 복사하기

    먼저 디스코드에 들어가서 서버 연동을 들어가 보자

    이때 이 버튼이 뜨지 않으면 권한이 없는 것이니 권한을 설정해주면 된다.

     

     

    그 후 웹훅을 만들어주고 웹훅 url을 복사해주자

     

     

    nextjs 세팅 (엔드포인트 만들기)

    이때 nextjs의 api 엔드포인트를 사용했다.

    다음과 같이 app 폴더의 api 폴더에 sentry-webhook 폴더에 라우터를 만들어 주고 코드를 복붙해주자

    다음과 같이 폴더 구조를 만들어주자

     

    webhook을 위한 post 엔드포인트 코드이다.

    // app/api/sentry-webhook/route.ts
    export const runtime = 'nodejs';
    
    const { DISCORD_WEBHOOK_URL } = process.env;
    
    function ellipsis(s: unknown, max = 300) {
      const str = String(s ?? '');
      return str.length > max ? `${str.slice(0, max - 1)}…` : str;
    }
    
    function colorByLevel(level?: string) {
      switch ((level || '').toLowerCase()) {
        case 'fatal':
          return 0x8e44ad; // 보라
        case 'error':
          return 0xe74c3c; // 빨강
        case 'warning':
          return 0xf39c12; // 주황
        case 'info':
          return 0x3498db; // 파랑
        case 'debug':
          return 0x95a5a6; // 회색
        default:
          return 0x2ecc71; // 초록
      }
    }
    
    function emojiByLevel(level?: string) {
      switch ((level || '').toLowerCase()) {
        case 'fatal':
          return '🟪';
        case 'error':
          return '🟥';
        case 'warning':
          return '🟧';
        case 'info':
          return '🟦';
        case 'debug':
          return '⬜';
        default:
          return '🟩';
      }
    }
    
    function compactDate(d?: string) {
      return d
        ? new Date(d).toISOString().replace('T', ' ').replace('Z', 'Z')
        : undefined;
    }
    
    function safeTagList(event: any, limit = 6) {
      const tags: Array<[string, string]> = event?.tags ?? [];
      const arr = Array.isArray(tags) ? tags : Object.entries(tags || {});
      return arr
        .slice(0, limit)
        .map(([k, v]) => `\`${k}:${v}\``)
        .join(' · ');
    }
    
    function pickTopFrame(event: any) {
      const entry = (event?.entries || []).find(
        (e: any) => e?.type === 'exception',
      );
      const values = entry?.data?.values || event?.exception?.values || [];
      const ex = values[values.length - 1] || values[0];
      const frames = ex?.stacktrace?.frames || [];
      if (!frames.length) return undefined;
    
      const frame =
        [...frames].reverse().find((f) => f?.in_app) || frames[frames.length - 1];
      const location = [
        frame?.filename || frame?.abs_path || '?',
        frame?.lineno ?? '?',
      ]
        .filter(Boolean)
        .join(':');
      const func = frame?.function || '(anonymous)';
      return { location, func };
    }
    
    async function postToDiscord(payload: any) {
      if (!DISCORD_WEBHOOK_URL) throw new Error('DISCORD_WEBHOOK_URL not set');
      const res = await fetch(DISCORD_WEBHOOK_URL, {
        method: 'POST',
        headers: { 'Content-Type': 'application/json' },
        body: JSON.stringify(payload),
      });
      if (!res.ok) {
        const text = await res.text().catch(() => '');
        throw new Error(`Discord webhook failed: ${res.status} ${text}`);
      }
    }
    
    function extract(body: any) {
      const issue = body?.data?.issue ?? body?.issue;
      const event = body?.data?.event ?? body?.event;
    
      const project =
        issue?.project?.name ?? event?.project ?? body?.project ?? 'Unknown';
      const level = (event?.level ?? issue?.level ?? 'error').toLowerCase();
      const environment =
        event?.environment ??
        issue?.environment ??
        body?.data?.environment ??
        'unknown';
    
      const release =
        event?.release ??
        event?.contexts?.app?.version ??
        issue?.firstRelease?.version ??
        undefined;
    
      const user =
        event?.user?.email || event?.user?.username || event?.user?.id || undefined;
    
      const shortId = issue?.shortId ?? undefined;
      const issueUrl = issue?.url ?? body?.url ?? undefined;
      const culprit = event?.culprit ?? issue?.culprit ?? undefined;
      const message = event?.message ?? issue?.title ?? 'Unknown error';
    
      const counts = {
        count: issue?.count ? String(issue.count) : undefined,
        users: issue?.userCount ? String(issue.userCount) : undefined,
        firstSeen: compactDate(issue?.firstSeen),
        lastSeen: compactDate(issue?.lastSeen),
      };
    
      const top = pickTopFrame(event);
      const tags = safeTagList(event, 8);
    
      return {
        project,
        level,
        environment,
        release,
        user,
        shortId,
        issueUrl,
        culprit,
        message,
        counts,
        top,
        tags,
      };
    }
    
    export async function POST(req: Request) {
      try {
        const body = await req.json();
        const {
          project,
          level,
          environment,
          release,
          user,
          shortId,
          issueUrl,
          culprit,
          message,
          counts,
          top,
          tags,
        } = extract(body);
    
        const levelEmoji = emojiByLevel(level);
    
        const title = shortId
          ? `${levelEmoji} [${project}] ${ellipsis(message, 190)} • ${shortId}`
          : `${levelEmoji} [${project}] ${ellipsis(message, 220)}`;
    
        const lines: string[] = [];
        if (culprit) lines.push(`**Culprit**: \`${ellipsis(culprit, 180)}\``);
        if (top)
          lines.push(
            `**Top Frame**: \`${ellipsis(`${top.location} · ${top.func}`, 220)}\``,
          );
        if (tags) lines.push(`**Tags**: ${ellipsis(tags, 900)}`);
        const description = lines.join('\n');
    
        const fields = [
          environment && {
            name: 'Environment',
            value: `\`${environment}\``,
            inline: true,
          },
          release && {
            name: 'Release',
            value: `\`${ellipsis(release, 60)}\``,
            inline: true,
          },
          user && {
            name: 'User',
            value: `\`${ellipsis(user, 60)}\``,
            inline: true,
          },
          counts.count && {
            name: 'Events',
            value: `\`${counts.count}\``,
            inline: true,
          },
          counts.users && {
            name: 'Users',
            value: `\`${counts.users}\``,
            inline: true,
          },
          counts.firstSeen && {
            name: 'First Seen',
            value: counts.firstSeen,
            inline: true,
          },
          counts.lastSeen && {
            name: 'Last Seen',
            value: counts.lastSeen,
            inline: true,
          },
          issueUrl && {
            name: 'Issue',
            value: `[Open in Sentry](${issueUrl})`,
            inline: false,
          },
        ].filter(Boolean) as Array<{
          name: string;
          value: string;
          inline?: boolean;
        }>;
    
        const embed = {
          title,
          url: issueUrl,
          description: ellipsis(description, 3900),
          color: colorByLevel(level),
          timestamp: new Date().toISOString(),
          footer: { text: `Sentry → Discord • ${project}` },
          fields,
          author: {
            name: 'Sentry Alert',
            url: issueUrl,
            icon_url:
              'https://raw.githubusercontent.com/getsentry/sentry-docs/main/src/images/favicon.png',
          },
        };
    
        await postToDiscord({ username: 'Sentry Bot', embeds: [embed] });
        return Response.json({ ok: true });
      } catch (err) {
        console.error('❌ Sentry Webhook 처리 실패:', err);
        return new Response('fail', { status: 200 });
      }
    }

     

    env파일을 그 후 만들어 주어 디스코드 웹훅을 연결해주자

    DISCORD_WEBHOOK_URL=""

     

    Sentry 세팅

    다음과 같이 sentry에서 integration에 들어가자

     


    아래와 같이 배포한 사이트 url에 연결해주자


    권한은 issue & event만 read 가능하게 만들어 주면 된다.

    나머지 권한은 프로젝트에 맞게 해주자

     

    그 후 issue에서 alert에 들어가주자

     

    아래와 같이 new-issue 새로운 에러 발생 시와

    해결된 에러가 재발생 시를 체크해 준다.

    (필요하면 프로젝트에 맞게 더 커스텀하자)

     

     

    then에서 내가 만든 integration을 꼭 연결해주자

     

    저기에 있는 send test notification을 눌렀을 때 디스코드에 메세지가 오면 성공적으로 연결된 것이다!

     

     

     

    실제 오류 테스트 방법

    만약 실제 배포 사이트에서 오류 발생 시 오류가 오는지 확인하고 싶다면 다음과 같이 세팅하고 실험해보면 된다.

    같은 에러도 1분안에 다시 재알림가능

     

    얼마나 자주 이슈가 발생할지 세팅 (5분 리미트면 쉽게 확인 가능하다.)

     

    반응형