Files
gotenberg/pkg/modules/webhook/client.go
2026-03-08 15:49:09 +01:00

138 lines
3.8 KiB
Go

package webhook
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
)
// client gathers all the data required to send a request to a webhook.
type client struct {
url string
method string
errorUrl string
errorMethod string
extraHttpHeaders map[string]string
startTime time.Time
client *retryablehttp.Client
logger *slog.Logger
}
// send call the webhook either to send the success response or the error response.
func (c client) send(ctx context.Context, body io.Reader, headers http.Header, errored bool) error {
url := c.url
if errored {
url = c.errorUrl
}
method := c.method
if errored {
method = c.errorMethod
}
req, err := retryablehttp.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
}
req.Header.Set("User-Agent", "Gotenberg")
// Extra HTTP headers are the custom headers from the user.
for key, value := range c.extraHttpHeaders {
req.Header.Set(key, value)
}
// Middleware caller's headers > extra HTTP headers from the user.
contentLength := headers.Get(echo.HeaderContentLength)
// Golang "http" package should automatically calculate the size of the
// body. But when using a buffered file reader, it does not work.
// Worse, the "Content-Length" header is also removed. Therefore,
// to keep this valuable information, we have to trust the caller
// by reading the value of the "Content-Length" entry and set it as the
// content length of the request. It's kinda suboptimal, but hey, at
// least it works.
bodySize, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return fmt.Errorf("parse content length entry: %w", err)
}
req.ContentLength = bodySize
for key := range headers {
req.Header.Set(key, headers.Get(key))
}
// OpenTelemetry.
meter := gotenberg.Meter()
semconvClient := semconvutil.NewHTTPClient(meter)
tracer := gotenberg.Tracer()
spanName := fmt.Sprintf("%s Webhook", req.Method)
if errored {
spanName = fmt.Sprintf("%s Webhook Error", req.Method)
}
ctx, span := tracer.Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...),
)
defer span.End()
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
resp, err := c.client.Do(req)
if err != nil {
span.RecordError(err)
span.SetStatus(semconvClient.Status(0))
return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
}
span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...)
span.SetStatus(semconvClient.Status(resp.StatusCode))
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
}
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", url, err))
}
}()
// Last piece for calculating the latency.
finishTime := time.Now()
// Now let's log!
fields := make([]any, 5)
fields[0] = slog.String("webhook_url", url)
fields[1] = slog.String("method", method)
fields[2] = slog.Int64("latency", int64(finishTime.Sub(c.startTime)))
fields[3] = slog.String("latency_human", finishTime.Sub(c.startTime).String())
fields[4] = slog.Int64("bytes_out", req.ContentLength)
if errored {
c.logger.WarnContext(ctx, "request to webhook with error details handled", fields...)
return nil
}
c.logger.InfoContext(ctx, "request to webhook handled", fields...)
return nil
}