package logging import ( "context" "fmt" "log/slog" "os" "strings" "time" "git/palnet/slack-notifier/internal/notify" ) // AlertPoster는 에러 알림을 Slack 채널로 보내는 최소 인터페이스. // 실패해도 slog를 호출하지 않아야 한다(재귀 방지) — slack.Client.PostAlert가 이를 만족. type AlertPoster interface { PostAlert(ctx context.Context, channel, text string, blocks []notify.Block) error } // slackAlertHandler는 다른 slog.Handler를 감싸, Error 이상 레벨의 로그를 // 지정한 Slack 채널로도 함께 알린다(보기 좋은 Block Kit 형식). type slackAlertHandler struct { base slog.Handler poster AlertPoster channel string attrs []slog.Attr // With로 누적된 속성(service/env 등) } func newSlackAlertHandler(base slog.Handler, poster AlertPoster, channel string) *slackAlertHandler { return &slackAlertHandler{base: base, poster: poster, channel: channel} } func (h *slackAlertHandler) Enabled(ctx context.Context, level slog.Level) bool { return h.base.Enabled(ctx, level) } func (h *slackAlertHandler) Handle(ctx context.Context, rec slog.Record) error { if rec.Level >= slog.LevelError { attrs := make([]slog.Attr, 0, len(h.attrs)+rec.NumAttrs()) attrs = append(attrs, h.attrs...) rec.Attrs(func(a slog.Attr) bool { attrs = append(attrs, a) return true }) text, blocks := buildAlert(rec.Level, rec.Message, rec.Time, attrs) // 비동기 — 로깅 경로를 막지 않게. 발송 실패는 stderr로만(다시 slog로 가면 무한루프). go func() { if err := h.poster.PostAlert(context.Background(), h.channel, text, blocks); err != nil { fmt.Fprintf(os.Stderr, "alert post failed: %v\n", err) } }() } return h.base.Handle(ctx, rec) } func (h *slackAlertHandler) WithAttrs(attrs []slog.Attr) slog.Handler { merged := make([]slog.Attr, 0, len(h.attrs)+len(attrs)) merged = append(merged, h.attrs...) merged = append(merged, attrs...) return &slackAlertHandler{base: h.base.WithAttrs(attrs), poster: h.poster, channel: h.channel, attrs: merged} } func (h *slackAlertHandler) WithGroup(name string) slog.Handler { return &slackAlertHandler{base: h.base.WithGroup(name), poster: h.poster, channel: h.channel, attrs: h.attrs} } // buildAlert는 로그 레코드를 보기 좋은 Slack 메시지로 만든다. // 맨 앞에 프로젝트(service)와 환경(env)을 표시해 어느 프로젝트의 에러인지 한눈에 보이게 한다. func buildAlert(level slog.Level, msg string, t time.Time, attrs []slog.Attr) (string, []notify.Block) { // service/env는 머리말로 분리, 나머지는 상세 필드로. service, env := "", "" var fields []notify.Block for _, a := range attrs { switch a.Key { case "service": service = a.Value.String() case "env": env = a.Value.String() default: if len(fields) < 10 { // Slack section fields 최대 10개 fields = append(fields, notify.Block{ "type": "mrkdwn", "text": "*" + a.Key + "*\n" + truncate(esc(a.Value.String()), 500), }) } } } project := service if project == "" { project = "app" } if env != "" { project += " · " + env } // 플레인 텍스트 알림(목록/푸시용)에도 프로젝트를 앞세운다. text := fmt.Sprintf("🚨 [%s] %s", project, msg) blocks := []notify.Block{ {"type": "header", "text": notify.Block{ "type": "plain_text", "text": truncate("🚨 ["+project+"] 에러 발생", 150), "emoji": true, }}, {"type": "section", "text": notify.Block{ "type": "mrkdwn", "text": "*" + truncate(esc(msg), 1500) + "*", }}, } if len(fields) > 0 { blocks = append(blocks, notify.Block{"type": "section", "fields": fields}) } ts := "" if !t.IsZero() { ts = t.Format("2006-01-02 15:04:05") } ctxLine := strings.TrimSpace(strings.Join(nonEmpty(level.String(), project, ts), " · ")) blocks = append(blocks, notify.Block{ "type": "context", "elements": []notify.Block{{"type": "mrkdwn", "text": ctxLine}}, }) return text, blocks } func nonEmpty(vals ...string) []string { out := make([]string, 0, len(vals)) for _, v := range vals { if v != "" { out = append(out, v) } } return out } func truncate(s string, max int) string { r := []rune(s) if len(r) <= max { return s } return string(r[:max]) + "…" } // esc는 Slack mrkdwn에서 의미를 갖는 문자만 최소 이스케이프한다. func esc(s string) string { return strings.NewReplacer("&", "&", "<", "<", ">", ">").Replace(s) }