Skip to content

Commit a01e410

Browse files
authored
Make user-supplied sinks operate on URIs (uber-go#606)
For future extensibility, make user-supplied factories for log sinks operate on URIs. Each user-supplied factory owns a scheme, and double-registering constructors for a scheme is an error. For back-compat, zap automatically registers a factory for the `file` scheme and treats URIs without a scheme as though they were for files.
1 parent 7e7e266 commit a01e410

File tree

5 files changed

+242
-122
lines changed

5 files changed

+242
-122
lines changed

config.go

+3-3
Original file line numberDiff line numberDiff line change
@@ -74,10 +74,10 @@ type Config struct {
7474
// EncoderConfig sets options for the chosen encoder. See
7575
// zapcore.EncoderConfig for details.
7676
EncoderConfig zapcore.EncoderConfig `json:"encoderConfig" yaml:"encoderConfig"`
77-
// OutputPaths is a list of paths to write logging output to. See Open for
78-
// details.
77+
// OutputPaths is a list of URLs or file paths to write logging output to.
78+
// See Open for details.
7979
OutputPaths []string `json:"outputPaths" yaml:"outputPaths"`
80-
// ErrorOutputPaths is a list of paths to write internal logger errors to.
80+
// ErrorOutputPaths is a list of URLs to write internal logger errors to.
8181
// The default is standard error.
8282
//
8383
// Note that this setting only affects internal errors; for sample code that

sink.go

+96-29
Original file line numberDiff line numberDiff line change
@@ -24,15 +24,19 @@ import (
2424
"errors"
2525
"fmt"
2626
"io"
27+
"net/url"
2728
"os"
29+
"strings"
2830
"sync"
2931

3032
"go.uber.org/zap/zapcore"
3133
)
3234

35+
const schemeFile = "file"
36+
3337
var (
3438
_sinkMutex sync.RWMutex
35-
_sinkFactories map[string]func() (Sink, error)
39+
_sinkFactories map[string]func(*url.URL) (Sink, error) // keyed by scheme
3640
)
3741

3842
func init() {
@@ -42,18 +46,10 @@ func init() {
4246
func resetSinkRegistry() {
4347
_sinkMutex.Lock()
4448
defer _sinkMutex.Unlock()
45-
_sinkFactories = map[string]func() (Sink, error){
46-
"stdout": func() (Sink, error) { return nopCloserSink{os.Stdout}, nil },
47-
"stderr": func() (Sink, error) { return nopCloserSink{os.Stderr}, nil },
48-
}
49-
}
5049

51-
type errSinkNotFound struct {
52-
key string
53-
}
54-
55-
func (e *errSinkNotFound) Error() string {
56-
return fmt.Sprintf("no sink found for %q", e.key)
50+
_sinkFactories = map[string]func(*url.URL) (Sink, error){
51+
schemeFile: newFileSink,
52+
}
5753
}
5854

5955
// Sink defines the interface to write to and close logger destinations.
@@ -62,33 +58,104 @@ type Sink interface {
6258
io.Closer
6359
}
6460

65-
// RegisterSink adds a Sink at the given key so it can be referenced
66-
// in config OutputPaths.
67-
func RegisterSink(key string, sinkFactory func() (Sink, error)) error {
61+
type nopCloserSink struct{ zapcore.WriteSyncer }
62+
63+
func (nopCloserSink) Close() error { return nil }
64+
65+
type errSinkNotFound struct {
66+
scheme string
67+
}
68+
69+
func (e *errSinkNotFound) Error() string {
70+
return fmt.Sprintf("no sink found for scheme %q", e.scheme)
71+
}
72+
73+
// RegisterSink registers a user-supplied factory for all sinks with a
74+
// particular scheme.
75+
//
76+
// All schemes must be ASCII, valid under section 3.1 of RFC 3986
77+
// (https://tools.ietf.org/html/rfc3986#section-3.1), and must not already
78+
// have a factory registered. Zap automatically registers a factory for the
79+
// "file" scheme.
80+
func RegisterSink(scheme string, factory func(*url.URL) (Sink, error)) error {
6881
_sinkMutex.Lock()
6982
defer _sinkMutex.Unlock()
70-
if key == "" {
71-
return errors.New("sink key cannot be blank")
83+
84+
if scheme == "" {
85+
return errors.New("can't register a sink factory for empty string")
86+
}
87+
normalized, err := normalizeScheme(scheme)
88+
if err != nil {
89+
return fmt.Errorf("%q is not a valid scheme: %v", scheme, err)
7290
}
73-
if _, ok := _sinkFactories[key]; ok {
74-
return fmt.Errorf("sink already registered for key %q", key)
91+
if _, ok := _sinkFactories[normalized]; ok {
92+
return fmt.Errorf("sink factory already registered for scheme %q", normalized)
7593
}
76-
_sinkFactories[key] = sinkFactory
94+
_sinkFactories[normalized] = factory
7795
return nil
7896
}
7997

80-
// newSink invokes the registered sink factory to create and return the
81-
// sink for the given key. Returns errSinkNotFound if the key cannot be found.
82-
func newSink(key string) (Sink, error) {
98+
func newSink(rawURL string) (Sink, error) {
99+
u, err := url.Parse(rawURL)
100+
if err != nil {
101+
return nil, fmt.Errorf("can't parse %q as a URL: %v", rawURL, err)
102+
}
103+
if u.Scheme == "" {
104+
u.Scheme = schemeFile
105+
}
106+
83107
_sinkMutex.RLock()
84-
defer _sinkMutex.RUnlock()
85-
sinkFactory, ok := _sinkFactories[key]
108+
factory, ok := _sinkFactories[u.Scheme]
109+
_sinkMutex.RUnlock()
86110
if !ok {
87-
return nil, &errSinkNotFound{key}
111+
return nil, &errSinkNotFound{u.Scheme}
88112
}
89-
return sinkFactory()
113+
return factory(u)
90114
}
91115

92-
type nopCloserSink struct{ zapcore.WriteSyncer }
116+
func newFileSink(u *url.URL) (Sink, error) {
117+
if u.User != nil {
118+
return nil, fmt.Errorf("user and password not allowed with file URLs: got %v", u)
119+
}
120+
if u.Fragment != "" {
121+
return nil, fmt.Errorf("fragments not allowed with file URLs: got %v", u)
122+
}
123+
if u.RawQuery != "" {
124+
return nil, fmt.Errorf("query parameters not allowed with file URLs: got %v", u)
125+
}
126+
// Error messages are better if we check hostname and port separately.
127+
if u.Port() != "" {
128+
return nil, fmt.Errorf("ports not allowed with file URLs: got %v", u)
129+
}
130+
if hn := u.Hostname(); hn != "" && hn != "localhost" {
131+
return nil, fmt.Errorf("file URLs must leave host empty or use localhost: got %v", u)
132+
}
133+
switch u.Path {
134+
case "stdout":
135+
return nopCloserSink{os.Stdout}, nil
136+
case "stderr":
137+
return nopCloserSink{os.Stderr}, nil
138+
}
139+
return os.OpenFile(u.Path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
140+
}
93141

94-
func (nopCloserSink) Close() error { return nil }
142+
func normalizeScheme(s string) (string, error) {
143+
// https://tools.ietf.org/html/rfc3986#section-3.1
144+
s = strings.ToLower(s)
145+
if first := s[0]; 'a' > first || 'z' < first {
146+
return "", errors.New("must start with a letter")
147+
}
148+
for i := 1; i < len(s); i++ { // iterate over bytes, not runes
149+
c := s[i]
150+
switch {
151+
case 'a' <= c && c <= 'z':
152+
continue
153+
case '0' <= c && c <= '9':
154+
continue
155+
case c == '.' || c == '+' || c == '-':
156+
continue
157+
}
158+
return "", fmt.Errorf("may not contain %q", c)
159+
}
160+
return s, nil
161+
}

sink_test.go

+59-35
Original file line numberDiff line numberDiff line change
@@ -21,56 +21,80 @@
2121
package zap
2222

2323
import (
24-
"errors"
25-
"os"
24+
"bytes"
25+
"io/ioutil"
26+
"net/url"
27+
"strings"
2628
"testing"
2729

2830
"github.com/stretchr/testify/assert"
31+
"github.com/stretchr/testify/require"
32+
33+
"go.uber.org/zap/zapcore"
2934
)
3035

3136
func TestRegisterSink(t *testing.T) {
32-
tests := []struct {
33-
name string
34-
key string
35-
factory func() (Sink, error)
36-
wantError bool
37-
}{
38-
{"valid", "valid", func() (Sink, error) { return nopCloserSink{os.Stdout}, nil }, false},
39-
{"empty", "", func() (Sink, error) { return nopCloserSink{os.Stdout}, nil }, true},
40-
{"stdout", "stdout", func() (Sink, error) { return nopCloserSink{os.Stdout}, nil }, true},
41-
}
37+
const (
38+
memScheme = "m"
39+
nopScheme = "no-op.1234"
40+
)
41+
var memCalls, nopCalls int
4242

43-
for _, tt := range tests {
44-
t.Run(tt.name, func(t *testing.T) {
45-
err := RegisterSink(tt.key, tt.factory)
46-
if tt.wantError {
47-
assert.NotNil(t, err)
48-
} else {
49-
assert.Nil(t, err)
50-
assert.NotNil(t, _sinkFactories[tt.key], "expected the factory to be present")
51-
}
52-
})
43+
buf := bytes.NewBuffer(nil)
44+
memFactory := func(u *url.URL) (Sink, error) {
45+
assert.Equal(t, u.Scheme, memScheme, "Scheme didn't match registration.")
46+
memCalls++
47+
return nopCloserSink{zapcore.AddSync(buf)}, nil
48+
}
49+
nopFactory := func(u *url.URL) (Sink, error) {
50+
assert.Equal(t, u.Scheme, nopScheme, "Scheme didn't match registration.")
51+
nopCalls++
52+
return nopCloserSink{zapcore.AddSync(ioutil.Discard)}, nil
5353
}
54-
}
5554

56-
func TestNewSink(t *testing.T) {
5755
defer resetSinkRegistry()
58-
errTestSink := errors.New("test erroring")
59-
err := RegisterSink("errors", func() (Sink, error) { return nil, errTestSink })
60-
assert.Nil(t, err)
56+
57+
require.NoError(t, RegisterSink(strings.ToUpper(memScheme), memFactory), "Failed to register scheme %q.", memScheme)
58+
require.NoError(t, RegisterSink(nopScheme, nopFactory), "Failed to register scheme %q.", memScheme)
59+
60+
sink, close, err := Open(
61+
memScheme+"://somewhere",
62+
nopScheme+"://somewhere-else",
63+
)
64+
assert.NoError(t, err, "Unexpected error opening URLs with registered schemes.")
65+
66+
defer close()
67+
68+
assert.Equal(t, 1, memCalls, "Unexpected number of calls to memory factory.")
69+
assert.Equal(t, 1, nopCalls, "Unexpected number of calls to no-op factory.")
70+
71+
_, err = sink.Write([]byte("foo"))
72+
assert.NoError(t, err, "Failed to write to combined WriteSyncer.")
73+
assert.Equal(t, "foo", buf.String(), "Unexpected buffer contents.")
74+
}
75+
76+
func TestRegisterSinkErrors(t *testing.T) {
77+
nopFactory := func(_ *url.URL) (Sink, error) {
78+
return nopCloserSink{zapcore.AddSync(ioutil.Discard)}, nil
79+
}
6180
tests := []struct {
62-
key string
63-
err error
81+
scheme string
82+
err string
6483
}{
65-
{"stdout", nil},
66-
{"errors", errTestSink},
67-
{"nonexistent", &errSinkNotFound{"nonexistent"}},
84+
{"", "empty string"},
85+
{"FILE", "already registered"},
86+
{"42", "not a valid scheme"},
87+
{"http*", "not a valid scheme"},
6888
}
6989

7090
for _, tt := range tests {
71-
t.Run(tt.key, func(t *testing.T) {
72-
_, err := newSink(tt.key)
73-
assert.Equal(t, tt.err, err)
91+
t.Run("scheme-"+tt.scheme, func(t *testing.T) {
92+
defer resetSinkRegistry()
93+
94+
err := RegisterSink(tt.scheme, nopFactory)
95+
if assert.Error(t, err, "expected error") {
96+
assert.Contains(t, err.Error(), tt.err, "unexpected error")
97+
}
7498
})
7599
}
76100
}

writer.go

+22-23
Original file line numberDiff line numberDiff line change
@@ -21,22 +21,33 @@
2121
package zap
2222

2323
import (
24+
"fmt"
2425
"io"
2526
"io/ioutil"
26-
"os"
2727

2828
"go.uber.org/zap/zapcore"
2929

3030
"go.uber.org/multierr"
3131
)
3232

33-
// Open is a high-level wrapper that takes a variadic number of paths, opens or
34-
// creates each of the specified files, and combines them into a locked
33+
// Open is a high-level wrapper that takes a variadic number of URLs, opens or
34+
// creates each of the specified resources, and combines them into a locked
3535
// WriteSyncer. It also returns any error encountered and a function to close
3636
// any opened files.
3737
//
38-
// Passing no paths returns a no-op WriteSyncer. The special paths "stdout" and
39-
// "stderr" are interpreted as os.Stdout and os.Stderr, respectively.
38+
// Passing no URLs returns a no-op WriteSyncer. Zap handles URLs without a
39+
// scheme and URLs with the "file" scheme. Third-party code may register
40+
// factories for other schemes using RegisterSink.
41+
//
42+
// URLs with the "file" scheme must use absolute paths on the local
43+
// filesystem. No user, password, port, fragments, or query parameters are
44+
// allowed, and the hostname must be empty or "localhost".
45+
//
46+
// Since it's common to write logs to the local filesystem, URLs without a
47+
// scheme (e.g., "/var/log/foo.log") are treated as local file paths. Without
48+
// a scheme, the special paths "stdout" and "stderr" are interpreted as
49+
// os.Stdout and os.Stderr. When specified without a scheme, relative file
50+
// paths also work.
4051
func Open(paths ...string) (zapcore.WriteSyncer, func(), error) {
4152
writers, close, err := open(paths)
4253
if err != nil {
@@ -48,36 +59,24 @@ func Open(paths ...string) (zapcore.WriteSyncer, func(), error) {
4859
}
4960

5061
func open(paths []string) ([]zapcore.WriteSyncer, func(), error) {
51-
var openErr error
5262
writers := make([]zapcore.WriteSyncer, 0, len(paths))
5363
closers := make([]io.Closer, 0, len(paths))
5464
close := func() {
5565
for _, c := range closers {
5666
c.Close()
5767
}
5868
}
69+
70+
var openErr error
5971
for _, path := range paths {
6072
sink, err := newSink(path)
61-
if err == nil {
62-
// Using a registered sink constructor.
63-
writers = append(writers, sink)
64-
closers = append(closers, sink)
65-
continue
66-
}
67-
if _, ok := err.(*errSinkNotFound); ok {
68-
// No named sink constructor, use key as path to log file.
69-
f, e := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0644)
70-
openErr = multierr.Append(openErr, e)
71-
if e == nil {
72-
writers = append(writers, f)
73-
closers = append(closers, f)
74-
}
73+
if err != nil {
74+
openErr = multierr.Append(openErr, fmt.Errorf("couldn't open sink %q: %v", path, err))
7575
continue
7676
}
77-
// Sink constructor failed.
78-
openErr = multierr.Append(openErr, err)
77+
writers = append(writers, sink)
78+
closers = append(closers, sink)
7979
}
80-
8180
if openErr != nil {
8281
close()
8382
return writers, nil, openErr

0 commit comments

Comments
 (0)