Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,3 +1,37 @@
# 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.
- `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="<nil>"` + `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() pkgerrors.StackTrace` interface.

### Documentation

- README clarifies the relationship between `ecsf.Err`, `ecsf.ErrorXxx`
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`.
31 changes: 29 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -84,15 +84,15 @@ 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 |
| ------------------------------------ | ------------------ | --------------------------------------------- |
| `service.*` | 11 | full top-level |
| `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 |
Expand Down Expand Up @@ -120,6 +120,33 @@ 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`

Under the ecszap encoder, `zap.Error(err)` produces ECS `error.message`
(always) and `error.stack_trace` (only when `err` implements pkg/errors'
`StackTrace() pkgerrors.StackTrace`). Plain `errors.New(...)` does not produce
a stack. You do not have to migrate existing `zap.Error(err)` call sites
Comment thread
maxence2997 marked this conversation as resolved.
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.** 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.

`ecsf.Err(err)` extracts `error.stack_trace` via either pkg/errors'
`StackTrace() pkgerrors.StackTrace` or samber/oops' `StackTrace() []byte`,
so any error wrapped by either library carries its stack through.
Comment thread
maxence2997 marked this conversation as resolved.

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

[MIT](LICENSE)
6 changes: 3 additions & 3 deletions docs/ecs-coverage.md
Original file line number Diff line number Diff line change
@@ -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.
Expand All @@ -12,15 +12,15 @@ 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 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` |
| `url.*` | 15 | `URLPort` is the only numeric field; `URLPasswordRedacted` always emits `***` |
| `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)

Expand Down
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,14 @@ 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
)

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
Expand Down
110 changes: 97 additions & 13 deletions zap/error.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,9 @@ package zap
import (
"errors"
"fmt"
"reflect"

pkgerrors "github.com/pkg/errors"
"go.uber.org/zap"
"go.uber.org/zap/zapcore"
)
Expand All @@ -32,21 +34,27 @@ 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 pkgerrors.New / pkgerrors.Wrap) 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() 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.
Expand All @@ -59,11 +67,87 @@ 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:
//
// - nil: returns nil (no fields)
// - typed-nil error: error.type emitted, error.message = "<nil>" — never
// 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() 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
// 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 {
if isTypedNil(err) {
return []zapcore.Field{
ErrorMessage("<nil>"),
ErrorType(fmt.Sprintf("%T", err)),
}
}
return Err(err)
}
return []zapcore.Field{
ErrorMessage(fmt.Sprint(v)),
ErrorType(fmt.Sprintf("%T", v)),
}
Comment thread
maxence2997 marked this conversation as resolved.
}

// 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
}
Loading