The webhook handler's client had no timeout, and slog calls the handler inside the log call, so a server that accepted the connection and never answered stopped that log call for good, and every later one. A request still running after 5 seconds, reading the answer included, now fails with a timeout error. The handler also reads the answer to the end before closing it, so the connection is reused for the next record. Two new tests point the handler at a server that never answers and at one that sends its status and then stalls the answer, and check that Handle returns a timeout error within the timeout. The README states the timeout. Model: opus-5-5
134 lines
3.6 KiB
Go
134 lines
3.6 KiB
Go
package simplelog
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"log/slog"
|
|
"net/http"
|
|
"net/url"
|
|
"time"
|
|
)
|
|
|
|
// errWebhookStatus is returned when the webhook answers with a status
|
|
// outside 2xx.
|
|
var errWebhookStatus = errors.New("webhook did not accept the record")
|
|
|
|
// webhookTimeout bounds each webhook request, reading the answer
|
|
// included. Handle runs inside the log call, so a server that never
|
|
// answers would otherwise hold that call up for good.
|
|
const webhookTimeout = 5 * time.Second
|
|
|
|
// WebhookHandler POSTs each log record as JSON to a configured webhook
|
|
// URL.
|
|
type WebhookHandler struct {
|
|
webhookURL string
|
|
client *http.Client
|
|
attrs handlerAttrs
|
|
}
|
|
|
|
// NewWebhookHandler returns a WebhookHandler that delivers records to
|
|
// the given URL, validating the URL first.
|
|
func NewWebhookHandler(webhookURL string) (*WebhookHandler, error) {
|
|
_, err := url.ParseRequestURI(webhookURL)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("invalid webhook URL: %w", err)
|
|
}
|
|
|
|
return &WebhookHandler{
|
|
webhookURL: webhookURL,
|
|
client: &http.Client{
|
|
Timeout: webhookTimeout,
|
|
// Following a redirect can resend the request as a GET
|
|
// without the record, so Handle gets the redirect answer
|
|
// itself and returns it as an error.
|
|
CheckRedirect: func(*http.Request, []*http.Request) error {
|
|
return http.ErrUseLastResponse
|
|
},
|
|
},
|
|
}, nil
|
|
}
|
|
|
|
// Enabled reports whether the handler processes records at the given
|
|
// level; it always returns true.
|
|
func (w *WebhookHandler) Enabled(_ context.Context, _ slog.Level) bool {
|
|
return true
|
|
}
|
|
|
|
// WithAttrs returns a new handler that also emits attrs, qualified by the
|
|
// groups open now. The receiver is not modified.
|
|
func (w *WebhookHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
|
if len(attrs) == 0 {
|
|
return w
|
|
}
|
|
|
|
return &WebhookHandler{
|
|
webhookURL: w.webhookURL,
|
|
client: w.client,
|
|
attrs: w.attrs.withAttrs(attrs),
|
|
}
|
|
}
|
|
|
|
// WithGroup returns a new handler that nests later attributes in an
|
|
// object named after the group. An empty name returns the receiver.
|
|
func (w *WebhookHandler) WithGroup(name string) slog.Handler {
|
|
if name == "" {
|
|
return w
|
|
}
|
|
|
|
return &WebhookHandler{
|
|
webhookURL: w.webhookURL,
|
|
client: w.client,
|
|
attrs: w.attrs.withGroup(name),
|
|
}
|
|
}
|
|
|
|
// Handle marshals the record, with its attributes, to one JSON object and
|
|
// POSTs it to the webhook URL. It returns an error when the request fails
|
|
// or runs past webhookTimeout, or when the server answers with a status
|
|
// outside 2xx.
|
|
func (w *WebhookHandler) Handle(ctx context.Context, record slog.Record) error {
|
|
jsonData, err := json.Marshal(recordToMap(record, w.attrs))
|
|
if err != nil {
|
|
return fmt.Errorf("error marshaling event: %w", err)
|
|
}
|
|
|
|
request, err := http.NewRequestWithContext(
|
|
ctx,
|
|
http.MethodPost,
|
|
w.webhookURL,
|
|
bytes.NewReader(jsonData),
|
|
)
|
|
if err != nil {
|
|
return fmt.Errorf("error creating webhook request: %w", err)
|
|
}
|
|
|
|
request.Header.Set("Content-Type", "application/json")
|
|
|
|
response, err := w.client.Do(request)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
defer func() { _ = response.Body.Close() }()
|
|
|
|
// The answer is read to the end so the client can reuse the
|
|
// connection for the next record. The read counts toward
|
|
// webhookTimeout, so a server that sends its status and then stalls
|
|
// fails here.
|
|
_, err = io.Copy(io.Discard, response.Body)
|
|
if err != nil {
|
|
return fmt.Errorf("error reading webhook answer: %w", err)
|
|
}
|
|
|
|
if response.StatusCode < http.StatusOK ||
|
|
response.StatusCode >= http.StatusMultipleChoices {
|
|
return fmt.Errorf("%w: %s", errWebhookStatus, response.Status)
|
|
}
|
|
|
|
return nil
|
|
}
|