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 } // The answer is read to the end before closing, so the client can // reuse the connection for the next record. defer func() { _, _ = io.Copy(io.Discard, response.Body) _ = response.Body.Close() }() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { return fmt.Errorf("%w: %s", errWebhookStatus, response.Status) } return nil }