Documentation
¶
Overview ¶
Package logging supplies the parts of structured logging that the standard library leaves to the application, and nothing else.
There is no Logger interface here, deliberately ¶
GatewayDNS logs through log/slog. This package defines no Logger interface of its own, and it never will. In 2019 the right thing for a library to do was declare a two-method Logger interface and let the application adapt whatever it already used; since Go 1.21 that decision has been made for everyone by the standard library, and slog.Handler is the extension point. A competing interface would buy nothing and cost every consumer an adapter, so components in this module accept a *slog.Logger and callers who use zap, zerolog or logr bring a Handler that targets it.
What the standard library does not supply, and this package does, is:
- NewSyslogHandler, an RFC 5424 Handler written over an io.Writer because log/syslog does not compile on Windows.
- NewSamplingHandler, which keeps a per-query log statement from becoming ten thousand lines a second.
- NewFanoutHandler, for "JSON to a file and syslog to the network".
- New, which builds a handler chain from a configuration struct and hands back the slog.LevelVar that a reload can retune.
- Discard and OrDiscard, so that "no logger configured" is not a nil dereference.
A log is not an event ¶
The distinction matters enough to state before anything else, because reaching for the wrong one is the mistake that is expensive to undo.
A LOG is a line of text for a human or a log aggregator. Its audience reads it after the fact, its schema is advisory, its contents may be sampled or dropped under load, and nothing in the engine ever branches on it. Log freely, and assume nobody is listening.
An EVENT is a typed value delivered to a programmatic subscriber — see the events package. Its audience is code: a metrics exporter, a policy engine, an audit sink, a test. Its schema is part of the module's compatibility promise, it is never sampled away, and a subscriber may act on it.
The rule of thumb: if a machine would have to parse your log line to do its job, it should have been an event. If a human would have to write a program to read your event, it should have been a log. Query-level observability belongs in events and metrics; logs describe what the process itself is doing.
What logging costs at 10,000 queries per second ¶
One log statement on the query path is ten thousand statements a second. At that rate the cost of logging is not the disk, it is the argument evaluation that happens whether or not the record is ever written.
The standard library gives three tools, and this package adds a fourth:
slog.Logger.Enabled short-circuits before anything is built. Guard any call whose arguments are not free:
if log.Enabled(ctx, slog.LevelDebug) { log.LogAttrs(ctx, slog.LevelDebug, "cache lookup", slog.String("qname", q.Name.String())) }
Note that q.Name.String() allocates; that is the cost the guard removes, and no handler can remove it for you because the conversion has already happened by the time Handle is called.
slog.Logger.LogAttrs takes slog.Attr values rather than ...any, so the arguments of a record that is written are not boxed into interfaces on the way. The variadic Info/Debug helpers also check Enabled before they look at their arguments, so the saving is on records that are kept, not on records that are dropped — but on a query path most records are kept by somebody, so prefer LogAttrs there and keep the variadic helpers for start-up and shutdown.
The typed constructors — slog.String, slog.Int, slog.Duration — rather than slog.Any, which boxes.
NewSamplingHandler, for the statement that is genuinely useful but cannot be allowed to fire on every query. It collapses repeats of the same (level, message) into the first N plus every Mth per interval, and reports how many it dropped so the gap is visible rather than silent.
Discard exists for the same reason: its Handler reports Enabled false, so a component handed the discard logger pays the guard's comparison and nothing else.
Allocation behaviour ¶
Measured with testing.AllocsPerRun once the internal buffer pool is warm: zero allocations for a disabled call behind an Enabled guard, zero for a record the sampler drops, and zero for a NewSyslogHandler record carrying bound attributes, a group and three typed attributes. The syslog handler formats into a pooled buffer and writes it in one call, so the only growth left is the pool reaching the size of the largest record it has seen.
The zeroes hold for slog.String, slog.Int, slog.Bool, slog.Duration and the other typed constructors. slog.Any boxes its argument and a value that is neither a known kind nor an error goes through fmt, which allocates. See TestAllocations, which fails rather than merely slowing down if any of this regresses.
Concurrency ¶
Every Handler in this package is safe for concurrent use by multiple goroutines, which the slog.Handler contract requires. NewSyslogHandler serialises writes to its io.Writer so that records interleave at record boundaries and never mid-line.
No global state ¶
Nothing here is a singleton and nothing is configured by a package-level variable. Every handler is constructed and injected, which is what makes it possible to embed two GatewayDNS engines in one process and log them differently.
Index ¶
- Constants
- func Discard() *slog.Logger
- func New(w io.Writer, opts Options) (*slog.Logger, *slog.LevelVar, error)
- func OrDiscard(l *slog.Logger) *slog.Logger
- type Facility
- type FanoutHandler
- type Format
- type Options
- type SamplingHandler
- func (h *SamplingHandler) Enabled(ctx context.Context, l slog.Level) bool
- func (h *SamplingHandler) Handle(ctx context.Context, r slog.Record) error
- func (h *SamplingHandler) Stats() SamplingStats
- func (h *SamplingHandler) WithAttrs(attrs []slog.Attr) slog.Handler
- func (h *SamplingHandler) WithGroup(name string) slog.Handler
- type SamplingOptions
- type SamplingStats
- type SyslogHandler
- type SyslogOptions
Examples ¶
Constants ¶
const ( DefaultSamplingInterval = time.Second DefaultSamplingFirst = 10 DefaultSamplingThereafter = 100 DefaultSamplingMaxKeys = 1024 DefaultSamplingMaxKeyLen = 256 )
Default sampler settings. They are tuned for a resolver answering thousands of queries per second: the first ten occurrences of a message describe the problem, and one in a hundred thereafter is enough to show that it is still happening.
const ( MaxHostnameLen = 255 MaxAppNameLen = 48 MaxProcIDLen = 128 MaxSDNameLen = 32 )
RFC 5424 section 6 length limits on the header fields. Exceeding them is a configuration error rather than a runtime one, so Options.Validate reports them and NewSyslogHandler truncates rather than emitting a header a strict receiver will discard.
const DefaultSDID = "gatewaydns@0"
DefaultSDID is the SD-ID used when SyslogOptions.SDID is empty.
const MaxFacility = FacilityLocal7
MaxFacility is the largest valid facility code. Values above it cannot be encoded in a priority value at all, so Options.Validate rejects them rather than letting them silently alias another facility.
Variables ¶
This section is empty.
Functions ¶
func Discard ¶
Discard returns a logger that drops everything.
Its handler reports Enabled false, which is the part that matters: a component handed this logger and guarding its expensive calls with slog.Logger.Enabled does no work at all, so passing it in a benchmark measures the code and not the logging. Use it in tests, and as the value for a caller who supplied no logger.
Each call returns a new slog.Logger over the same stateless handler; hoist it into a field rather than calling it per query.
func New ¶
New builds a logger from a configuration document and returns it together with the slog.LevelVar that controls it.
The LevelVar is the reason this function returns two values. A configuration reload that wants to go from info to debug must not rebuild the handler chain: doing so would race every goroutine holding a *slog.Logger, drop whatever buffering the sink had, and reopen a network connection for no reason. Holding on to the returned LevelVar and calling Set on it retunes the running logger atomically, from any goroutine, with no coordination:
log, level, err := logging.New(os.Stderr, cfg.Logging) … level.Set(slog.LevelDebug) // takes effect on the next record, everywhere
Every record is written to w. A nil w is treated as io.Discard, so that a caller with nowhere to log gets a working logger rather than a panic on the first record. New returns the error from Options.Validate unchanged, so it may report several problems at once.
Example ¶
A logger is built from configuration once, at start-up, and the LevelVar is kept so that a later reload can retune it without rebuilding anything.
package main
import (
"context"
"fmt"
"log/slog"
"os"
"github.com/daboss2003/dns/logging"
)
func main() {
log, level, err := logging.New(os.Stdout, logging.Options{
Format: logging.FormatJSON,
Level: slog.LevelInfo,
})
if err != nil {
panic(err)
}
ctx := context.Background()
fmt.Println("debug enabled:", log.Enabled(ctx, slog.LevelDebug))
// A configuration reload arrives and asks for debug. Nothing is rebuilt,
// the sink stays open, and no goroutine already holding log needs telling.
level.Set(slog.LevelDebug)
fmt.Println("debug enabled:", log.Enabled(ctx, slog.LevelDebug))
}
Output: debug enabled: false debug enabled: true
func OrDiscard ¶
OrDiscard returns l, or Discard when l is nil.
Every package in this module accepts an optional *slog.Logger and runs it through here at construction, so that "the caller did not configure logging" is expressed once, here, instead of as a nil check at every call site — where it would eventually be forgotten.
Example ¶
OrDiscard is how every component in this module accepts an optional logger.
package main
import (
"context"
"fmt"
"log/slog"
"github.com/daboss2003/dns/logging"
)
func main() {
newResolver := func(log *slog.Logger) *slog.Logger {
// One nil check, at construction, instead of one at every call site.
return logging.OrDiscard(log)
}
log := newResolver(nil)
log.Error("this goes nowhere and costs nothing")
// Enabled is false, so a guarded call never builds its arguments either.
fmt.Println("error enabled:", log.Enabled(context.Background(), slog.LevelError))
}
Output: error enabled: false
Types ¶
type Facility ¶
type Facility uint8
Facility is the RFC 5424 section 6.2.1 facility code, the coarse "which subsystem is talking" half of the priority value.
The numbering is the one every syslog implementation has used since BSD, so an operator writing 16 into a configuration file gets local0 and is not surprised.
const ( FacilityKernel Facility = 0 FacilityUser Facility = 1 FacilityMail Facility = 2 FacilityDaemon Facility = 3 FacilityAuth Facility = 4 FacilitySyslog Facility = 5 FacilityLPR Facility = 6 FacilityNews Facility = 7 FacilityUUCP Facility = 8 FacilityCron Facility = 9 FacilityAuthPriv Facility = 10 FacilityFTP Facility = 11 FacilityNTP Facility = 12 FacilityLogAudit Facility = 13 FacilityLogAlert Facility = 14 FacilityClock Facility = 15 FacilityLocal0 Facility = 16 FacilityLocal1 Facility = 17 FacilityLocal2 Facility = 18 FacilityLocal3 Facility = 19 FacilityLocal4 Facility = 20 FacilityLocal5 Facility = 21 FacilityLocal6 Facility = 22 FacilityLocal7 Facility = 23 )
Facility codes from RFC 5424 table 1.
FacilityKernel is listed for completeness only: RFC 5424 reserves facility 0 for messages generated by the kernel, so a userspace process must never claim it. NewSyslogHandler therefore reads a zero SyslogOptions.Facility as "unset" and substitutes FacilityDaemon, which is what a long-running network service should be using anyway.
type FanoutHandler ¶
type FanoutHandler struct {
// contains filtered or unexported fields
}
FanoutHandler delivers every record to several handlers.
The motivating case is having two audiences at once: indented JSON to a file for the aggregator, RFC 5424 to a network socket for the operator's syslog infrastructure, and neither one willing to be the other. slog.Logger holds a single Handler, so the multiplexing has to live in a Handler.
Two behaviours are deliberate. Enabled reports true if ANY child is enabled, because a child configured at debug level must still receive debug records even when a sibling is at warn — the sibling declines them itself, in its own Enabled. And Handle attempts every child and joins their errors with errors.Join rather than returning at the first failure, because a broken network socket must not silence the log file.
A FanoutHandler is safe for concurrent use if its children are, which the slog.Handler contract requires of them.
func NewFanoutHandler ¶
func NewFanoutHandler(hs ...slog.Handler) *FanoutHandler
NewFanoutHandler returns a handler that writes to each of hs, in order. A FanoutHandler over no children is valid and reports Enabled false.
An untyped nil in hs is dropped, so a list built conditionally does not need every entry guarded. A TYPED nil — a (*SyslogHandler)(nil) from a constructor that returned an error, say — is not detected and will panic on first use. Detecting it would need reflection on every construction to catch what is a programming error, so the honest contract is: do not put one in the list.
Example ¶
The composition this package exists for: machine-readable JSON to a file and RFC 5424 to a syslog receiver, from one logger, with one level control.
package main
import (
"context"
"log/slog"
"os"
"time"
"github.com/daboss2003/dns/logging"
)
func main() {
level := new(slog.LevelVar)
h := logging.NewFanoutHandler(
slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{Level: level}),
logging.NewSyslogHandler(os.Stdout, &logging.SyslogOptions{
Facility: logging.FacilityLocal0,
Hostname: "res1.example.net",
AppName: "gatewaydns",
Level: level,
}),
)
// A record is built by hand here only so that the example has a fixed
// timestamp to print; ordinarily this is slog.Logger.LogAttrs. A zero time
// is what both handlers render as "unknown".
r := slog.NewRecord(time.Time{}, slog.LevelWarn, "upstream timeout", 0)
r.AddAttrs(slog.String("addr", "192.0.2.1:53"), slog.Duration("after", 2*time.Second))
if err := h.Handle(context.Background(), r); err != nil {
panic(err)
}
}
Output: {"level":"WARN","msg":"upstream timeout","addr":"192.0.2.1:53","after":2000000000} <132>1 - res1.example.net gatewaydns - - [gatewaydns@0 addr="192.0.2.1:53" after="2s"] upstream timeout
func (*FanoutHandler) Enabled ¶
Enabled implements slog.Handler.
func (*FanoutHandler) Handle ¶
Handle implements slog.Handler.
Each child that is enabled for the record's level receives its own clone. The clone is not paranoia: slog.Record shares a backing array for attributes beyond the first few, so a child that calls AddAttrs on the record it was given would otherwise corrupt what the next child sees.
func (*FanoutHandler) WithAttrs ¶
func (h *FanoutHandler) WithAttrs(attrs []slog.Attr) slog.Handler
WithAttrs implements slog.Handler. Each child derives its own handler and the result gets a fresh slice, so no two derivations can share a backing array.
func (*FanoutHandler) WithGroup ¶
func (h *FanoutHandler) WithGroup(name string) slog.Handler
WithGroup implements slog.Handler.
type Format ¶
type Format string
Format selects the wire form of a log record.
It is a string rather than an integer so that a configuration file says "json" and not 1, and so that an unknown value survives round-tripping through JSON far enough for Options.Validate to name it in an error.
const ( // FormatText is [slog.NewTextHandler]: key=value pairs, meant for a // terminal or a development log. It is the zero value because a program // with no logging configured at all should still produce something a human // can read. FormatText Format = "text" // FormatJSON is [slog.NewJSONHandler], one object per line. This is what a // log aggregator wants. FormatJSON Format = "json" // FormatSyslog is [NewSyslogHandler], RFC 5424 over the supplied writer. FormatSyslog Format = "syslog" )
The formats New can build.
type Options ¶
type Options struct {
// Level is the minimum level to emit. It unmarshals from the usual names —
// "debug", "INFO", "warn+2" — because [slog.Level] implements
// [encoding.TextUnmarshaler]. The zero value is [slog.LevelInfo].
Level slog.Level `json:"level"`
// Format selects the record encoding. Empty means [FormatText].
Format Format `json:"format"`
// AddSource attaches the file and line of the log call. It costs a
// runtime.CallersFrames lookup per record, so it is off by default and
// should stay off on a query path. It applies to [FormatText] and
// [FormatJSON]; RFC 5424 has no field for a source location, so
// [FormatSyslog] ignores it — attach the location as an attribute if you
// need it there.
AddSource bool `json:"add_source"`
// Syslog configures [FormatSyslog] and is ignored by the other formats.
Syslog SyslogOptions `json:"syslog"`
// Sampling wraps the chosen format in a [SamplingHandler]. Nil, the zero
// value, means no sampling: every record that passes the level check is
// written. An empty object enables sampling with the package defaults,
// which makes "sampling": {} the shortest way to turn it on.
Sampling *SamplingOptions `json:"sampling,omitempty"`
}
Options is the logging section of a configuration document.
It is meant to be embedded in a larger config struct, so its JSON tags are lowercase and snake_case, and its zero value is a working configuration: info-level text with no source locations and no sampling. Nothing here needs to be set for New to succeed.
func (Options) Validate ¶
Validate reports every problem it can find in o, joined with errors.Join.
Reporting all of them at once is the whole point. An operator editing a configuration file gets one restart per error otherwise, and a five-mistake file becomes five deployments; errors.Join renders one problem per line, which is what a startup failure should print.
New calls Validate, so a caller who is not validating separately is not skipping it.
type SamplingHandler ¶
type SamplingHandler struct {
// contains filtered or unexported fields
}
SamplingHandler passes the first N and then every Mth record with the same (level, message) key through to another handler, per interval.
Why ¶
A statement on the query path fires once per query. At 10,000 queries per second that is 10,000 lines per second, which costs more CPU than answering the query, fills a disk in an afternoon and is unreadable besides. The signal in a repeated message is in its first few occurrences and in the fact that it is still occurring; the sampler keeps both and discards the rest.
Bounded key space ¶
The key map is capped at SamplingOptions.MaxKeys, and messages longer than SamplingOptions.MaxKeyLen never become keys at all. This is a security property, not tidiness: log messages are frequently built from data the peer controls — a query name, a client address — and an unbounded key map turns that into remote memory exhaustion.
On overflow a record is counted against a single shared bucket and sampled by the same First/Thereafter rule. Fail-open (passing every overflow record through) would hand an attacker the flood the sampler exists to prevent; fail-closed (dropping them) would let an attacker silence a genuine new error by filling the map first. Sharing one bucket bounds the volume without ever going completely quiet, and SamplingHandler.Stats reports how often it happened.
Visibility ¶
Whenever an interval rolls over having dropped anything, the sampler emits one record at slog.LevelWarn to the wrapped handler reporting the count. Silent suppression is how a sampler turns into a bug report about missing logs, so the gap announces itself.
A SamplingHandler is safe for concurrent use. Enabled does no work of its own beyond delegating, so a caller's Enabled guard stays as cheap as it was.
func NewSamplingHandler ¶
func NewSamplingHandler(next slog.Handler, opts *SamplingOptions) *SamplingHandler
NewSamplingHandler returns a handler that samples records before passing them to next. A nil opts selects every default; a nil next makes the handler a well-behaved sink that reports Enabled false.
Example ¶
A statement on the query path is guarded so that its arguments are never built when the level is off, and sampled so that it cannot become ten thousand lines a second when it is on.
package main
import (
"context"
"log/slog"
"os"
"time"
"github.com/daboss2003/dns/logging"
)
func main() {
base := slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
ReplaceAttr: func(_ []string, a slog.Attr) slog.Attr {
if a.Key == slog.TimeKey {
return slog.Attr{}
}
return a
},
})
log := slog.New(logging.NewSamplingHandler(base, &logging.SamplingOptions{
Interval: time.Second,
First: 2,
Thereafter: -1, // nothing beyond the first two, for a tidy example
}))
ctx := context.Background()
for i := 0; i < 1000; i++ {
if log.Enabled(ctx, slog.LevelInfo) {
log.LogAttrs(ctx, slog.LevelInfo, "cache miss", slog.String("qname", "example.com."))
}
}
}
Output: level=INFO msg="cache miss" qname=example.com. level=INFO msg="cache miss" qname=example.com.
func (*SamplingHandler) Enabled ¶
Enabled implements slog.Handler. It delegates without touching the sampler, because the guard in front of an expensive log call must stay a single comparison; the sampling decision needs the message and cannot be made here anyway.
func (*SamplingHandler) Handle ¶
Handle implements slog.Handler.
func (*SamplingHandler) Stats ¶
func (h *SamplingHandler) Stats() SamplingStats
Stats returns a snapshot of the sampler's counters. It is safe to call concurrently with logging and does not contend with it.
func (*SamplingHandler) WithAttrs ¶
func (h *SamplingHandler) WithAttrs(attrs []slog.Attr) slog.Handler
WithAttrs implements slog.Handler. The sampler state is shared with the returned handler by design; see [samplerState].
func (*SamplingHandler) WithGroup ¶
func (h *SamplingHandler) WithGroup(name string) slog.Handler
WithGroup implements slog.Handler.
type SamplingOptions ¶
type SamplingOptions struct {
// Interval is the window over which counting happens; every counter resets
// when it elapses. Zero selects [DefaultSamplingInterval].
Interval time.Duration `json:"interval"`
// First is how many records with a given key are passed through at the
// start of each interval. Zero selects [DefaultSamplingFirst].
//
// A negative value is an error, and [Options.Validate] rejects it, because a
// negative count is far more likely a typo than an intent. Write 0 to select
// the default; there is deliberately no way to spell "pass nothing at the
// start of an interval and rely on Thereafter alone", because a sampler that
// drops the first occurrence of a message is a sampler that hides the thing
// you most wanted to see.
First int `json:"first"`
// Thereafter passes every Nth record beyond First. Zero selects
// [DefaultSamplingThereafter]. Set it to a negative value to pass nothing
// beyond First for the rest of the interval.
Thereafter int `json:"thereafter"`
// MaxKeys caps how many distinct (level, message) pairs are tracked at once;
// see [SamplingHandler] for what happens on overflow. Zero selects
// [DefaultSamplingMaxKeys].
MaxKeys int `json:"max_keys"`
// MaxKeyLen caps the message length that may become a map key. Zero selects
// [DefaultSamplingMaxKeyLen]. Longer messages go to the overflow bucket
// rather than being retained, because a map key is a live reference to the
// string and a message built from attacker-controlled data can be large.
MaxKeyLen int `json:"max_key_len"`
// Clock is the time source. Nil means [clock.System]; tests pass
// [clock.NewFake] so that interval boundaries are exact.
Clock clock.Clock `json:"-"`
}
SamplingOptions configures NewSamplingHandler. The zero value selects every default above, so &SamplingOptions{} is a reasonable sampler.
func (SamplingOptions) MarshalJSON ¶
func (o SamplingOptions) MarshalJSON() ([]byte, error)
MarshalJSON implements json.Marshaler.
Interval is written as a duration string rather than a count of nanoseconds. The struct is meant to be embedded in an operator-edited configuration file, and "1s" is a value a human can write and read back; 1000000000 is not.
func (*SamplingOptions) UnmarshalJSON ¶
func (o *SamplingOptions) UnmarshalJSON(b []byte) error
UnmarshalJSON implements json.Unmarshaler, accepting "interval" either as a time.ParseDuration string or as a bare number of nanoseconds, so that a document produced by an older writer still loads.
type SamplingStats ¶
type SamplingStats struct {
// Total is every record the sampler considered.
Total uint64
// Logged is how many it passed to the wrapped handler.
Logged uint64
// Dropped is how many it suppressed. This is the number that must not be
// invisible: a metric or dashboard reading zero here means the log is
// complete, and anything else quantifies what is missing.
Dropped uint64
// Overflowed is how many records were routed to the shared overflow bucket
// because the key set was full or the message was too long. A number that
// climbs is a cardinality problem — usually a message built by
// concatenating a variable into it, which should have been an attribute.
Overflowed uint64
}
SamplingStats is a snapshot of what a sampler has done since it was created. All counters are cumulative and never reset, so a scrape can difference two readings.
type SyslogHandler ¶
type SyslogHandler struct {
// contains filtered or unexported fields
}
SyslogHandler formats records as RFC 5424 syslog messages and writes them to an io.Writer.
Why this is not log/syslog ¶
The standard library's log/syslog package does not build on Windows, and it dials a transport of its own choosing. Importing it would cost this module both its platform independence and the caller's control over where bytes go, so the framing is implemented here, over an io.Writer, and it must stay that way. If you are reading this while "simplifying" the package back onto log/syslog: it will not compile on Windows. That is the whole reason.
Because the sink is an io.Writer, the transport is the caller's decision. A *os.File gives a local file, a net.Conn from net.Dial("udp", …) gives RFC 5426 syslog over UDP, and a TCP conn gives RFC 6587 non-transparent framing — which is why every record is terminated with a single LF, never CRLF, on every platform.
Structured data ¶
Attributes become one SD-ELEMENT whose SD-ID is SyslogOptions.SDID. Groups are flattened into dotted parameter names — a group "upstream" containing "addr" is written upstream.addr="…" — because RFC 5424 structured data is exactly one level deep: an SD-ELEMENT cannot contain another SD-ELEMENT, and SD-ID is a flat token. Emitting one SD-ELEMENT per group would be the other legal option, but it loses the nesting just the same while making it impossible to tell a group's parameters apart from a sibling group's, and it puts the caller's group names into the SD-ID namespace that section 7.2.2 reserves for enterprise-qualified names. Dotted names keep the structure visible and the SD-ID under the operator's control.
A SyslogHandler is safe for concurrent use. Records are formatted into a pooled buffer without holding the lock and written in a single Write call, so records interleave at record boundaries and never mid-line.
func NewSyslogHandler ¶
func NewSyslogHandler(w io.Writer, opts *SyslogOptions) *SyslogHandler
NewSyslogHandler returns a SyslogHandler writing RFC 5424 messages to w.
A nil opts is equivalent to the zero SyslogOptions. Header fields are sanitised once, here, rather than on every record: RFC 5424 section 6 allows only printable ASCII in them and forbids spaces, so anything else is replaced with '-' and over-long values are truncated to the limits in section 6. Sanitising at construction keeps the per-record path to a single copy of a precomputed string.
A construction-time repair is always preferred to a runtime failure here, because logging is not the caller's errand and a handler that refused to exist would take the diagnostics down with it. A facility above MaxFacility is therefore clamped rather than rejected — but note that Options.Validate reports it as an error, so a handler built through New never has to be clamped in the first place.
func (*SyslogHandler) Enabled ¶
Enabled implements slog.Handler.
func (*SyslogHandler) Handle ¶
Handle implements slog.Handler.
func (*SyslogHandler) WithAttrs ¶
func (h *SyslogHandler) WithAttrs(attrs []slog.Attr) slog.Handler
WithAttrs implements slog.Handler.
The returned handler owns its own formatted-attribute buffer, allocated at exactly the length it needs. That precision is the point: the classic slog.Handler defect is deriving two handlers from one parent and letting both append into the parent's spare capacity, so that the second silently overwrites the first's attributes. Copying into a fresh slice makes the bug unreachable rather than merely unlikely.
func (*SyslogHandler) WithGroup ¶
func (h *SyslogHandler) WithGroup(name string) slog.Handler
WithGroup implements slog.Handler.
The group becomes a prefix on subsequent parameter names. Because prefix is a string and groups is clipped before it is appended to, two handlers derived from the same parent cannot disturb each other's group path either.
type SyslogOptions ¶
type SyslogOptions struct {
// Facility is the RFC 5424 facility code. Zero means unset and selects
// [FacilityDaemon]; see the note on [FacilityKernel] for why.
Facility Facility `json:"facility"`
// Hostname identifies the machine. It is not filled in from the operating
// system, because a library that calls os.Hostname behind the caller's back
// gets it wrong inside a container and makes tests non-deterministic. Pass
// os.Hostname's result if that is what you want.
Hostname string `json:"hostname"`
// AppName identifies the program, the "gatewaydns" in a log line.
AppName string `json:"app_name"`
// ProcID is free-form and need not be a process ID. RFC 5424 section 6.2.6
// suggests using it for anything whose change signals a discontinuity, so a
// worker or instance identifier is a legitimate use.
ProcID string `json:"proc_id"`
// SDID names the STRUCTURED-DATA element that carries the slog attributes.
// RFC 5424 section 7.2.2 requires a non-IANA-registered SD-ID to be of the
// form name@<private-enterprise-number>; the default uses enterprise number
// 0 as a placeholder because this project has none. Operators shipping to a
// receiver that validates SD-IDs should set their own.
SDID string `json:"sd_id"`
// Level is the minimum level the handler reports as enabled. Nil means
// [slog.LevelInfo]. Pass a *[slog.LevelVar] to be able to retune it at
// runtime; [New] does exactly that.
Level slog.Leveler `json:"-"`
// ReplaceAttr rewrites or drops attributes before they are formatted, with
// the same contract as [slog.HandlerOptions.ReplaceAttr]: it receives the
// enclosing groups and the attribute, and returning the zero [slog.Attr]
// drops it. Unlike the standard handlers it is not called for the built-in
// time, level and message fields, because in RFC 5424 those are header
// fields rather than attributes and there is nothing to replace them with.
ReplaceAttr func(groups []string, a slog.Attr) slog.Attr `json:"-"`
}
SyslogOptions configures NewSyslogHandler. The zero value is usable: every header field the caller leaves empty is written as NILVALUE, which is what RFC 5424 section 6.2 says to do when a value is unknown.