Logging: structured slog messages throughout, and WithLogger options so callers choose the logger
Log messages are lower-case event names with their details as key/value
attributes ("error" for errors), in place of formatted strings.
Loggable package take a WithLogger option as part of their "New*" call,
defaulting to slog.Default(). Each tags its lines with its `component`.
Some debug loggers print directly rather than using slog when in
terminal logging mode.
This commit is contained in:
1 parent
0b5fde9563
commit
043e307663
49 files changed
+598
-248
No files matched your search
+38
-27
@@ -226,7 +226,9 @@ func missingIDsError(haveOCID, haveID bool) error {
|
||||
// releases, etc).
|
||||
//
|
||||
// This method takes ownership of the response body and closes it when done.
|
||||
func safeUnpackReleases(response *resty.Response) (*ocds.ReleasePackage, error) {
|
||||
func safeUnpackReleases(
|
||||
logger *slog.Logger, response *resty.Response,
|
||||
) (*ocds.ReleasePackage, error) {
|
||||
var pkg safeReleasePackage
|
||||
|
||||
// Take ownership of the response body.
|
||||
@@ -263,9 +265,12 @@ func safeUnpackReleases(response *resty.Response) (*ocds.ReleasePackage, error)
|
||||
err := unmarshalLenientDates(rawRel, &release)
|
||||
if err != nil {
|
||||
ocid, release_id, _ := releaseIDs(rawRel)
|
||||
slog.Warn(fmt.Sprintf(
|
||||
"Skipping release (%s/%s) due to error: %s", ocid, release_id, err,
|
||||
))
|
||||
logger.Warn(
|
||||
"skipping unreadable release",
|
||||
"ocid", ocid,
|
||||
"release_id", release_id,
|
||||
"error", err,
|
||||
)
|
||||
continue
|
||||
}
|
||||
out.Releases = append(out.Releases, release)
|
||||
@@ -303,8 +308,9 @@ func closeBody(res *resty.Response) {
|
||||
// response status.
|
||||
func fetchURI(ft *FindATender, uri string) (*resty.Response, error) {
|
||||
withTrace := ft.config.Logging.LogLevel() <= slog.LevelDebug
|
||||
logger := ft.logger()
|
||||
|
||||
slog.Info("findTender.resty", "request_uri", uri)
|
||||
logger.Info("fetching", "uri", uri)
|
||||
|
||||
ctx := ft.ctx
|
||||
if ctx == nil {
|
||||
@@ -316,23 +322,31 @@ func fetchURI(ft *FindATender, uri string) (*resty.Response, error) {
|
||||
res, err := req.SetTrace(withTrace).Get(uri)
|
||||
|
||||
if withTrace {
|
||||
if logging.IsJSONHandler(slog.Default().Handler()) {
|
||||
slog.Debug(
|
||||
"Resty",
|
||||
"trace", res.Request.TraceInfo().JSON(),
|
||||
"status", res.Status(),
|
||||
"response", res,
|
||||
)
|
||||
} else {
|
||||
slog.Debug("Resty " + res.Request.TraceInfo().String())
|
||||
slog.Debug("Resty status: " + res.Status())
|
||||
slog.Debug("Resty response: " + res.String())
|
||||
}
|
||||
logTrace(logger, res)
|
||||
}
|
||||
|
||||
return res, err
|
||||
}
|
||||
|
||||
// logTrace logs res's trace, status and body at debug level. On a terminal
|
||||
// the trace and body are printed as they are, as a field would escape them
|
||||
// into one line; elsewhere they are fields, the trace as JSON for the JSON
|
||||
// (file) logger.
|
||||
func logTrace(logger *slog.Logger, res *resty.Response) {
|
||||
if logging.WritesToTerminal(logger.Handler()) {
|
||||
logger.Debug("fetched", "status", res.Status())
|
||||
logging.PrintToTerminal(res.Request.TraceInfo().String())
|
||||
logging.PrintToTerminal(res.String())
|
||||
return
|
||||
}
|
||||
|
||||
trace := res.Request.TraceInfo().String()
|
||||
if logging.IsJSONHandler(logger.Handler()) {
|
||||
trace = res.Request.TraceInfo().JSON()
|
||||
}
|
||||
logger.Debug("fetched", "trace", trace, "status", res.Status(), "response", res.String())
|
||||
}
|
||||
|
||||
// fetchURI performs a fetch against the API. If the API returns with a rate limit
|
||||
// reached response and allowRateLimit is true, then wait and retry like a good
|
||||
// client. If allowRateLimit is false, return to the caller informing them of
|
||||
@@ -365,10 +379,9 @@ func (ft *FindATender) fetchURI(
|
||||
return nil, fmt.Errorf("unknown retry-after value: %w", err)
|
||||
}
|
||||
|
||||
slog.Warn(fmt.Sprintf(
|
||||
"API rate limit hit, waiting as requested for %d seconds", retrySec,
|
||||
))
|
||||
if rateSleep(ctx, time.Duration(retrySec)*time.Second) != nil {
|
||||
retryAfter := time.Duration(retrySec) * time.Second
|
||||
ft.logger().Warn("api rate limit hit, waiting as asked", "retry_after", retryAfter)
|
||||
if rateSleep(ctx, retryAfter) != nil {
|
||||
return nil, ErrFetchCancelled
|
||||
}
|
||||
|
||||
@@ -432,7 +445,7 @@ func (ft *FindATender) fetchOnce(opts RequestOptions) ([]ocds.Release, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err := safeUnpackReleases(res)
|
||||
pkg, err := safeUnpackReleases(ft.logger(), res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -454,7 +467,7 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err := safeUnpackReleases(res)
|
||||
pkg, err := safeUnpackReleases(ft.logger(), res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -466,9 +479,7 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
||||
for {
|
||||
if pkg.Links != nil && len(pkg.Links.Next) > 0 {
|
||||
if seen[pkg.Links.Next] {
|
||||
slog.Warn(fmt.Sprintf(
|
||||
"Stopping at repeated next link: %s", pkg.Links.Next,
|
||||
))
|
||||
ft.logger().Warn("stopping at repeated next link", "link", pkg.Links.Next)
|
||||
break
|
||||
}
|
||||
seen[pkg.Links.Next] = true
|
||||
@@ -482,7 +493,7 @@ func (ft *FindATender) fetchAll(opts RequestOptions) ([]ocds.Release, error) {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
pkg, err = safeUnpackReleases(res)
|
||||
pkg, err = safeUnpackReleases(ft.logger(), res)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -312,9 +312,14 @@ func TestFetchURIDebugTrace(t *testing.T) {
|
||||
if len(releases) != 1 {
|
||||
t.Errorf("got %d releases, want 1", len(releases))
|
||||
}
|
||||
if !strings.Contains(logs.String(), "Resty") {
|
||||
if !strings.Contains(logs.String(), "msg=fetched") &&
|
||||
!strings.Contains(logs.String(), `"msg":"fetched"`) {
|
||||
t.Errorf("no trace logged: %s", logs.String())
|
||||
}
|
||||
// Every field must be loggable (a *resty.Response isn't, as JSON).
|
||||
if strings.Contains(logs.String(), "!ERROR") {
|
||||
t.Errorf("a field could not be logged: %s", logs.String())
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -387,7 +392,7 @@ func TestSafeUnpackReleases(t *testing.T) {
|
||||
{"ocid": "ocds-3", "id": "3", "date": "2026-10-08", "tag": [], "initiationType": "tender"}
|
||||
]
|
||||
}`
|
||||
pkg, err := safeUnpackReleases(responseFor(t, body))
|
||||
pkg, err := safeUnpackReleases(slog.Default(), responseFor(t, body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -424,7 +429,7 @@ func TestSafeUnpackReleasesPublishedDate(t *testing.T) {
|
||||
if tt.date != "" {
|
||||
body = `{"publishedDate":` + tt.date + `,"releases":[]}`
|
||||
}
|
||||
pkg, err := safeUnpackReleases(responseFor(t, body))
|
||||
pkg, err := safeUnpackReleases(slog.Default(), responseFor(t, body))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -448,7 +453,7 @@ func TestSafeUnpackReleasesInvalidJSON(t *testing.T) {
|
||||
"publisher array": `{"publisher": []}`,
|
||||
} {
|
||||
t.Run(name, func(t *testing.T) {
|
||||
if _, err := safeUnpackReleases(responseFor(t, body)); err == nil {
|
||||
if _, err := safeUnpackReleases(slog.Default(), responseFor(t, body)); err == nil {
|
||||
t.Error("expected a decode error")
|
||||
}
|
||||
})
|
||||
|
||||
@@ -18,21 +18,45 @@ type FindATender struct {
|
||||
config *config.JobSearchConfig
|
||||
apiRoot string
|
||||
ctx context.Context
|
||||
log *slog.Logger
|
||||
}
|
||||
|
||||
// Option configures a FindATender client.
|
||||
type Option func(*FindATender)
|
||||
|
||||
// WithLogger makes the client log to logger instead of slog.Default().
|
||||
func WithLogger(logger *slog.Logger) Option {
|
||||
return func(ft *FindATender) {
|
||||
ft.log = logger
|
||||
}
|
||||
}
|
||||
|
||||
// New returns a client for the OCDS API at apiRoot. Requests time out after
|
||||
// 60 seconds and are retried once. Call Shutdown when finished with it.
|
||||
func New(conf *config.JobSearchConfig, apiRoot string) *FindATender {
|
||||
// 60 seconds and are retried once. Call Shutdown when finished with it. It
|
||||
// logs to slog.Default() unless given WithLogger.
|
||||
func New(conf *config.JobSearchConfig, apiRoot string, opts ...Option) *FindATender {
|
||||
ft := &FindATender{
|
||||
config: conf,
|
||||
apiRoot: apiRoot,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(ft)
|
||||
}
|
||||
ft.httpClient = resty.New().
|
||||
SetTimeout(60 * time.Duration(time.Second)).
|
||||
SetRetryCount(1)
|
||||
return ft
|
||||
}
|
||||
|
||||
// logger returns the logger the client logs to, tagged with its component.
|
||||
func (ft *FindATender) logger() *slog.Logger {
|
||||
log := ft.log
|
||||
if log == nil {
|
||||
log = slog.Default()
|
||||
}
|
||||
return log.With("component", "findatender")
|
||||
}
|
||||
|
||||
// ReloadConfig replaces the configuration the client reads, such as the log
|
||||
// level. The HTTP client and API root set by New are not changed.
|
||||
func (ft *FindATender) ReloadConfig(conf *config.JobSearchConfig) {
|
||||
@@ -55,7 +79,7 @@ func (ft *FindATender) With(ctx context.Context) *FindATender {
|
||||
// logged, as there is nothing a caller could do about it.
|
||||
func (ft *FindATender) Shutdown() {
|
||||
if err := ft.httpClient.Close(); err != nil {
|
||||
slog.Warn("findatender: closing HTTP client", "error", err)
|
||||
ft.logger().Warn("closing http client failed", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user