From cd99c3eaa6723c37f686d00a3ad0152370d69182 Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 08:29:25 +0800 Subject: [PATCH 1/7] feat: add ErrAny for non-error recover() values 1.recover() returns any not error -> ErrAny accepts any 2.error input could share Err logic -> delegate when err 3.nil input would emit empty fields -> return nil safely --- zap/error.go | 35 +++++++++++++++++++ zap/error_test.go | 88 +++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 123 insertions(+) diff --git a/zap/error.go b/zap/error.go index c62e587..3f6175e 100644 --- a/zap/error.go +++ b/zap/error.go @@ -67,3 +67,38 @@ func Err(err error) []zapcore.Field { } return fields } + +// ErrAny extracts ECS error.* fields from any value, intended for cases where +// the input is not statically typed as error — most commonly the result of +// recover() during panic handling. Behavior by input type: +// +// - nil: returns nil (no fields), so callers can splat unconditionally +// - error: delegates to Err(err) — error.stack_trace included if the error +// implements interface{ StackTrace() []byte } +// - other: error.message = fmt.Sprint(v); error.type = fmt.Sprintf("%T", v) +// +// ErrAny intentionally does not call runtime/debug.Stack() itself. To attach +// the panic stack, append ErrorStackTrace(debug.Stack()) at the call site — +// callers may want to skip the cost or use a different stack source. +// +// Typical panic recovery: +// +// defer func() { +// if r := recover(); r != nil { +// fields := ErrAny(r) +// fields = append(fields, ErrorStackTrace(debug.Stack())) +// logger.Error("panic recovered", fields...) +// } +// }() +func ErrAny(v any) []zapcore.Field { + if v == nil { + return nil + } + if err, ok := v.(error); ok { + return Err(err) + } + return []zapcore.Field{ + ErrorMessage(fmt.Sprint(v)), + ErrorType(fmt.Sprintf("%T", v)), + } +} diff --git a/zap/error_test.go b/zap/error_test.go index 1f3413c..42927a7 100644 --- a/zap/error_test.go +++ b/zap/error_test.go @@ -109,3 +109,91 @@ func TestErr_StackTracer_EmitsStackTrace(t *testing.T) { } } } + +func TestErrAny_Nil(t *testing.T) { + assert.Nil(t, ecszap.ErrAny(nil)) +} + +func TestErrAny_Error_DelegatesToErr(t *testing.T) { + err := errors.New("boom") + got := ecszap.ErrAny(err) + require.Len(t, got, 2) + + keys := []string{got[0].Key, got[1].Key} + assert.ElementsMatch(t, []string{"error.message", "error.type"}, keys) + for _, f := range got { + switch f.Key { + case "error.message": + assert.Equal(t, "boom", f.String) + case "error.type": + assert.Equal(t, fmt.Sprintf("%T", err), f.String) + } + } +} + +func TestErrAny_StackTracerError_IncludesStackTrace(t *testing.T) { + err := &fakeStackTracer{msg: "kaboom", stack: []byte("goroutine 1...")} + got := ecszap.ErrAny(err) + require.Len(t, got, 3) + + var keys []string + for _, f := range got { + keys = append(keys, f.Key) + } + assert.ElementsMatch(t, + []string{"error.message", "error.type", "error.stack_trace"}, + keys, + ) +} + +func TestErrAny_String(t *testing.T) { + got := ecszap.ErrAny("oops") + require.Len(t, got, 2) + + for _, f := range got { + switch f.Key { + case "error.message": + assert.Equal(t, "oops", f.String) + case "error.type": + assert.Equal(t, "string", f.String) + default: + t.Fatalf("unexpected key %q", f.Key) + } + } +} + +func TestErrAny_Int(t *testing.T) { + got := ecszap.ErrAny(42) + require.Len(t, got, 2) + + for _, f := range got { + switch f.Key { + case "error.message": + assert.Equal(t, "42", f.String) + case "error.type": + assert.Equal(t, "int", f.String) + default: + t.Fatalf("unexpected key %q", f.Key) + } + } +} + +type panicPayload struct { + Reason string +} + +func TestErrAny_Struct(t *testing.T) { + got := ecszap.ErrAny(panicPayload{Reason: "deadlocked"}) + require.Len(t, got, 2) + + for _, f := range got { + switch f.Key { + case "error.message": + assert.Equal(t, "{deadlocked}", f.String) + case "error.type": + assert.Equal(t, "zap_test.panicPayload", f.String) + default: + t.Fatalf("unexpected key %q", f.Key) + } + } +} From 0d8a0b127debf60ad31ec60dbcc0e2ade1c8dcb3 Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 08:30:31 +0800 Subject: [PATCH 2/7] doc: clarify error helpers vs zap.Error and log v0.2.0 1.Reader saw Err() as redundant under ecszap -> add subsection 2.Coverage table missed ErrAny -> bump to ~117 helpers 3.CHANGELOG had only Unreleased -> log v0.2.0 entry --- CHANGELOG.md | 14 ++++++++++++++ README.md | 25 +++++++++++++++++++++++-- docs/ecs-coverage.md | 6 +++--- 3 files changed, 40 insertions(+), 5 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1a58814..5849886 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,17 @@ # Changelog ## [Unreleased] + +## [0.2.0] + +### Added + +- `ErrAny(v any) []zap.Field` — extracts ECS `error.*` fields from any value, + intended for `recover()` payloads (typed as `any`). Delegates to `Err(err)` + when the value satisfies `error`; falls back to `fmt.Sprint` / + `fmt.Sprintf("%T", v)` for non-error values; returns nil for nil input. + +### Documentation + +- README clarifies the relationship between `ecsf.Err`, `ecsf.ErrorXxx` + single-field helpers, and `zap.Error(err)` under the ecszap encoder. diff --git a/README.md b/README.md index 47ce769..53f2e57 100644 --- a/README.md +++ b/README.md @@ -84,7 +84,7 @@ See [`example/main.go`](example/main.go) for a runnable end-to-end example. ## Coverage -v0.1.0 covers ~116 helpers across these top-level ECS fieldsets: +v0.2.0 covers ~117 helpers across these top-level ECS fieldsets: | Fieldset | Helpers | Notes | | ------------------------------------ | ------------------ | --------------------------------------------- | @@ -92,7 +92,7 @@ v0.1.0 covers ~116 helpers across these top-level ECS fieldsets: | `host.*` | 9 | top-level only; metrics deferred | | `process.*` | 11 | top-level; io / env_vars / entity_id deferred | | `event.*` | 22 + 4 typed enums | kind / outcome / category / type are typed | -| `error.*` | 5 + `Err()` helper | full top-level | +| `error.*` | 5 + `Err()` / `ErrAny()` | full top-level | | `log.*` (+ origin) | 6 | syslog deferred | | `trace` / `span` / `transaction` ids | 3 | full | | `http.*` | 13 | full | @@ -120,6 +120,27 @@ fields (`numeric_labels.*`, `service.address`, `service.ephemeral_id`, etc.). | [`github.com/elastic/ecs`](https://github.com/elastic/ecs) | Canonical ECS schema definitions and document marshaling for full-document construction. | Different concern. ecsfields is for incremental field-by-field logging in zap, not for building complete ECS documents. | | [`github.com/andrewkroh/go-ecs`](https://github.com/andrewkroh/go-ecs) | ECS schema query and introspection tool. | Different concern (schema introspection at runtime, not log emission). | +### Using ecsfields error helpers vs `zap.Error` + +If you use the ecszap encoder, `zap.Error(err)` already produces ECS-shaped +`error.message` and `error.stack_trace` — you do not have to migrate existing +`zap.Error(err)` call sites purely for ECS compliance. + +That said, `ecsf.Err(err)` does three things `zap.Error(err)` doesn't: + +1. **Always emits `error.type`.** Lets you filter Kibana by Go error class + (`*pq.Error`, `*net.OpError`, ...). `zap.Error` never sets this field. +2. **Encoder-agnostic.** Produces correct ECS keys regardless of encoder. + `zap.Error` only outputs ECS shape under ecszap; with the default JSON + encoder it falls back to a flat `"error":"..."` string. +3. **Composable.** The single-field helpers (`ErrorCode`, `ErrorID`, + `ErrorStackTrace`) and `ErrAny(any)` (for `recover()` values) cover cases + that have no Go `error` to pass to `zap.Error` in the first place. + +Recommended: use `ecsf.Err(err)` for new code or any error you want to be able +to classify in Kibana. Keep existing `zap.Error(err)` call sites if you only +need message + stack_trace and you're committed to the ecszap encoder. + ## License [MIT](LICENSE) diff --git a/docs/ecs-coverage.md b/docs/ecs-coverage.md index 99cadfa..65c513f 100644 --- a/docs/ecs-coverage.md +++ b/docs/ecs-coverage.md @@ -1,4 +1,4 @@ -# ECS coverage — v0.1.0 +# ECS coverage — v0.2.0 Pinned to **ECS 8.17**. This document tracks which ECS field families are covered, deferred, or out of scope. @@ -12,7 +12,7 @@ covered, deferred, or out of scope. | `host.*` (top-level) | 9 | `HostIP` / `HostMAC` are variadic; `HostUptime` emits seconds | | `process.*` (top-level) | 11 | `ProcessUptime` emits seconds; `ProcessStart` is `time.Time`; endpoint-security subtrees excluded | | `event.*` | 22 | `event.duration` emits **nanoseconds**; `event.original` is bytes; typed enums for `kind`/`outcome`/`category`/`type` | -| `error.*` + `Err()` | 5 + 1 | `Err()` extracts `error.message` / `error.type` always, `error.stack_trace` when source implements `interface{ StackTrace() []byte }` | +| `error.*` + `Err()` / `ErrAny()` | 5 + 2 | `Err(error)` and `ErrAny(any)` both extract `error.message` / `error.type` always, plus `error.stack_trace` when source implements `interface{ StackTrace() []byte }`. `ErrAny` accepts `recover()` payloads (typed as `any`) and delegates to `Err` when the value satisfies `error`. | | `log.*` | 6 | Includes `log.origin.*` | | `trace.id`, `span.id`, `transaction.id` | 3 | APM correlation | | `http.*` | 13 | Bytes are `int64`, status code is `int` | @@ -20,7 +20,7 @@ covered, deferred, or out of scope. | `client.*` (top-level) | 8 | Excludes network-monitoring subtrees | | `server.*` (top-level) | 8 | Mirrors `client.*` | | `user_agent.*` | 4 | `original`, `name`, `version`, `device.name` | -| **Total** | **~116** | | +| **Total** | **~117** | | ## Deferred (additive in future v1.x) From 491f42628076c4c606ccd3ec78992ca10807c460 Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 08:48:44 +0800 Subject: [PATCH 3/7] fix: guard ErrAny against typed-nil error inputs 1.recover(typedNilErr) would panic in err.Error() -> typed-nil check 2.Lost type info on nil error harmed debugging -> emit error.type 3.Defense covers all nillable kinds not just Ptr -> reflect.Kind switch --- zap/error.go | 30 ++++++++++++++++++++++++++---- zap/error_test.go | 29 +++++++++++++++++++++++++++++ 2 files changed, 55 insertions(+), 4 deletions(-) diff --git a/zap/error.go b/zap/error.go index 3f6175e..b876e09 100644 --- a/zap/error.go +++ b/zap/error.go @@ -11,6 +11,7 @@ package zap import ( "errors" "fmt" + "reflect" "go.uber.org/zap" "go.uber.org/zap/zapcore" @@ -72,10 +73,12 @@ func Err(err error) []zapcore.Field { // the input is not statically typed as error — most commonly the result of // recover() during panic handling. Behavior by input type: // -// - nil: returns nil (no fields), so callers can splat unconditionally -// - error: delegates to Err(err) — error.stack_trace included if the error -// implements interface{ StackTrace() []byte } -// - other: error.message = fmt.Sprint(v); error.type = fmt.Sprintf("%T", v) +// - nil: returns nil (no fields) +// - typed-nil error: error.type emitted, error.message = "" — never +// calls Error() on the typed-nil receiver, which would panic +// - error: delegates to Err(err) — error.stack_trace included if +// the error implements interface{ StackTrace() []byte } +// - other: error.message = fmt.Sprint(v); error.type = fmt.Sprintf("%T", v) // // ErrAny intentionally does not call runtime/debug.Stack() itself. To attach // the panic stack, append ErrorStackTrace(debug.Stack()) at the call site — @@ -95,6 +98,12 @@ func ErrAny(v any) []zapcore.Field { return nil } if err, ok := v.(error); ok { + if isTypedNil(err) { + return []zapcore.Field{ + ErrorMessage(""), + ErrorType(fmt.Sprintf("%T", err)), + } + } return Err(err) } return []zapcore.Field{ @@ -102,3 +111,16 @@ func ErrAny(v any) []zapcore.Field { ErrorType(fmt.Sprintf("%T", v)), } } + +// isTypedNil reports whether v is non-nil at the interface level but holds a +// nil concrete value (e.g. (*MyErr)(nil) cast to error). Calling methods that +// dereference the receiver on such a value panics, so ErrAny short-circuits +// before invoking err.Error(). +func isTypedNil(v any) bool { + rv := reflect.ValueOf(v) + switch rv.Kind() { + case reflect.Ptr, reflect.Map, reflect.Slice, reflect.Chan, reflect.Func, reflect.Interface: + return rv.IsNil() + } + return false +} diff --git a/zap/error_test.go b/zap/error_test.go index 42927a7..c7f0c73 100644 --- a/zap/error_test.go +++ b/zap/error_test.go @@ -197,3 +197,32 @@ func TestErrAny_Struct(t *testing.T) { } } } + +// derefingErr panics on Error() if the receiver is nil — emulating the common +// Go gotcha where panic(typedNilError) is recovered as a non-nil error +// interface holding a nil pointer. +type derefingErr struct{ msg string } + +func (d *derefingErr) Error() string { return d.msg } + +func TestErrAny_TypedNilPointerError_DoesNotPanic(t *testing.T) { + var typedNil *derefingErr + var asInterface error = typedNil + + var got []zapcore.Field + require.NotPanics(t, func() { + got = ecszap.ErrAny(asInterface) + }) + require.Len(t, got, 2) + + for _, f := range got { + switch f.Key { + case "error.message": + assert.Equal(t, "", f.String) + case "error.type": + assert.Equal(t, "*zap_test.derefingErr", f.String) + default: + t.Fatalf("unexpected key %q", f.Key) + } + } +} From a3102b2f2953f00736d81ef4cee6ec8ba5160820 Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 10:07:10 +0800 Subject: [PATCH 4/7] feat: support pkg/errors stack traces in Err 1.pkg/errors users got message+type but no stack -> add interface 2.Two interfaces share extraction logic -> extractStackTrace helper 3.GoDoc warned pkg/errors unsupported -> update to both supported --- go.mod | 2 +- zap/error.go | 56 +++++++++++++++++++++++++++++++++++------------ zap/error_test.go | 31 ++++++++++++++++++++++++++ 3 files changed, 74 insertions(+), 15 deletions(-) diff --git a/go.mod b/go.mod index e8af7fe..ca34414 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/maxence2997/ecsfields go 1.22.0 require ( + github.com/pkg/errors v0.9.1 github.com/stretchr/testify v1.11.1 go.elastic.co/ecszap v1.0.3 go.uber.org/zap v1.28.0 @@ -10,7 +11,6 @@ require ( require ( github.com/davecgh/go-spew v1.1.1 // indirect - github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect go.uber.org/multierr v1.10.0 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect diff --git a/zap/error.go b/zap/error.go index b876e09..a6e6705 100644 --- a/zap/error.go +++ b/zap/error.go @@ -13,6 +13,7 @@ import ( "fmt" "reflect" + pkgerrors "github.com/pkg/errors" "go.uber.org/zap" "go.uber.org/zap/zapcore" ) @@ -33,21 +34,28 @@ func ErrorCode(code string) zapcore.Field { return zap.String("error.code", code // ErrorID emits ECS error.id (a unique identifier for the error instance). func ErrorID(id string) zapcore.Field { return zap.String("error.id", id) } -// stackTracer is the conventional interface for errors that carry a captured stack. -// samber/oops satisfies this interface natively. -type stackTracer interface { +// stackTracerBytes is satisfied by errors that expose a pre-formatted stack +// trace as bytes. samber/oops errors satisfy this interface natively. +type stackTracerBytes interface { StackTrace() []byte } +// stackTracerPCs is satisfied by errors that expose a stack trace as +// pkg/errors-style program counters. github.com/pkg/errors errors (e.g. those +// returned by errors.New / errors.Wrap from that package) satisfy this +// interface natively. +type stackTracerPCs interface { + StackTrace() pkgerrors.StackTrace +} + // Err extracts ECS error.* fields from a Go error. It returns: // // - error.message: always (err.Error()) // - error.type: always (fmt.Sprintf("%T", err)) -// - error.stack_trace: if any error in the chain implements interface{ StackTrace() []byte } -// -// The StackTrace method must have signature: StackTrace() []byte. -// Note: github.com/pkg/errors exposes StackTrace() errors.StackTrace ([]uintptr) -// and does NOT satisfy this interface. Use a wrapper or samber/oops instead. +// - error.stack_trace: if any error in the chain implements one of the +// conventional stack-trace interfaces — checked in this order: +// 1. interface{ StackTrace() []byte } (samber/oops) +// 2. interface{ StackTrace() errors.StackTrace } (github.com/pkg/errors) // // Err is the only multi-field constructor in the library, provided so callers // do not need any specific zap encoder (e.g. ecszap) to obtain a stack trace. @@ -60,15 +68,34 @@ func Err(err error) []zapcore.Field { ErrorMessage(err.Error()), ErrorType(fmt.Sprintf("%T", err)), } - var st stackTracer - if errors.As(err, &st) { - if stack := st.StackTrace(); len(stack) > 0 { - fields = append(fields, ErrorStackTrace(stack)) - } + if stack := extractStackTrace(err); len(stack) > 0 { + fields = append(fields, ErrorStackTrace(stack)) } return fields } +// extractStackTrace walks the error chain and returns the first stack trace +// found, in []byte form ready for ErrorStackTrace. Returns nil if no error in +// the chain carries a stack trace. +func extractStackTrace(err error) []byte { + var bytesST stackTracerBytes + if errors.As(err, &bytesST) { + if s := bytesST.StackTrace(); len(s) > 0 { + return s + } + } + var pcsST stackTracerPCs + if errors.As(err, &pcsST) { + if s := pcsST.StackTrace(); len(s) > 0 { + // pkg/errors.StackTrace implements fmt.Formatter; %+v renders each + // frame as "function\n\tfile:line", matching what users expect to + // see in error.stack_trace. + return fmt.Appendf(nil, "%+v", s) + } + } + return nil +} + // ErrAny extracts ECS error.* fields from any value, intended for cases where // the input is not statically typed as error — most commonly the result of // recover() during panic handling. Behavior by input type: @@ -77,7 +104,8 @@ func Err(err error) []zapcore.Field { // - typed-nil error: error.type emitted, error.message = "" — never // calls Error() on the typed-nil receiver, which would panic // - error: delegates to Err(err) — error.stack_trace included if -// the error implements interface{ StackTrace() []byte } +// the error implements either StackTrace() []byte (samber/oops) or +// StackTrace() errors.StackTrace (github.com/pkg/errors) // - other: error.message = fmt.Sprint(v); error.type = fmt.Sprintf("%T", v) // // ErrAny intentionally does not call runtime/debug.Stack() itself. To attach diff --git a/zap/error_test.go b/zap/error_test.go index c7f0c73..6665c78 100644 --- a/zap/error_test.go +++ b/zap/error_test.go @@ -7,6 +7,7 @@ import ( "fmt" "testing" + pkgerrors "github.com/pkg/errors" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" "go.uber.org/zap/zapcore" @@ -198,6 +199,36 @@ func TestErrAny_Struct(t *testing.T) { } } +func TestErr_PkgErrorsStackTracer_EmitsStackTrace(t *testing.T) { + err := pkgerrors.New("boom") + got := ecszap.Err(err) + require.Len(t, got, 3) + + var keys []string + for _, f := range got { + keys = append(keys, f.Key) + } + assert.ElementsMatch(t, + []string{"error.message", "error.type", "error.stack_trace"}, + keys, + ) + + for _, f := range got { + switch f.Key { + case "error.message": + assert.Equal(t, "boom", f.String) + case "error.stack_trace": + assert.Equal(t, zapcore.ByteStringType, f.Type) + enc := zapcore.NewMapObjectEncoder() + f.AddTo(enc) + stack := enc.Fields["error.stack_trace"].(string) + assert.NotEmpty(t, stack) + assert.Contains(t, stack, "TestErr_PkgErrorsStackTracer_EmitsStackTrace", + "stack should reference the test function frame") + } + } +} + // derefingErr panics on Error() if the receiver is nil — emulating the common // Go gotcha where panic(typedNilError) is recovered as a non-nil error // interface holding a nil pointer. From 552855730ab153f66a5c76592615eb7217758e9f Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 10:07:46 +0800 Subject: [PATCH 5/7] doc: clarify zap.Error stack semantics and log v0.3 features 1.zap.Error stack was overstated -> qualify with StackTracer 2.Err now also reads pkg/errors -> note dual interface support 3.CHANGELOG missed pkg/errors and typed-nil -> add entries --- CHANGELOG.md | 22 +++++++++++++++++++++- README.md | 18 ++++++++++++------ 2 files changed, 33 insertions(+), 7 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 5849886..1ff234e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,28 @@ intended for `recover()` payloads (typed as `any`). Delegates to `Err(err)` when the value satisfies `error`; falls back to `fmt.Sprint` / `fmt.Sprintf("%T", v)` for non-error values; returns nil for nil input. +- `Err(err)` now extracts `error.stack_trace` from `github.com/pkg/errors` + errors as well — previously only `samber/oops`-style `StackTrace() []byte` + was supported. + +### Changed + +- `Err(err)` stack-trace extraction is now delegated to an internal + `extractStackTrace` helper that walks the error chain via `errors.As` and + tries the `samber/oops` interface first, then the `pkg/errors` interface. + No public API change. +- `ErrAny(v)` guards against typed-nil error inputs (e.g. `(*MyErr)(nil)` + cast to `error`) — emits `error.message=""` + `error.type` instead of + panicking inside `err.Error()`. + +### Dependencies + +- Add `github.com/pkg/errors v0.9.1` (direct) — required to type-check the + pkg/errors `StackTrace() errors.StackTrace` interface. ### Documentation - README clarifies the relationship between `ecsf.Err`, `ecsf.ErrorXxx` - single-field helpers, and `zap.Error(err)` under the ecszap encoder. + single-field helpers, and `zap.Error(err)` under the ecszap encoder; in + particular that `zap.Error` only produces `error.stack_trace` when the + underlying error implements pkg/errors' `StackTracer`. diff --git a/README.md b/README.md index 53f2e57..c1db27e 100644 --- a/README.md +++ b/README.md @@ -122,9 +122,11 @@ fields (`numeric_labels.*`, `service.address`, `service.ephemeral_id`, etc.). ### Using ecsfields error helpers vs `zap.Error` -If you use the ecszap encoder, `zap.Error(err)` already produces ECS-shaped -`error.message` and `error.stack_trace` — you do not have to migrate existing -`zap.Error(err)` call sites purely for ECS compliance. +Under the ecszap encoder, `zap.Error(err)` produces ECS `error.message` +(always) and `error.stack_trace` (only when `err` implements pkg/errors' +`StackTrace() errors.StackTrace`). Plain `errors.New(...)` does not produce +a stack. You do not have to migrate existing `zap.Error(err)` call sites +purely for ECS compliance. That said, `ecsf.Err(err)` does three things `zap.Error(err)` doesn't: @@ -133,12 +135,16 @@ That said, `ecsf.Err(err)` does three things `zap.Error(err)` doesn't: 2. **Encoder-agnostic.** Produces correct ECS keys regardless of encoder. `zap.Error` only outputs ECS shape under ecszap; with the default JSON encoder it falls back to a flat `"error":"..."` string. -3. **Composable.** The single-field helpers (`ErrorCode`, `ErrorID`, +3. **Composable.** Single-field helpers (`ErrorCode`, `ErrorID`, `ErrorStackTrace`) and `ErrAny(any)` (for `recover()` values) cover cases that have no Go `error` to pass to `zap.Error` in the first place. -Recommended: use `ecsf.Err(err)` for new code or any error you want to be able -to classify in Kibana. Keep existing `zap.Error(err)` call sites if you only +`ecsf.Err(err)` extracts `error.stack_trace` via either pkg/errors' +`StackTrace() errors.StackTrace` or samber/oops' `StackTrace() []byte`, +so any error wrapped by either library carries its stack through. + +Recommended: use `ecsf.Err(err)` for new code or any error you want to +classify in Kibana. Keep existing `zap.Error(err)` call sites if you only need message + stack_trace and you're committed to the ecszap encoder. ## License From 6b9c588748fac7276789cf9beb58bdbb12ef861d Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 10:10:55 +0800 Subject: [PATCH 6/7] doc: sync ecs-coverage Err description for v0.2.0 1.Coverage said StackTrace bytes only -> note both interfaces 2.pkg/errors users had no signal in coverage -> name it --- docs/ecs-coverage.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ecs-coverage.md b/docs/ecs-coverage.md index 65c513f..52b16df 100644 --- a/docs/ecs-coverage.md +++ b/docs/ecs-coverage.md @@ -12,7 +12,7 @@ covered, deferred, or out of scope. | `host.*` (top-level) | 9 | `HostIP` / `HostMAC` are variadic; `HostUptime` emits seconds | | `process.*` (top-level) | 11 | `ProcessUptime` emits seconds; `ProcessStart` is `time.Time`; endpoint-security subtrees excluded | | `event.*` | 22 | `event.duration` emits **nanoseconds**; `event.original` is bytes; typed enums for `kind`/`outcome`/`category`/`type` | -| `error.*` + `Err()` / `ErrAny()` | 5 + 2 | `Err(error)` and `ErrAny(any)` both extract `error.message` / `error.type` always, plus `error.stack_trace` when source implements `interface{ StackTrace() []byte }`. `ErrAny` accepts `recover()` payloads (typed as `any`) and delegates to `Err` when the value satisfies `error`. | +| `error.*` + `Err()` / `ErrAny()` | 5 + 2 | `Err(error)` and `ErrAny(any)` both extract `error.message` / `error.type` always, plus `error.stack_trace` when the source implements either `StackTrace() []byte` (samber/oops) or `StackTrace() errors.StackTrace` (github.com/pkg/errors). `ErrAny` accepts `recover()` payloads (typed as `any`) and delegates to `Err` when the value satisfies `error`. | | `log.*` | 6 | Includes `log.origin.*` | | `trace.id`, `span.id`, `transaction.id` | 3 | APM correlation | | `http.*` | 13 | Bytes are `int64`, status code is `int` | From f26d8230bf6980dabcd91befe83f3dd02c03193d Mon Sep 17 00:00:00 2001 From: Maxence Yang Date: Wed, 6 May 2026 10:24:51 +0800 Subject: [PATCH 7/7] doc: disambiguate pkg/errors references with alias prefix 1.errors.StackTrace clashed visually with stdlib -> pkgerrors prefix 2.errors.New/Wrap reads as stdlib in package doc -> pkgerrors prefix 3.Markdown lacks import context too -> use same prefix everywhere --- CHANGELOG.md | 2 +- README.md | 4 ++-- docs/ecs-coverage.md | 2 +- zap/error.go | 7 +++---- 4 files changed, 7 insertions(+), 8 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 1ff234e..5077486 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -27,7 +27,7 @@ ### Dependencies - Add `github.com/pkg/errors v0.9.1` (direct) — required to type-check the - pkg/errors `StackTrace() errors.StackTrace` interface. + pkg/errors `StackTrace() pkgerrors.StackTrace` interface. ### Documentation diff --git a/README.md b/README.md index c1db27e..378d998 100644 --- a/README.md +++ b/README.md @@ -124,7 +124,7 @@ fields (`numeric_labels.*`, `service.address`, `service.ephemeral_id`, etc.). Under the ecszap encoder, `zap.Error(err)` produces ECS `error.message` (always) and `error.stack_trace` (only when `err` implements pkg/errors' -`StackTrace() errors.StackTrace`). Plain `errors.New(...)` does not produce +`StackTrace() pkgerrors.StackTrace`). Plain `errors.New(...)` does not produce a stack. You do not have to migrate existing `zap.Error(err)` call sites purely for ECS compliance. @@ -140,7 +140,7 @@ That said, `ecsf.Err(err)` does three things `zap.Error(err)` doesn't: that have no Go `error` to pass to `zap.Error` in the first place. `ecsf.Err(err)` extracts `error.stack_trace` via either pkg/errors' -`StackTrace() errors.StackTrace` or samber/oops' `StackTrace() []byte`, +`StackTrace() pkgerrors.StackTrace` or samber/oops' `StackTrace() []byte`, so any error wrapped by either library carries its stack through. Recommended: use `ecsf.Err(err)` for new code or any error you want to diff --git a/docs/ecs-coverage.md b/docs/ecs-coverage.md index 52b16df..b449e3d 100644 --- a/docs/ecs-coverage.md +++ b/docs/ecs-coverage.md @@ -12,7 +12,7 @@ covered, deferred, or out of scope. | `host.*` (top-level) | 9 | `HostIP` / `HostMAC` are variadic; `HostUptime` emits seconds | | `process.*` (top-level) | 11 | `ProcessUptime` emits seconds; `ProcessStart` is `time.Time`; endpoint-security subtrees excluded | | `event.*` | 22 | `event.duration` emits **nanoseconds**; `event.original` is bytes; typed enums for `kind`/`outcome`/`category`/`type` | -| `error.*` + `Err()` / `ErrAny()` | 5 + 2 | `Err(error)` and `ErrAny(any)` both extract `error.message` / `error.type` always, plus `error.stack_trace` when the source implements either `StackTrace() []byte` (samber/oops) or `StackTrace() errors.StackTrace` (github.com/pkg/errors). `ErrAny` accepts `recover()` payloads (typed as `any`) and delegates to `Err` when the value satisfies `error`. | +| `error.*` + `Err()` / `ErrAny()` | 5 + 2 | `Err(error)` and `ErrAny(any)` both extract `error.message` / `error.type` always, plus `error.stack_trace` when the source implements either `StackTrace() []byte` (samber/oops) or `StackTrace() pkgerrors.StackTrace` (github.com/pkg/errors). `ErrAny` accepts `recover()` payloads (typed as `any`) and delegates to `Err` when the value satisfies `error`. | | `log.*` | 6 | Includes `log.origin.*` | | `trace.id`, `span.id`, `transaction.id` | 3 | APM correlation | | `http.*` | 13 | Bytes are `int64`, status code is `int` | diff --git a/zap/error.go b/zap/error.go index a6e6705..c5c202e 100644 --- a/zap/error.go +++ b/zap/error.go @@ -42,8 +42,7 @@ type stackTracerBytes interface { // stackTracerPCs is satisfied by errors that expose a stack trace as // pkg/errors-style program counters. github.com/pkg/errors errors (e.g. those -// returned by errors.New / errors.Wrap from that package) satisfy this -// interface natively. +// returned by pkgerrors.New / pkgerrors.Wrap) satisfy this interface natively. type stackTracerPCs interface { StackTrace() pkgerrors.StackTrace } @@ -55,7 +54,7 @@ type stackTracerPCs interface { // - error.stack_trace: if any error in the chain implements one of the // conventional stack-trace interfaces — checked in this order: // 1. interface{ StackTrace() []byte } (samber/oops) -// 2. interface{ StackTrace() errors.StackTrace } (github.com/pkg/errors) +// 2. interface{ StackTrace() pkgerrors.StackTrace } (github.com/pkg/errors) // // Err is the only multi-field constructor in the library, provided so callers // do not need any specific zap encoder (e.g. ecszap) to obtain a stack trace. @@ -105,7 +104,7 @@ func extractStackTrace(err error) []byte { // calls Error() on the typed-nil receiver, which would panic // - error: delegates to Err(err) — error.stack_trace included if // the error implements either StackTrace() []byte (samber/oops) or -// StackTrace() errors.StackTrace (github.com/pkg/errors) +// StackTrace() pkgerrors.StackTrace (github.com/pkg/errors) // - other: error.message = fmt.Sprint(v); error.type = fmt.Sprintf("%T", v) // // ErrAny intentionally does not call runtime/debug.Stack() itself. To attach