9 changed files with 302 additions and 27 deletions
@ -0,0 +1,144 @@ |
|||||||
|
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) |
||||||
|
} |
||||||
Loading…
Reference in new issue