Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
214 changes: 214 additions & 0 deletions .github/scripts/sentry-daily-digest.sh
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
#!/usr/bin/env bash
set -euo pipefail

: "${SENTRY_TOKEN:?SENTRY_TOKEN 이 없습니다}"
: "${SENTRY_ORG:?SENTRY_ORG 가 없습니다}"
: "${SENTRY_PROJECT:?SENTRY_PROJECT 가 없습니다}"
SENTRY_ENV="${SENTRY_ENV:-production}"
DRY_RUN="${DRY_RUN:-0}"
if [ "$DRY_RUN" != "1" ]; then
: "${DISCORD_BOT_TOKEN:?DISCORD_BOT_TOKEN 이 없습니다}"
: "${CHANNEL_ID:?CHANNEL_ID 가 없습니다}"
fi

case "$SENTRY_ENV" in
production) LABEL="[WEB·PROD]" ;;
staging) LABEL="[WEB·STG]" ;;
*) LABEL="[WEB·$(printf '%s' "$SENTRY_ENV" | tr '[:lower:]' '[:upper:]')]" ;;
esac

API="https://sentry.io/api/0"
WORK=$(mktemp -d)
trap 'rm -rf "$WORK"' EXIT

send_discord() {
local text="$1" code
if [ "$DRY_RUN" = "1" ]; then
printf '%s\n%s\n' "----- Discord 메시지 ($(jq -rn --arg t "$text" '$t | length')자) -----" "$text"
return 0
fi
code=$(curl -s --max-time 10 -o "$WORK/discord.json" -w '%{http_code}' \
-X POST "https://discord.com/api/v10/channels/$CHANNEL_ID/messages" \
-H "Authorization: Bot $DISCORD_BOT_TOKEN" \
-H "Content-Type: application/json; charset=utf-8" \
--data "$(jq -n --arg content "$text" '{content: $content, allowed_mentions: {parse: []}}')") || code="000"
if [ "$code" -lt 200 ] || [ "$code" -ge 300 ]; then
echo "::error::Discord 전송 실패 (HTTP $code) $(cat "$WORK/discord.json" 2>/dev/null)"
return 1
fi
}

# 조회가 실패한 날을 "에러 없는 날"과 구분하려고 실패도 채널에 알린다
fail() {
echo "::error::$1"
send_discord "⚠️ $LABEL Sentry 일간 요약 실패: $1" || true
exit 1
}

sentry_get() {
local out="$1" url="$2" code
shift 2
code=$(curl -sS --max-time 20 --get -o "$out" -D "$out.headers" -w '%{http_code}' \
-H "Authorization: Bearer $SENTRY_TOKEN" "$url" "$@") || code="000"
echo "$code"
}

is_2xx() { [ "${1:0:1}" = "2" ] && [ "${#1}" -eq 3 ]; }

CODE=$(sentry_get "$WORK/project.json" "$API/projects/$SENTRY_ORG/$SENTRY_PROJECT/")
is_2xx "$CODE" || fail "프로젝트 조회 HTTP $CODE"
PROJECT_ID=$(jq -r '.id // empty' "$WORK/project.json")
[ -n "$PROJECT_ID" ] || fail "프로젝트 ID 없음"

CODE=$(sentry_get "$WORK/issues.json" "$API/organizations/$SENTRY_ORG/issues/" \
--data-urlencode "project=$PROJECT_ID" \
--data-urlencode "environment=$SENTRY_ENV" \
--data-urlencode "statsPeriod=24h" \
--data-urlencode "groupStatsPeriod=24h" \
--data-urlencode "query=is:unresolved lastSeen:-24h" \
--data-urlencode "sort=freq" \
--data-urlencode "limit=100")
is_2xx "$CODE" || fail "이슈 조회 HTTP $CODE"
jq -e 'type == "array"' "$WORK/issues.json" >/dev/null || fail "이슈 응답 형식 오류"

UNRESOLVED=""
CODE=$(sentry_get "$WORK/unresolved.json" "$API/organizations/$SENTRY_ORG/issues/" \
--data-urlencode "project=$PROJECT_ID" \
--data-urlencode "environment=$SENTRY_ENV" \
--data-urlencode "statsPeriod=30d" \
--data-urlencode "query=is:unresolved" \
--data-urlencode "limit=1")
if is_2xx "$CODE"; then
UNRESOLVED=$(grep -i '^x-hits:' "$WORK/unresolved.json.headers" | tr -dc '0-9' || true)
fi

