Browse Source

fix(notion): 수신자 해석 캐싱·실패 로깅·진단 로그 추가

- users/{id} 조회 결과를 Client에 캐싱(성공·실패 모두): renderAllProps +
  assigneeIDs + 작성자 조회의 중복 호출 제거 → 레이트리밋 회피
- 조회 실패/이메일 없음 시 WARN 로그 후 수신자 목록에서 제외(기존엔 조용히
  빈 문자열 반환으로 묻힘)
- deliver에 "notion recipients resolved" 진단 로그(assigneeProps/mentions/
  recipients/authorExcluded) 추가 — DM 0건 원인 추적 가능

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
main
지대한 3 days ago
parent
commit
7ed88ba384
  1. 52
      internal/notion/notion.go

52
internal/notion/notion.go

@ -12,6 +12,7 @@ import (
"sort"
"strconv"
"strings"
"sync"
"time"
"git/palnet/slack-notifier/internal/notify"
@ -32,6 +33,17 @@ type Client struct {
assigneeProperties []string // 알림 대상 People 속성들 (담당자/처리자/참조인원 등)
snap *SnapshotStore
http *http.Client
userMu sync.Mutex
userCache map[string]userInfo // userID → 조회 결과 (성공/실패 모두 캐시)
}
// userInfo is the cached result of a users/{id} lookup. ok=false면 조회 실패라
// 수신자 목록에서 제외한다(빈 이메일).
type userInfo struct {
name string
email string
ok bool
}
func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client {
@ -40,6 +52,7 @@ func New(token string, assigneeProperties []string, snap *SnapshotStore) *Client
assigneeProperties: assigneeProperties,
snap: snap,
http: &http.Client{Timeout: 10 * time.Second},
userCache: make(map[string]userInfo),
}
}
@ -318,8 +331,9 @@ func (c *Client) deliver(ctx context.Context, pg *page, ev *event, label, header
context = "by " + strings.Join(authorNames, ", ")
}
assigneeIDs := c.assigneeIDs(pg)
recipients := make(map[string]bool) // email → mentioned?
for _, id := range c.assigneeIDs(pg) {
for _, id := range assigneeIDs {
if email := c.resolveEmail(ctx, id); email != "" {
if _, ok := recipients[email]; !ok {
recipients[email] = false
@ -332,6 +346,18 @@ func (c *Client) deliver(ctx context.Context, pg *page, ev *event, label, header
}
}
// 수신자 해석 결과를 남겨 "왜 DM이 안 갔는지"를 로그로 추적 가능하게 한다.
excluded := 0
for email := range recipients {
if authorEmails[email] {
excluded++
}
}
slog.Info("notion recipients resolved",
"event", ev.Type, "label", label,
"assigneeProps", len(assigneeIDs), "mentions", len(mentionIDs),
"recipients", len(recipients), "authorExcluded", excluded)
var notes []notify.Notification
for email, mentioned := range recipients {
if authorEmails[email] {
@ -626,7 +652,20 @@ func (c *Client) resolveAuthors(ctx context.Context, ev *event) (names []string,
return names, emails
}
// resolveUser looks up a user's name/email via users/{id}, memoizing the
// result (성공·실패 모두). 조회 실패 시 빈 이메일을 돌려주고 호출부는 수신자
// 목록에서 제외한다. 같은 이벤트 안 중복 조회를 없애 레이트리밋을 피한다.
func (c *Client) resolveUser(ctx context.Context, userID string) (name, email string) {
if userID == "" {
return "", ""
}
c.userMu.Lock()
if cached, ok := c.userCache[userID]; ok {
c.userMu.Unlock()
return cached.name, cached.email
}
c.userMu.Unlock()
var u struct {
Name string `json:"name"`
Type string `json:"type"`
@ -635,11 +674,22 @@ func (c *Client) resolveUser(ctx context.Context, userID string) (name, email st
} `json:"person"`
}
if err := c.get(ctx, "users/"+userID, &u); err != nil {
// 실패도 캐시(이벤트 내 재시도 폭주 방지)하되, 경고는 남긴다.
slog.Warn("notion resolve user failed — 수신자에서 제외", "user", userID, "err", err)
c.userMu.Lock()
c.userCache[userID] = userInfo{ok: false}
c.userMu.Unlock()
return "", ""
}
if u.Type == "person" {
email = u.Person.Email
}
if email == "" {
slog.Warn("notion user has no email — 수신자에서 제외", "user", userID, "name", u.Name, "type", u.Type)
}
c.userMu.Lock()
c.userCache[userID] = userInfo{name: u.Name, email: email, ok: true}
c.userMu.Unlock()
return u.Name, email
}

Loading…
Cancel
Save