Emit slog attributes from every handler #21
44
README.md
44
README.md
@@ -18,6 +18,13 @@ 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`. See
|
||||
[Attribute output](#attribute-output) for the details worth knowing
|
||||
|
||||
## Planned Features
|
||||
|
||||
@@ -60,6 +67,43 @@ func main() {
|
||||
}
|
||||
```
|
||||
|
||||
## Attribute output
|
||||
|
||||
Attributes reach every handler: the ones passed to the log call, the ones
|
||||
accumulated with `WithAttrs`, and the ones qualified by the groups open at
|
||||
the time they were attached. `slog.Group` values nest, and
|
||||
`slog.LogValuer` values are resolved to the value they stand for. A few
|
||||
behaviours are worth knowing before you rely on them.
|
||||
|
||||
**Your values are never modified.** Whatever you log is read and rendered,
|
||||
never written to. A map or a slice you pass to `slog.Any` comes back from
|
||||
the logger exactly as you handed it over, even when a group later uses the
|
||||
same key.
|
||||
|
||||
**Durations are nanoseconds in json, and readable on the console.** The
|
||||
json and webhook payloads emit a `slog.Duration` as a number of
|
||||
nanoseconds, matching `slog.NewJSONHandler`, so a consumer can compare and
|
||||
aggregate the field without parsing it. The console line emits the same
|
||||
duration as `3s`, matching `slog.NewTextHandler`, because a person reads
|
||||
it.
|
||||
|
||||
**The json payload is an object, with the consequences an object has.**
|
||||
The record's own fields are named `Time`, `Level`, `Message` and `PC`, and
|
||||
they own those names: an attribute keyed after one of them is dropped from
|
||||
the json and webhook output. A key logged more than once keeps its last
|
||||
value there for the same reason, with one exception: when both are
|
||||
`slog.Group` values sharing a key, the two groups are merged into one
|
||||
object holding the members of both, rather than the second replacing the
|
||||
first. Neither the collision nor the merge applies to the console output,
|
||||
which is a line of text: every pair appears, in order. If you need a field
|
||||
called `message`, pick a key that does not collide - the collision is
|
||||
silent.
|
||||
|
||||
**Empty things follow the `slog.Handler` contract.** An empty `Attr` is
|
||||
ignored, an empty group is elided along with its key, a group with an
|
||||
empty key is inlined into its parent, and `WithGroup("")` returns the
|
||||
handler unchanged.
|
||||
|
||||
## License
|
||||
|
||||
[WTFPL](./LICENSE)
|
||||
|
||||
6
TODO.md
6
TODO.md
@@ -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)
|
||||
|
||||
255
attrs.go
Normal file
255
attrs.go
Normal file
@@ -0,0 +1,255 @@
|
||||
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
|
||||
}
|
||||
|
||||
// groupMap is a JSON object this package built for itself: the payload of a
|
||||
// record, or one of the nested objects a slog.Group becomes.
|
||||
//
|
||||
// The distinct type is what keeps the handler from writing into data the
|
||||
// caller still owns. A value handed to slog.Any goes into the payload by
|
||||
// reference - copying every logged map and slice would be a real cost for no
|
||||
// gain, since rendering only reads. The one place the handler writes into a
|
||||
// value already in the payload is the group merge below, and a type assertion
|
||||
// to groupMap can only succeed on a map this package allocated: a caller's
|
||||
// map[string]any is a different type and never matches, however it was keyed.
|
||||
// So the invariant holds by construction - nothing reachable from the caller
|
||||
// is ever written to, only read.
|
||||
type groupMap map[string]any
|
||||
|
||||
// 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) groupMap {
|
||||
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;
|
||||
// any other repeated key keeps the last value, as a JSON object must.
|
||||
func attrsToMap(attrs []slog.Attr) groupMap {
|
||||
fields := make(groupMap, len(attrs))
|
||||
for _, attr := range attrs {
|
||||
addAttrToMap(fields, attr)
|
||||
}
|
||||
return fields
|
||||
}
|
||||
|
||||
func addAttrToMap(fields groupMap, 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 != "" {
|
||||
// Merge only into a group this package built. Anything else
|
||||
// at this key - including a map the caller logged - is
|
||||
// replaced rather than written into, so the caller's own
|
||||
// data structure is never touched.
|
||||
nested, ok := fields[attr.Key].(groupMap)
|
||||
if !ok {
|
||||
nested = make(groupMap, 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 a value is never
|
||||
// rendered as an empty object.
|
||||
//
|
||||
// Values are returned as they were given, not copied: nothing here or in its
|
||||
// callers writes to a value the caller supplied.
|
||||
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:
|
||||
// Nanoseconds as a number, which is what slog.NewJSONHandler
|
||||
// emits. A JSON consumer can then compare and aggregate the
|
||||
// field; the "3s" form would have to be parsed first, and no
|
||||
// common log pipeline knows how.
|
||||
return value.Duration().Nanoseconds()
|
||||
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.
|
||||
//
|
||||
// Values take their slog string form, so a duration reads as "3s" here where
|
||||
// the json output carries nanoseconds as a number. That is the same split the
|
||||
// stdlib makes between slog.NewTextHandler and slog.NewJSONHandler: the
|
||||
// console line is read by a person, the json line by a program.
|
||||
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(" ")
|
||||
// The key is quoted on the same terms as the value, and as a whole
|
||||
// including its group prefix, because that is the token a reader has to
|
||||
// find the "=" in. slog.NewTextHandler quotes "prefix+key" the same way,
|
||||
// so a key like "a=b" reads as "a=b"=v2 rather than the ambiguous
|
||||
// a=b=v2, which parses as the key "a" with the value "b=v2".
|
||||
out.WriteString(quoteIfNeeded(prefix + attr.Key))
|
||||
out.WriteString("=")
|
||||
out.WriteString(quoteIfNeeded(value.String()))
|
||||
}
|
||||
|
||||
// quoteIfNeeded quotes a key or a value only when leaving it bare would make
|
||||
// the key=value pairs ambiguous, matching how the stdlib text handler reads.
|
||||
func quoteIfNeeded(text string) string {
|
||||
if text == "" {
|
||||
return `""`
|
||||
}
|
||||
for _, r := range text {
|
||||
if unicode.IsSpace(r) || !unicode.IsPrint(r) ||
|
||||
r == '"' || r == '=' {
|
||||
return strconv.Quote(text)
|
||||
}
|
||||
}
|
||||
return text
|
||||
}
|
||||
@@ -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 {
|
||||
return c
|
||||
if len(attrs) == 0 {
|
||||
return c
|
||||
}
|
||||
return &ConsoleHandler{attrs: c.attrs.withAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (c *ConsoleHandler) WithGroup(name string) slog.Handler {
|
||||
return c
|
||||
if name == "" {
|
||||
return c
|
||||
}
|
||||
return &ConsoleHandler{attrs: c.attrs.withGroup(name)}
|
||||
}
|
||||
|
||||
@@ -8,14 +8,19 @@ 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)
|
||||
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 {
|
||||
return j
|
||||
if len(attrs) == 0 {
|
||||
return j
|
||||
}
|
||||
return &JSONHandler{attrs: j.attrs.withAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (j *JSONHandler) WithGroup(name string) slog.Handler {
|
||||
return j
|
||||
if name == "" {
|
||||
return j
|
||||
}
|
||||
return &JSONHandler{attrs: j.attrs.withGroup(name)}
|
||||
}
|
||||
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
type WebhookHandler struct {
|
||||
webhookURL string
|
||||
attrs handlerAttrs
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
@@ -19,11 +20,23 @@ func (w *WebhookHandler) Enabled(ctx context.Context, level slog.Level) bool {
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return w
|
||||
if len(attrs) == 0 {
|
||||
return w
|
||||
}
|
||||
return &WebhookHandler{
|
||||
webhookURL: w.webhookURL,
|
||||
attrs: w.attrs.withAttrs(attrs),
|
||||
}
|
||||
}
|
||||
|
||||
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
|
||||
return w
|
||||
if name == "" {
|
||||
return w
|
||||
}
|
||||
return &WebhookHandler{
|
||||
webhookURL: w.webhookURL,
|
||||
attrs: w.attrs.withGroup(name),
|
||||
}
|
||||
}
|
||||
|
||||
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user