feat: implement structured logging and error handling in discord-bot
All checks were successful
CI / test (push) Successful in 4s
CI / build-ha-gateway (push) Successful in 1m7s
CI / build-discord-bot (push) Successful in 47s

This commit is contained in:
Nik Afiq 2026-04-09 05:06:38 +09:00
parent 4186f864f9
commit 1d3e223dbb
6 changed files with 187 additions and 32 deletions

View File

@ -2,3 +2,5 @@ DISCORD_TOKEN=your-bot-token-here
GUILD_ID=your-guild-id-here GUILD_ID=your-guild-id-here
HA_GATEWAY_ADDR=localhost:50051 HA_GATEWAY_ADDR=localhost:50051
OTEL_ENDPOINT= OTEL_ENDPOINT=
LOG_LEVEL=info
LOG_FORMAT=text

View File

@ -2,7 +2,6 @@ package main
import ( import (
"context" "context"
"log/slog"
"os" "os"
"os/signal" "os/signal"
"syscall" "syscall"
@ -15,6 +14,7 @@ import (
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/adapters/secondary/gateway" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/adapters/secondary/gateway"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/app" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/app"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/config" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/config"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/logger"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/telemetry" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/telemetry"
) )
@ -25,27 +25,37 @@ func main() {
cfg, err := config.Load() cfg, err := config.Load()
if err != nil { if err != nil {
slog.Error("config error", "err", err) os.Stderr.WriteString("config error: " + err.Error() + "\n")
os.Exit(1) os.Exit(1)
} }
log := logger.New(cfg.LogFormat, cfg.LogLevel)
log.Info("starting discord-bot",
"version", version,
"ha_gateway_addr", cfg.HAGatewayAddr,
"discord_token", redactToken(cfg.DiscordToken),
"log_level", cfg.LogLevel,
"log_format", cfg.LogFormat,
)
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop() defer stop()
ctx = logger.WithLogger(ctx, log)
shutdown, err := telemetry.Setup(ctx, cfg, version) shutdown, err := telemetry.Setup(ctx, cfg, version)
if err != nil { if err != nil {
slog.Error("telemetry setup failed", "err", err) log.Error("telemetry setup failed", "err", err)
os.Exit(1) os.Exit(1)
} }
haClient, err := gateway.New(ctx, cfg.HAGatewayAddr) haClient, err := gateway.New(ctx, cfg.HAGatewayAddr, log)
if err != nil { if err != nil {
slog.Error("ha-gateway client setup failed", "err", err) log.Error("ha-gateway client setup failed", "err", err)
os.Exit(1) os.Exit(1)
} }
defer func() { defer func() {
if err := haClient.Close(); err != nil { if err := haClient.Close(); err != nil {
slog.Error("ha-gateway client close failed", "err", err) log.Error("ha-gateway client close failed", "err", err)
} }
}() }()
@ -53,7 +63,7 @@ func main() {
session, err := discordgo.New("Bot " + cfg.DiscordToken) session, err := discordgo.New("Bot " + cfg.DiscordToken)
if err != nil { if err != nil {
slog.Error("create discord session failed", "err", err) log.Error("create discord session failed", "err", err)
os.Exit(1) os.Exit(1)
} }
session.Identify.Intents = discordgo.IntentsGuilds session.Identify.Intents = discordgo.IntentsGuilds
@ -62,12 +72,13 @@ func main() {
handler.Register(session) handler.Register(session)
if err := session.Open(); err != nil { if err := session.Open(); err != nil {
slog.Error("open discord session failed", "err", err) log.Error("open discord session failed", "err", err)
os.Exit(1) os.Exit(1)
} }
log.Info("discord session ready", "bot_user", session.State.User.Username)
if err := discordadapter.RegisterCommands(session, cfg.GuildID); err != nil { if err := discordadapter.RegisterCommands(session, cfg.GuildID); err != nil {
slog.Error("register discord commands failed", "err", err) log.Error("register discord commands failed", "err", err)
os.Exit(1) os.Exit(1)
} }
@ -75,18 +86,29 @@ func main() {
if cfg.GuildID != "" { if cfg.GuildID != "" {
scope = cfg.GuildID scope = cfg.GuildID
} }
slog.Info("discord bot started", "command_scope", scope, "ha_gateway_addr", cfg.HAGatewayAddr, "version", version) log.Info("discord bot started", "command_scope", scope, "ha_gateway_addr", cfg.HAGatewayAddr, "version", version)
<-ctx.Done() <-ctx.Done()
slog.InfoContext(ctx, "shutting down discord bot") log.Info("shutdown signal received, closing session")
if err := session.Close(); err != nil { if err := session.Close(); err != nil {
slog.ErrorContext(ctx, "close discord session failed", "err", err) log.Error("close discord session failed", "err", err)
} }
log.Info("shutdown complete")
shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second) shutdownCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
if err := shutdown(shutdownCtx); err != nil { if err := shutdown(shutdownCtx); err != nil {
slog.Error("telemetry shutdown error", "err", err) log.Error("telemetry shutdown error", "err", err)
} }
} }
func redactToken(token string) string {
if token == "" {
return "[not set]"
}
if len(token) <= 8 {
return "[set]"
}
return token[:8] + "..."
}

View File

@ -8,6 +8,7 @@ import (
"time" "time"
apppkg "gitea.nik4nao.com/nik/home-services/discord-bot/internal/app" apppkg "gitea.nik4nao.com/nik/home-services/discord-bot/internal/app"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/logger"
"github.com/bwmarrin/discordgo" "github.com/bwmarrin/discordgo"
) )
@ -36,6 +37,7 @@ func (h *Handler) Register(s *discordgo.Session) {
func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) { func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.InteractionCreate) {
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel() defer cancel()
ctx = logger.WithLogger(ctx, interactionLogger(ctx, i))
switch i.Type { switch i.Type {
case discordgo.InteractionApplicationCommand: case discordgo.InteractionApplicationCommand:
@ -46,9 +48,13 @@ func (h *Handler) onInteractionCreate(s *discordgo.Session, i *discordgo.Interac
} }
func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
log := logger.FromContext(ctx)
start := time.Now()
log.Debug("command received")
data := i.ApplicationCommandData() data := i.ApplicationCommandData()
if len(data.Options) == 0 { if len(data.Options) == 0 {
h.respondError(ctx, s, i.Interaction, true, fmt.Errorf("missing subcommand")) h.respondError(ctx, s, i.Interaction, true, start, fmt.Errorf("missing subcommand"))
return return
} }
@ -57,44 +63,56 @@ func (h *Handler) handleApplicationCommand(ctx context.Context, s *discordgo.Ses
case "light.list": case "light.list":
msg, err := h.app.HandleLightList(ctx) msg, err := h.app.HandleLightList(ctx)
if err != nil { if err != nil {
h.respondError(ctx, s, i.Interaction, false, err) h.respondError(ctx, s, i.Interaction, false, start, err)
return return
} }
h.respondMessage(ctx, s, i.Interaction, msg, false) h.respondMessage(ctx, s, i.Interaction, msg, false)
log.Info("command handled", "duration_ms", time.Since(start).Milliseconds())
case "switch.list": case "switch.list":
msg, err := h.app.HandleSwitchList(ctx) msg, err := h.app.HandleSwitchList(ctx)
if err != nil { if err != nil {
h.respondError(ctx, s, i.Interaction, false, err) h.respondError(ctx, s, i.Interaction, false, start, err)
return return
} }
h.respondMessage(ctx, s, i.Interaction, msg, false) h.respondMessage(ctx, s, i.Interaction, msg, false)
log.Info("command handled", "duration_ms", time.Since(start).Milliseconds())
case "light.on": case "light.on":
if err := h.deferResponse(s, i.Interaction, true); err != nil { if err := h.deferResponse(s, i.Interaction, true); err != nil {
slog.ErrorContext(ctx, "defer discord response failed", "err", err) log.Error("discord response failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return return
} }
msg, err := h.app.HandleLightOn(ctx, requiredStringOption(sub, "light"), optionalUint32Option(sub, "brightness"), optionalUint32Option(sub, "color_temp")) msg, err := h.app.HandleLightOn(ctx, requiredStringOption(sub, "light"), optionalUint32Option(sub, "brightness"), optionalUint32Option(sub, "color_temp"))
h.followup(ctx, s, i.Interaction, msg, true, err) h.followup(ctx, s, i.Interaction, msg, true, start, err)
case "light.off": case "light.off":
if err := h.deferResponse(s, i.Interaction, true); err != nil { if err := h.deferResponse(s, i.Interaction, true); err != nil {
slog.ErrorContext(ctx, "defer discord response failed", "err", err) log.Error("discord response failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return return
} }
msg, err := h.app.HandleLightOff(ctx, requiredStringOption(sub, "light"), optionalUint32Option(sub, "transition")) msg, err := h.app.HandleLightOff(ctx, requiredStringOption(sub, "light"), optionalUint32Option(sub, "transition"))
h.followup(ctx, s, i.Interaction, msg, true, err) h.followup(ctx, s, i.Interaction, msg, true, start, err)
case "light.toggle": case "light.toggle":
if err := h.deferResponse(s, i.Interaction, true); err != nil { if err := h.deferResponse(s, i.Interaction, true); err != nil {
slog.ErrorContext(ctx, "defer discord response failed", "err", err) log.Error("discord response failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return return
} }
msg, err := h.app.HandleLightToggle(ctx, requiredStringOption(sub, "light")) msg, err := h.app.HandleLightToggle(ctx, requiredStringOption(sub, "light"))
h.followup(ctx, s, i.Interaction, msg, true, err) h.followup(ctx, s, i.Interaction, msg, true, start, err)
default: default:
h.respondError(ctx, s, i.Interaction, true, fmt.Errorf("unsupported command: %s.%s", data.Name, sub.Name)) h.respondError(ctx, s, i.Interaction, true, start, fmt.Errorf("unsupported command: %s.%s", data.Name, sub.Name))
} }
} }
func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) { func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session, i *discordgo.InteractionCreate) {
log := logger.FromContext(ctx)
data := i.ApplicationCommandData() data := i.ApplicationCommandData()
var ( var (
choices []apppkg.Choice choices []apppkg.Choice
@ -110,7 +128,7 @@ func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session,
choices = nil choices = nil
} }
if err != nil { if err != nil {
slog.ErrorContext(ctx, "autocomplete failed", "command", data.Name, "err", err) log.Error("command failed", "error", err.Error())
choices = nil choices = nil
} }
@ -135,7 +153,7 @@ func (h *Handler) handleAutocomplete(ctx context.Context, s *discordgo.Session,
Choices: respChoices, Choices: respChoices,
}, },
}); err != nil { }); err != nil {
slog.ErrorContext(ctx, "discord autocomplete response failed", "err", err) log.Error("discord response failed", "error", err.Error())
} }
} }
@ -154,6 +172,7 @@ func (h *Handler) deferResponse(s *discordgo.Session, interaction *discordgo.Int
} }
func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool) { func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool) {
log := logger.FromContext(ctx)
data := &discordgo.InteractionResponseData{Content: content} data := &discordgo.InteractionResponseData{Content: content}
if ephemeral { if ephemeral {
data.Flags = discordgo.MessageFlagsEphemeral data.Flags = discordgo.MessageFlagsEphemeral
@ -162,29 +181,55 @@ func (h *Handler) respondMessage(ctx context.Context, s *discordgo.Session, inte
Type: discordgo.InteractionResponseChannelMessageWithSource, Type: discordgo.InteractionResponseChannelMessageWithSource,
Data: data, Data: data,
}); err != nil { }); err != nil {
slog.ErrorContext(ctx, "discord response failed", "err", err) log.Error("discord response failed", "error", err.Error())
} }
} }
func (h *Handler) respondError(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool, err error) { func (h *Handler) respondError(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, ephemeral bool, start time.Time, err error) {
slog.ErrorContext(ctx, "discord command failed", "err", err) logger.FromContext(ctx).Error("command failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
h.respondMessage(ctx, s, interaction, fmt.Sprintf("Error: %v", err), ephemeral) h.respondMessage(ctx, s, interaction, fmt.Sprintf("Error: %v", err), ephemeral)
} }
func (h *Handler) followup(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool, err error) { func (h *Handler) followup(ctx context.Context, s *discordgo.Session, interaction *discordgo.Interaction, content string, ephemeral bool, start time.Time, err error) {
log := logger.FromContext(ctx)
if err != nil { if err != nil {
slog.ErrorContext(ctx, "discord action failed", "err", err) log.Error("command failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
content = fmt.Sprintf("Error: %v", err) content = fmt.Sprintf("Error: %v", err)
} else {
log.Info("command handled", "duration_ms", time.Since(start).Milliseconds())
} }
params := &discordgo.WebhookParams{Content: content} params := &discordgo.WebhookParams{Content: content}
if ephemeral { if ephemeral {
params.Flags = discordgo.MessageFlagsEphemeral params.Flags = discordgo.MessageFlagsEphemeral
} }
if _, followErr := s.FollowupMessageCreate(interaction, true, params); followErr != nil { if _, followErr := s.FollowupMessageCreate(interaction, true, params); followErr != nil {
slog.ErrorContext(ctx, "discord followup failed", "err", followErr) log.Error("discord response failed", "error", followErr.Error())
} }
} }
func interactionLogger(ctx context.Context, i *discordgo.InteractionCreate) *slog.Logger {
log := logger.FromContext(ctx)
data := i.ApplicationCommandData()
command := data.Name
user := ""
if i.Member != nil && i.Member.User != nil {
user = i.Member.User.Username
} else if i.User != nil {
user = i.User.Username
}
return log.With(
"command", command,
"user", user,
"guild", i.GuildID,
)
}
func requiredStringOption(sub *discordgo.ApplicationCommandInteractionDataOption, name string) string { func requiredStringOption(sub *discordgo.ApplicationCommandInteractionDataOption, name string) string {
for _, opt := range sub.Options { for _, opt := range sub.Options {
if opt.Name == name { if opt.Name == name {

View File

@ -3,8 +3,11 @@ package gateway
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"time"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven" "gitea.nik4nao.com/nik/home-services/discord-bot/internal/core/ports/driven"
"gitea.nik4nao.com/nik/home-services/discord-bot/internal/logger"
hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1" hav1 "gitea.nik4nao.com/nik/home-services/gen/ha/v1"
"go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc" "go.opentelemetry.io/contrib/instrumentation/google.golang.org/grpc/otelgrpc"
"google.golang.org/grpc" "google.golang.org/grpc"
@ -15,9 +18,10 @@ type Client struct {
conn *grpc.ClientConn conn *grpc.ClientConn
lightClient hav1.LightServiceClient lightClient hav1.LightServiceClient
switchClient hav1.SwitchServiceClient switchClient hav1.SwitchServiceClient
log *slog.Logger
} }
func New(ctx context.Context, addr string) (*Client, error) { func New(ctx context.Context, addr string, log *slog.Logger) (*Client, error) {
conn, err := grpc.NewClient( conn, err := grpc.NewClient(
addr, addr,
grpc.WithTransportCredentials(insecure.NewCredentials()), grpc.WithTransportCredentials(insecure.NewCredentials()),
@ -31,6 +35,7 @@ func New(ctx context.Context, addr string) (*Client, error) {
conn: conn, conn: conn,
lightClient: hav1.NewLightServiceClient(conn), lightClient: hav1.NewLightServiceClient(conn),
switchClient: hav1.NewSwitchServiceClient(conn), switchClient: hav1.NewSwitchServiceClient(conn),
log: log,
}, nil }, nil
} }
@ -42,10 +47,17 @@ func (c *Client) Close() error {
} }
func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) { func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/ListLights")
resp, err := c.lightClient.ListLights(ctx, &hav1.ListLightsRequest{}) resp, err := c.lightClient.ListLights(ctx, &hav1.ListLightsRequest{})
if err != nil { if err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return nil, fmt.Errorf("list lights: %w", err) return nil, fmt.Errorf("list lights: %w", err)
} }
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
lights := make([]driven.Light, 0, len(resp.GetLights())) lights := make([]driven.Light, 0, len(resp.GetLights()))
for _, light := range resp.GetLights() { for _, light := range resp.GetLights() {
@ -64,10 +76,17 @@ func (c *Client) ListLights(ctx context.Context) ([]driven.Light, error) {
} }
func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) { func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "SwitchService/ListSwitches")
resp, err := c.switchClient.ListSwitches(ctx, &hav1.ListSwitchesRequest{}) resp, err := c.switchClient.ListSwitches(ctx, &hav1.ListSwitchesRequest{})
if err != nil { if err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return nil, fmt.Errorf("list switches: %w", err) return nil, fmt.Errorf("list switches: %w", err)
} }
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
switches := make([]driven.Switch, 0, len(resp.GetSwitches())) switches := make([]driven.Switch, 0, len(resp.GetSwitches()))
for _, sw := range resp.GetSwitches() { for _, sw := range resp.GetSwitches() {
@ -82,6 +101,8 @@ func (c *Client) ListSwitches(ctx context.Context) ([]driven.Switch, error) {
} }
func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error { func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct *uint32, colorTempKelvin *uint32) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOn")
req := &hav1.TurnOnRequest{EntityId: entityID} req := &hav1.TurnOnRequest{EntityId: entityID}
if brightnessPct != nil { if brightnessPct != nil {
req.BrightnessPct = brightnessPct req.BrightnessPct = brightnessPct
@ -91,26 +112,45 @@ func (c *Client) TurnOnLight(ctx context.Context, entityID string, brightnessPct
} }
if _, err := c.lightClient.TurnOn(ctx, req); err != nil { if _, err := c.lightClient.TurnOn(ctx, req); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn on light %s: %w", entityID, err) return fmt.Errorf("turn on light %s: %w", entityID, err)
} }
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil return nil
} }
func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error { func (c *Client) TurnOffLight(ctx context.Context, entityID string, transition *uint32) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/TurnOff")
req := &hav1.TurnOffRequest{EntityId: entityID} req := &hav1.TurnOffRequest{EntityId: entityID}
if transition != nil { if transition != nil {
req.Transition = transition req.Transition = transition
} }
if _, err := c.lightClient.TurnOff(ctx, req); err != nil { if _, err := c.lightClient.TurnOff(ctx, req); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("turn off light %s: %w", entityID, err) return fmt.Errorf("turn off light %s: %w", entityID, err)
} }
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil return nil
} }
func (c *Client) ToggleLight(ctx context.Context, entityID string) error { func (c *Client) ToggleLight(ctx context.Context, entityID string) error {
start := time.Now()
log := logger.FromContext(ctx).With("grpc.method", "LightService/Toggle")
if _, err := c.lightClient.Toggle(ctx, &hav1.ToggleRequest{EntityId: entityID}); err != nil { if _, err := c.lightClient.Toggle(ctx, &hav1.ToggleRequest{EntityId: entityID}); err != nil {
log.Error("grpc call failed",
"duration_ms", time.Since(start).Milliseconds(),
"error", err.Error(),
)
return fmt.Errorf("toggle light %s: %w", entityID, err) return fmt.Errorf("toggle light %s: %w", entityID, err)
} }
log.Debug("grpc call completed", "duration_ms", time.Since(start).Milliseconds())
return nil return nil
} }

View File

@ -10,6 +10,8 @@ type Config struct {
GuildID string GuildID string
HAGatewayAddr string HAGatewayAddr string
OTELEndpoint string OTELEndpoint string
LogLevel string
LogFormat string
} }
func Load() (*Config, error) { func Load() (*Config, error) {
@ -28,5 +30,14 @@ func Load() (*Config, error) {
GuildID: os.Getenv("GUILD_ID"), GuildID: os.Getenv("GUILD_ID"),
HAGatewayAddr: addr, HAGatewayAddr: addr,
OTELEndpoint: os.Getenv("OTEL_ENDPOINT"), OTELEndpoint: os.Getenv("OTEL_ENDPOINT"),
LogLevel: getenvDefault("LOG_LEVEL", "info"),
LogFormat: getenvDefault("LOG_FORMAT", "json"),
}, nil }, nil
} }
func getenvDefault(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}

View File

@ -0,0 +1,35 @@
package logger
import (
"context"
"fmt"
"log/slog"
"os"
)
type contextKey struct{}
func New(format, level string) *slog.Logger {
var parsed slog.Level
if err := parsed.UnmarshalText([]byte(level)); err != nil {
_, _ = fmt.Fprintf(os.Stderr, "invalid log level %q, falling back to info\n", level)
parsed = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: parsed}
if format == "json" {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
func WithLogger(ctx context.Context, l *slog.Logger) context.Context {
return context.WithValue(ctx, contextKey{}, l)
}
func FromContext(ctx context.Context) *slog.Logger {
if l, ok := ctx.Value(contextKey{}).(*slog.Logger); ok && l != nil {
return l
}
return slog.Default()
}