Compare commits

...

2 Commits

Author SHA1 Message Date
clawbot
412eed0d54 Emit slog attributes from every handler (closes #19)
All checks were successful
check / check (push) Successful in 54s
check / check (pull_request) Successful in 39s
The handlers took attributes and dropped them on the floor. Handle never
read record.Attrs, so the inline slog.Info("casting", "device", d) form
lost its fields; WithAttrs returned the receiver unchanged, so anything
attached to a derived logger vanished; and WithGroup did the same, so
grouping silently did nothing. The JSON and webhook handlers marshaled
the slog.Record value directly, which cannot work: a record keeps its
attributes in unexported fields, so encoding/json only ever saw Time,
Message, Level and PC.

The consequence was perverse. Converting log.Printf("[%s] Casting %s",
device, file) into structured attributes, as the Go styleguide asks,
left the output with strictly less information than before, and the
calling code reviewed as correct because it was correct.

A small shared attribute layer now holds the accumulated attributes and
open groups. It is copy-on-write, so two loggers derived from one parent
cannot leak attributes into each other, and it qualifies attributes by
the groups open at the time they were attached, per the slog.Handler
contract. Rendering follows each handler's format: the JSON and webhook
handlers emit attributes as object fields with groups as nested objects,
merging a group named twice rather than duplicating its key; the console
handler appends key=value pairs, groups flattened to dotted keys, quoted
only where a bare value would be ambiguous.

Values are resolved through slog.Value.Resolve, so LogValuer values are
reported as the value they stand for instead of as a struct, and errors
are reported as their message rather than as the empty object
encoding/json makes of them. Anything encoding/json cannot marshal falls
back to its slog string form, so an attribute is never silently emptied.

The record's own fields keep the names they have always had - Time,
Level, Message, PC - and win a collision with an attribute key, so
existing consumers of the json output see no change beyond the added
fields.

All three handlers are covered, including WebhookHandler, which had the
same defect and is reached through the same MultiplexHandler.

Two ignored error returns are also checked explicitly, because current
golangci-lint no longer excludes them by default and would otherwise
fail make check on a developer's toolchain.
2026-08-10 12:38:03 +00:00
clawbot
8c3ab23843 Add a failing test pinning the discarded slog attributes
Every handler in this package accepts slog attributes and then throws
them away: Handle never reads record.Attrs, and WithAttrs and WithGroup
return the receiver unchanged in ConsoleHandler, JSONHandler and
WebhookHandler alike. So slog.Info("casting", "device", d, "file", f)
emits the message and silently loses both fields, which makes properly
structured logging carry less information than the interpolated
log.Printf calls it replaces.

The test asserts on the bytes the handlers actually write - os.Stdout
for the console and JSON handlers, the posted body for the webhook
handler - rather than on internal state, because the output is where the
loss is observable. It covers record attributes, WithAttrs accumulation,
WithAttrs not mutating its receiver so sibling loggers cannot leak
attributes into each other, WithGroup qualification, slog.Group nesting,
and LogValuer resolution, for each handler and through MultiplexHandler.

This commit adds only the test, and it fails. The fix follows.

Refs: #19
2026-08-10 12:33:06 +00:00
7 changed files with 688 additions and 13 deletions

View File

@@ -18,6 +18,12 @@ Released v1.0.0 2024-06-14. Works as intended. No known bugs.
- if output is a tty, outputs pretty color logs
- if output is not a tty, outputs json
- supports delivering each log message via a webhook
- emits every `slog` attribute: those passed to a log call, those
accumulated with `WithAttrs`, and those qualified by `WithGroup`.
`slog.Group` values nest, and `slog.LogValuer` values are resolved. In
json output attributes are object fields (groups become nested objects);
in console output they are appended as `key=value` pairs, with grouped
keys written as `group.key=value`
## Planned Features

View File

@@ -24,6 +24,10 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
# Completed Steps
* 2026-08-10: fixed every handler discarding slog attributes: console,
JSON and webhook handlers now emit record attributes, accumulate
WithAttrs without mutating the receiver, and honour WithGroup;
slog.Group values nest and LogValuer values are resolved
* 2026-02-08: fixed JSONHandler deadlock from recursive log.Println,
with regression test; tagged 1.0.1
* 2024-06-14: 1.0 prep: lint and fmt enforced in Docker build, call
@@ -46,6 +50,8 @@ files it depends on: .golangci.yml, REPO_POLICIES.md, .editorconfig,
the working tree
* Pick one tag scheme before the next release (v1.0.0 vs 1.0.1 are
inconsistent)
* Tag v1.0.2, with the leading v, once the attribute fix lands, so
consuming repos can move off pseudo-version pins in one step
* Fix RELP output to cache (from old TODO)
* Re-add RELP delivery over TCP to remote rsyslog imrelp; removed
2024-06-14 because it did not build (README planned feature)

220
attrs.go Normal file
View File

@@ -0,0 +1,220 @@
package simplelog
import (
"encoding/json"
"log/slog"
"strconv"
"strings"
"unicode"
)
// handlerAttrs is the attribute state every handler carries: the attributes
// accumulated by WithAttrs, plus the groups opened by WithGroup. Its methods
// never mutate the receiver, so handlers derived from a common parent stay
// independent of each other.
type handlerAttrs struct {
attrs []slog.Attr
groups []string
}
// withAttrs returns a copy carrying attrs in addition to those already held.
// The attributes are qualified by the groups that are open at the time they
// are attached, as the slog.Handler contract requires.
func (h handlerAttrs) withAttrs(attrs []slog.Attr) handlerAttrs {
qualified := qualifyAttrs(h.groups, attrs)
combined := make([]slog.Attr, 0, len(h.attrs)+len(qualified))
combined = append(combined, h.attrs...)
combined = append(combined, qualified...)
return handlerAttrs{attrs: combined, groups: h.groups}
}
// withGroup returns a copy with a further group open. An empty name is a no-op,
// per the slog.Handler contract.
func (h handlerAttrs) withGroup(name string) handlerAttrs {
if name == "" {
return h
}
groups := make([]string, 0, len(h.groups)+1)
groups = append(groups, h.groups...)
groups = append(groups, name)
return handlerAttrs{attrs: h.attrs, groups: groups}
}
// forRecord returns the accumulated attributes followed by the record's own,
// the latter qualified by any open groups.
func (h handlerAttrs) forRecord(record slog.Record) []slog.Attr {
own := qualifyAttrs(h.groups, recordAttrs(record))
all := make([]slog.Attr, 0, len(h.attrs)+len(own))
all = append(all, h.attrs...)
all = append(all, own...)
return all
}
// qualifyAttrs nests attrs inside the given open groups, innermost last.
func qualifyAttrs(groups []string, attrs []slog.Attr) []slog.Attr {
for i := len(groups) - 1; i >= 0; i-- {
attrs = []slog.Attr{{
Key: groups[i],
Value: slog.GroupValue(attrs...),
}}
}
return attrs
}
// recordAttrs collects the attributes a record carries. They live in
// unexported fields, so they are only reachable through Record.Attrs - which
// is why marshaling a slog.Record directly loses every one of them.
func recordAttrs(record slog.Record) []slog.Attr {
attrs := make([]slog.Attr, 0, record.NumAttrs())
record.Attrs(func(attr slog.Attr) bool {
attrs = append(attrs, attr)
return true
})
return attrs
}
// recordToMap renders a record, with its handler's attributes, as the JSON
// object the JSON and webhook handlers emit. The record's own fields keep the
// names they have always had, and win a collision with an attribute key.
func recordToMap(record slog.Record, attrs handlerAttrs) map[string]any {
fields := attrsToMap(attrs.forRecord(record))
fields["Time"] = record.Time
fields["Level"] = record.Level
fields["Message"] = record.Message
fields["PC"] = record.PC
return fields
}
// attrsToMap renders attributes as a JSON object in which groups are nested
// objects. A group named more than once is merged rather than duplicated.
func attrsToMap(attrs []slog.Attr) map[string]any {
fields := make(map[string]any, len(attrs))
for _, attr := range attrs {
addAttrToMap(fields, attr)
}
return fields
}
func addAttrToMap(fields map[string]any, attr slog.Attr) {
value := attr.Value.Resolve()
if attr.Key == "" && value.Any() == nil {
// An empty Attr is ignored, per the slog.Handler contract.
return
}
if value.Kind() == slog.KindGroup {
group := value.Group()
if len(group) == 0 {
// An empty group is elided, as is its key.
return
}
// A group with an empty key is inlined into its parent.
target := fields
if attr.Key != "" {
nested, ok := fields[attr.Key].(map[string]any)
if !ok {
nested = make(map[string]any, len(group))
fields[attr.Key] = nested
}
target = nested
}
for _, member := range group {
addAttrToMap(target, member)
}
return
}
fields[attr.Key] = jsonValue(value)
}
// jsonValue converts a resolved slog.Value into something encoding/json can
// render usefully. Values it cannot marshal - and errors, which marshal to an
// empty object - fall back to their slog string form, so an attribute is never
// silently emptied.
func jsonValue(value slog.Value) any {
switch value.Kind() {
case slog.KindString:
return value.String()
case slog.KindInt64:
return value.Int64()
case slog.KindUint64:
return value.Uint64()
case slog.KindFloat64:
return value.Float64()
case slog.KindBool:
return value.Bool()
case slog.KindDuration:
return value.Duration().String()
case slog.KindTime:
return value.Time()
default:
// KindAny, and anything a future Go release adds.
return jsonAnyValue(value)
}
}
func jsonAnyValue(value slog.Value) any {
held := value.Any()
if _, ok := held.(json.Marshaler); !ok {
if err, ok := held.(error); ok {
return err.Error()
}
}
if _, err := json.Marshal(held); err != nil {
return value.String()
}
return held
}
// attrsToText renders attributes as the space separated key=value pairs the
// console handler appends to a log line. Groups become dotted key prefixes.
func attrsToText(attrs []slog.Attr) string {
var out strings.Builder
for _, attr := range attrs {
appendAttrText(&out, "", attr)
}
return out.String()
}
func appendAttrText(out *strings.Builder, prefix string, attr slog.Attr) {
value := attr.Value.Resolve()
if attr.Key == "" && value.Any() == nil {
return
}
if value.Kind() == slog.KindGroup {
group := value.Group()
if len(group) == 0 {
return
}
nested := prefix
if attr.Key != "" {
nested = prefix + attr.Key + "."
}
for _, member := range group {
appendAttrText(out, nested, member)
}
return
}
out.WriteString(" ")
out.WriteString(prefix)
out.WriteString(attr.Key)
out.WriteString("=")
out.WriteString(quoteIfNeeded(value.String()))
}
// quoteIfNeeded quotes a value only when leaving it bare would make the
// key=value pairs ambiguous, matching how the stdlib text handler reads.
func quoteIfNeeded(value string) string {
if value == "" {
return `""`
}
for _, r := range value {
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
r == '"' || r == '=' {
return strconv.Quote(value)
}
}
return value
}

410
attrs_test.go Normal file
View File

@@ -0,0 +1,410 @@
package simplelog
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"log/slog"
"net/http"
"net/http/httptest"
"os"
"strings"
"testing"
"time"
)
// These tests assert on the bytes the handlers actually emit, because that is
// where the defect lives: the handlers accept attributes and then throw them
// away, so nothing short of reading the output proves they survived.
// captureStdout redirects os.Stdout for the duration of fn and returns what was
// written to it. Both the console and the JSON handler write to os.Stdout, so
// this is the only way to see their real output.
func captureStdout(t *testing.T, fn func()) string {
t.Helper()
r, w, err := os.Pipe()
if err != nil {
t.Fatalf("os.Pipe: %v", err)
}
original := os.Stdout
os.Stdout = w
collected := make(chan string, 1)
go func() {
var buf bytes.Buffer
_, _ = io.Copy(&buf, r)
collected <- buf.String()
}()
defer func() {
os.Stdout = original
_ = r.Close()
}()
fn()
os.Stdout = original
if err := w.Close(); err != nil {
t.Fatalf("close pipe writer: %v", err)
}
return <-collected
}
// testRecord builds an INFO record carrying the given attributes.
func testRecord(message string, attrs ...slog.Attr) slog.Record {
record := slog.NewRecord(time.Now(), slog.LevelInfo, message, 0)
record.AddAttrs(attrs...)
return record
}
// decodeLine parses a single line of JSON handler output.
func decodeLine(t *testing.T, output string) map[string]any {
t.Helper()
line := strings.TrimSpace(output)
if line == "" {
t.Fatal("handler emitted no output")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(line), &decoded); err != nil {
t.Fatalf("output is not valid JSON: %v\noutput: %s", err, line)
}
return decoded
}
// wantField asserts that a decoded JSON object has key with the given value.
func wantField(t *testing.T, decoded map[string]any, key string, want any) {
t.Helper()
got, ok := decoded[key]
if !ok {
t.Fatalf("field %q missing from output: %v", key, decoded)
}
if got != want {
t.Fatalf("field %q = %v, want %v", key, got, want)
}
}
// wantGroup asserts that a decoded JSON object has key holding a nested object.
func wantGroup(t *testing.T, decoded map[string]any, key string) map[string]any {
t.Helper()
got, ok := decoded[key]
if !ok {
t.Fatalf("group %q missing from output: %v", key, decoded)
}
group, ok := got.(map[string]any)
if !ok {
t.Fatalf("field %q = %v, want a nested object", key, got)
}
return group
}
// wantContains asserts that console output contains a fragment.
func wantContains(t *testing.T, output, fragment string) {
t.Helper()
if !strings.Contains(output, fragment) {
t.Fatalf("output does not contain %q\noutput: %s", fragment, output)
}
}
// wantNotContains asserts that console output does not contain a fragment.
func wantNotContains(t *testing.T, output, fragment string) {
t.Helper()
if strings.Contains(output, fragment) {
t.Fatalf("output unexpectedly contains %q\noutput: %s", fragment, output)
}
}
// castTarget is a slog.LogValuer: the handler must resolve it rather than
// serialising the struct itself.
type castTarget struct {
id string
}
func (c castTarget) LogValue() slog.Value {
return slog.StringValue(c.id)
}
var _ slog.LogValuer = castTarget{}
func TestJSONHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
wantField(t, decoded, "device", "livingroom")
wantField(t, decoded, "file", "movie.mp4")
wantField(t, decoded, "attempt", float64(3))
}
func TestJSONHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "component", "caster")
wantField(t, decoded, "device", "livingroom")
}
func TestJSONHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := NewJSONHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantField(t, decodeLine(t, firstOutput), "worker", "first")
wantField(t, decodeLine(t, secondOutput), "worker", "second")
if _, present := decodeLine(t, parentOutput)["worker"]; present {
t.Fatalf("parent handler leaked an attribute from a derived handler: %s", parentOutput)
}
}
func TestJSONHandlerWithGroupNestsAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "Message", "casting")
group := wantGroup(t, decoded, "cast")
wantField(t, group, "device", "livingroom")
wantField(t, group, "file", "movie.mp4")
}
func TestJSONHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := NewJSONHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502), slog.String("method", "POST")),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errors.New("connection refused")),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "target", "chromecast-7")
wantField(t, decoded, "error", "connection refused")
group := wantGroup(t, decoded, "request")
wantField(t, group, "status", float64(502))
wantField(t, group, "method", "POST")
}
func TestConsoleHandlerEmitsRecordAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord(
"casting",
slog.String("device", "livingroom"),
slog.String("file", "movie.mp4"),
slog.Int("attempt", 3),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "casting")
wantContains(t, output, "device=livingroom")
wantContains(t, output, "file=movie.mp4")
wantContains(t, output, "attempt=3")
}
func TestConsoleHandlerQuotesValuesNeedingIt(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord("casting", slog.String("file", "The Movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, `file="The Movie.mp4"`)
}
func TestConsoleHandlerWithAttrsAccumulates(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler().
WithAttrs([]slog.Attr{slog.String("service", "cattbox")}).
WithAttrs([]slog.Attr{slog.String("component", "caster")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "service=cattbox")
wantContains(t, output, "component=caster")
wantContains(t, output, "device=livingroom")
}
func TestConsoleHandlerWithAttrsDoesNotMutateReceiver(t *testing.T) {
parent := NewConsoleHandler()
first := parent.WithAttrs([]slog.Attr{slog.String("worker", "first")})
second := parent.WithAttrs([]slog.Attr{slog.String("worker", "second")})
firstOutput := captureStdout(t, func() {
if err := first.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
secondOutput := captureStdout(t, func() {
if err := second.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
parentOutput := captureStdout(t, func() {
if err := parent.Handle(context.Background(), testRecord("work")); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, firstOutput, "worker=first")
wantNotContains(t, firstOutput, "worker=second")
wantContains(t, secondOutput, "worker=second")
wantNotContains(t, secondOutput, "worker=first")
wantNotContains(t, parentOutput, "worker=")
}
func TestConsoleHandlerWithGroupQualifiesAttrs(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler().
WithGroup("cast").
WithAttrs([]slog.Attr{slog.String("device", "livingroom")})
record := testRecord("casting", slog.String("file", "movie.mp4"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "cast.device=livingroom")
wantContains(t, output, "cast.file=movie.mp4")
}
func TestConsoleHandlerResolvesValues(t *testing.T) {
output := captureStdout(t, func() {
handler := NewConsoleHandler()
record := testRecord(
"cast failed",
slog.Group("request", slog.Int("status", 502)),
slog.Any("target", castTarget{id: "chromecast-7"}),
slog.Any("error", errors.New("connection refused")),
)
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
wantContains(t, output, "request.status=502")
wantContains(t, output, "target=chromecast-7")
wantContains(t, output, `error="connection refused"`)
}
func TestWebhookHandlerEmitsAttrs(t *testing.T) {
bodies := make(chan []byte, 1)
server := httptest.NewServer(http.HandlerFunc(
func(w http.ResponseWriter, r *http.Request) {
body, err := io.ReadAll(r.Body)
if err != nil {
t.Errorf("read webhook body: %v", err)
}
bodies <- body
w.WriteHeader(http.StatusOK)
},
))
defer server.Close()
handler, err := NewWebhookHandler(server.URL)
if err != nil {
t.Fatalf("NewWebhookHandler: %v", err)
}
withAttrs := handler.WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := withAttrs.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
var body []byte
select {
case body = <-bodies:
case <-time.After(5 * time.Second):
t.Fatal("webhook handler posted nothing")
}
decoded := decodeLine(t, string(body))
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}
// TestMultiplexHandlerPassesAttrsThrough guards the composite handler that
// package init installs: attributes must survive the multiplex too.
func TestMultiplexHandlerPassesAttrsThrough(t *testing.T) {
output := captureStdout(t, func() {
handler := (&MultiplexHandler{handlers: []ExtendedHandler{NewJSONHandler()}}).
WithAttrs([]slog.Attr{slog.String("service", "cattbox")})
record := testRecord("casting", slog.String("device", "livingroom"))
if err := handler.Handle(context.Background(), record); err != nil {
t.Fatalf("Handle: %v", err)
}
})
decoded := decodeLine(t, output)
wantField(t, decoded, "service", "cattbox")
wantField(t, decoded, "device", "livingroom")
}

View File

@@ -10,7 +10,9 @@ import (
"github.com/fatih/color"
)
type ConsoleHandler struct{}
type ConsoleHandler struct {
attrs handlerAttrs
}
func NewConsoleHandler() *ConsoleHandler {
return &ConsoleHandler{}
@@ -42,12 +44,13 @@ func (c *ConsoleHandler) Handle(
}
fmt.Println(
colorFunc(
"%s [%s] %s:%d: %s",
"%s [%s] %s:%d: %s%s",
timestamp,
record.Level,
file,
line,
record.Message,
attrsToText(c.attrs.forRecord(record)),
),
)
return nil
@@ -61,9 +64,15 @@ func (c *ConsoleHandler) Enabled(
}
func (c *ConsoleHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return c
}
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
}
func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
if name == "" {
return c
}
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
}

View File

@@ -8,15 +8,20 @@ import (
"os"
)
type JSONHandler struct{}
type JSONHandler struct {
attrs handlerAttrs
}
func NewJSONHandler() *JSONHandler {
return &JSONHandler{}
}
func (j *JSONHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, _ := json.Marshal(record)
fmt.Fprintln(os.Stdout, string(jsonData))
jsonData, err := json.Marshal(recordToMap(record, j.attrs))
if err != nil {
return fmt.Errorf("error marshaling log record: %w", err)
}
_, _ = fmt.Fprintln(os.Stdout, string(jsonData))
return nil
}
@@ -25,9 +30,15 @@ func (j *JSONHandler) Enabled(ctx context.Context, level slog.Level) bool {
}
func (j *JSONHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return j
}
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
}
func (j *JSONHandler) WithGroup(name string) slog.Handler {
if name == "" {
return j
}
return &JSONHandler{attrs: j.attrs.withGroup(name)}
}

View File

@@ -12,6 +12,7 @@ import (
type WebhookHandler struct {
webhookURL string
attrs handlerAttrs
}
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
@@ -19,12 +20,24 @@ func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
}
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
if len(attrs) == 0 {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withAttrs(attrs),
}
}
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
if name == "" {
return w
}
return &WebhookHandler{
webhookURL: w.webhookURL,
attrs: w.attrs.withGroup(name),
}
}
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
if _, err := url.ParseRequestURI(webhookURL); err != nil {
@@ -34,7 +47,7 @@ func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
}
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
jsonData, err := json.Marshal(record)
jsonData, err := json.Marshal(recordToMap(record, w.attrs))
if err != nil {
return fmt.Errorf("error marshaling event: %v", err)
}
@@ -42,6 +55,6 @@ func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
if err != nil {
return err
}
defer response.Body.Close()
defer func() { _ = response.Body.Close() }()
return nil
}