LIST_URL="https://$SENTRY_ORG.sentry.io/issues/?project=$PROJECT_ID&environment=$SENTRY_ENV&query=is%3Aunresolved&statsPeriod=24h"

jq --argjson now "$(date +%s)" '
def ts: sub("\\.[0-9]+"; "") | sub("\\+00:00$"; "Z") | fromdateiso8601;
($now - 86400) as $since
| length as $fetched
| map({
id: (.id | tostring),
title: (.title // "(제목 없음)"),
message: (.metadata.value // ""),
culprit: (.culprit // ""),
file: (.metadata.filename // ""),
func: (.metadata.function // ""),
link: .permalink,
events: (if (.stats["24h"] | type) == "array"
then (.stats["24h"] | map(.[1]) | add // 0)
else ((.count // "0") | tonumber) end),
users: (.userCount // 0),
firstSeen: ((.firstSeen // "1970-01-01T00:00:00Z") | ts),
substatus: .substatus
})
| sort_by(-.events)
| {
now: $now,
since: $since,
fetched: $fetched,
new: map(select(.firstSeen >= $since)),
regressed: map(select(.firstSeen < $since and .substatus == "regressed")),
top: .[0:3]
}
' "$WORK/issues.json" > "$WORK/classified.json" || fail "이슈 분류 실패"

PAGES='{}'
while read -r ISSUE_ID; do
[[ "$ISSUE_ID" =~ ^[0-9]+$ ]] || continue
CODE=$(sentry_get "$WORK/event-$ISSUE_ID.json" \
"$API/organizations/$SENTRY_ORG/issues/$ISSUE_ID/events/latest/" \
--data-urlencode "environment=$SENTRY_ENV")
is_2xx "$CODE" || continue
PAGE=$(jq -r '
[(.tags // [])[] | select(.key == "url") | .value][0]
// [(.tags // [])[] | select(.key == "transaction") | .value][0]
// ""
| sub("^[a-z]+://[^/]+"; "") | sub("[?#].*$"; "")
' "$WORK/event-$ISSUE_ID.json" 2>/dev/null) || continue
if [ -n "$PAGE" ]; then
PAGES=$(jq -c --arg id "$ISSUE_ID" --arg page "$PAGE" '. + {($id): $page}' <<<"$PAGES")
fi
done < <(jq -r '[.new[0:5][], .regressed[0:5][], .top[]] | map(.id) | unique | .[]' "$WORK/classified.json")

if [ "$DRY_RUN" = "1" ]; then
echo "----- 이슈별 원본 값 (필드 확인용) -----"
jq -r '["shortId", "stats24h합", "count", "lifetime", "userCount", "substatus", "firstSeen", "title", "metadata.value", "culprit"],
(.[] | [.shortId, ((.stats["24h"] // []) | map(.[1]) | add // 0), .count, .lifetime.count, .userCount, .substatus, .firstSeen, .title, .metadata.value, .culprit])
| @tsv' "$WORK/issues.json"
echo "미해결(30일) X-Hits: ${UNRESOLVED:-없음}"
echo "발생 페이지: $PAGES"
fi

MESSAGE=$(jq -r \
--arg label "$LABEL" \
--arg unresolved "$UNRESOLVED" \
--arg listUrl "$LIST_URL" \
--argjson pages "$PAGES" '
def kst: . + 32400 | strftime("%m/%d %H:%M");
def clip($n): if length > $n then .[0:$n - 1] + "…" else . end;
# 이슈 제목·메시지는 외부 입력 — 링크 라벨 탈출과 서식 깨짐을 이스케이프로 막는다
def esc: gsub("(?<c>[\\[\\]\\\\`*_~|<>])"; "\\\(.c)");

def headline:
. as $i
| if $i.message != "" and ($i.title | contains($i.message) | not)
then "\($i.title): \($i.message)" else $i.title end;

def where:
([.culprit | capture("data-sentry-component=\"(?<c>[^\"]+)\"") | .c] | first) as $component
| if $component then $component
elif .func != "" then .func
elif .file != "" then (.file | split("/") | last)
else .culprit end;

def itemLine:
([($pages[.id] // "" | clip(60)), (where | clip(80))] | map(select(. != "") | esc)) as $loc
| "• \(headline | clip(100) | esc)"
+ (if ($loc | length) > 0 then "\n 📍 \($loc | join(" · "))" else "" end)
+ "\n \(.events)회 · 유저 \(.users)명 · [Sentry](<\(.link)>)";

def section($title; $items; $max):
if ($items | length) == 0 then empty
else
([$title] + ($items[0:$max] | map(itemLine))
+ (if ($items | length) > $max then [" 외 \(($items | length) - $max)건"] else [] end))
| join("\n")
end;

def topSection($items):
if ($items | length) == 0 then empty
else
(["🔥 24시간 최다 발생"]
+ ($items | to_entries | map(
($pages[.value.id] // "") as $page
| "\(.key + 1). \(.value | headline | clip(60) | esc)"
+ (if $page != "" then " · \($page | clip(40) | esc)" else "" end)
+ " · \(.value.events)회 · [Sentry](<\(.value.link)>)")))
| join("\n")
end;

. as $d
| (if $unresolved != "" then " · 미해결(30일) \($unresolved)건" else "" end) as $unresolvedPart
| "[미해결 이슈 전체 보기](<\($listUrl)>)" as $footer

| if ($d.new | length) == 0 and ($d.regressed | length) == 0 then
"📋 \($label) Sentry 지난 24시간 새 이슈·재발 없음 · 발생 이슈 \($d.fetched)건\($unresolvedPart) · \($footer)"
else
def build($max; $withTop):
[
"📋 \($label) Sentry 일간 요약 (\($d.since | kst) ~ \($d.now | kst) KST)\n새 이슈 \($d.new | length)건 · 재발 \($d.regressed | length)건 · 발생 이슈 \($d.fetched)건\(if $d.fetched >= 100 then "(조회 상한)" else "" end)\($unresolvedPart)",
section("🆕 새 이슈"; $d.new; $max),
section("🔁 재발 (해결 처리 뒤 다시 발생)"; $d.regressed; $max),
(if $withTop then topSection($d.top) else empty end),
$footer
] | join("\n\n");
# Discord 메시지 상한 2000자 — 최다 발생 섹션부터 빼고, 그래도 넘치면 섹션별 건수를 줄인다
[build(5; true), build(5; false), build(3; false), build(2; false), build(1; false)]
| map(select(length <= 2000))
| .[0] // (build(1; false) | .[0:1990] + "…")
end
' "$WORK/classified.json") || fail "메시지 조립 실패"

send_discord "$MESSAGE"
35 changes: 35 additions & 0 deletions .github/workflows/sentry-daily-digest.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
name: Sentry Daily Digest

on:
schedule:
- cron: '0 1 * * *' # 10:00 KST
workflow_dispatch:

permissions: {}

concurrency:
group: sentry-daily-digest
cancel-in-progress: false

jobs:
digest:
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v7
with:
sparse-checkout: .github/scripts
persist-credentials: false

- name: Sentry 요약 전송 (허거덩 봇)
env:
SENTRY_TOKEN: ${{ secrets.SENTRY_READ_TOKEN }}
SENTRY_ORG: piki-92
SENTRY_PROJECT: piki-web
SENTRY_ENV: production
DISCORD_BOT_TOKEN: ${{ secrets.DISCORD_BOT_TOKEN }}
CHANNEL_ID: ${{ secrets.DISCORD_SENTRY_CHANNEL_ID }}
run: bash .github/scripts/sentry-daily-digest.sh
6 changes: 6 additions & 0 deletions apps/web/next.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -130,4 +130,10 @@ export default withSentryConfig(nextConfig, {
bundleSizeOptimizations: {
excludeDebugStatements: true,
},
/** DOM 에 data-sentry-component 를 붙여 Rage Click 등 이슈 선택자에 컴포넌트 이름이 보이게 함 (Turbopack 빌드용) */
_experimental: {
turbopackReactComponentAnnotation: {
enabled: true,
},
},
});
Loading