feat(otel): add OpenTelemetry support

This commit is contained in:
Julien Neuhart
2026-03-27 16:28:45 +01:00
parent 08088c15f4
commit 4e9f63004d
86 changed files with 4396 additions and 1283 deletions

View File

@@ -1,15 +1,22 @@
package webhook
import (
"context"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// client gathers all the data required to send a request to a webhook.
@@ -22,11 +29,11 @@ type client struct {
startTime time.Time
client *retryablehttp.Client
logger *zap.Logger
logger *slog.Logger
}
// send call the webhook either to send the success response or the error response.
func (c client) send(body io.Reader, headers map[string]string, errored bool) error {
func (c client) send(ctx context.Context, body io.Reader, headers map[string]string, errored bool) error {
url := c.url
if errored {
url = c.errorUrl
@@ -37,11 +44,25 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
method = c.errorMethod
}
spanName := fmt.Sprintf("%s Webhook", method)
if errored {
spanName = fmt.Sprintf("%s Webhook Error", method)
}
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindClient))
defer span.End()
req, err := retryablehttp.NewRequest(method, url, body)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
}
// Inject trace context into outbound request headers.
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
req.Header.Set("User-Agent", "Gotenberg")
// Extra HTTP headers are the custom headers from the user.
@@ -63,6 +84,8 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
bodySize, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("parse content length entry: %w", err)
}
@@ -75,17 +98,22 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
resp, err := c.client.Do(req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
}
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
err := fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", url, err))
c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", url, err))
}
}()
@@ -93,20 +121,22 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
finishTime := time.Now()
// Now let's log!
fields := make([]zap.Field, 5)
fields[0] = zap.String("webhook_url", url)
fields[1] = zap.String("method", method)
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
fields[4] = zap.Int64("bytes_out", req.ContentLength)
attrs := []any{
slog.String("webhook_url", url),
slog.String("method", method),
slog.Int64("latency", int64(finishTime.Sub(c.startTime))),
slog.String("latency_human", finishTime.Sub(c.startTime).String()),
slog.Int64("bytes_out", req.ContentLength),
}
if errored {
c.logger.Warn("request to webhook with error details handled", fields...)
c.logger.WarnContext(ctx, "request to webhook with error details handled", attrs...)
span.SetStatus(codes.Ok, "")
return nil
}
c.logger.Info("request to webhook handled", fields...)
c.logger.InfoContext(ctx, "request to webhook handled", attrs...)
span.SetStatus(codes.Ok, "")
return nil
}

View File

@@ -21,13 +21,13 @@ import (
)
type sendOutputFileParams struct {
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
traceHeader string
trace string
client *client
handleError func(error)
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
correlationIdHeader string
correlationId string
client *client
handleError func(error)
}
func webhookMiddleware(w *Webhook) api.Middleware {
@@ -72,16 +72,16 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
headers := map[string]string{
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.traceHeader: params.trace,
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.correlationIdHeader: params.correlationId,
}
_, ok := params.extraHttpHeaders[echo.HeaderContentDisposition]
if !ok {
headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath))
}
err = params.client.send(bufio.NewReader(outputFile), headers, false)
err = params.client.send(params.ctx, bufio.NewReader(outputFile), headers, false)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
@@ -179,8 +179,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
correlationIdHeader := c.Get("correlationIdHeader").(string)
correlationId := c.Get("correlationId").(string)
client := &client{
url: webhookUrl,
@@ -226,10 +226,10 @@ func webhookMiddleware(w *Webhook) api.Middleware {
headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON,
traceHeader: trace,
correlationIdHeader: correlationId,
}
err = client.send(bytes.NewReader(b), headers, true)
err = client.send(ctx, bytes.NewReader(b), headers, true)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
@@ -262,13 +262,13 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
// No error, let's send the output file to the webhook URL.
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
correlationIdHeader: correlationIdHeader,
correlationId: correlationId,
client: client,
handleError: handleError,
})
return c.NoContent(http.StatusNoContent)
}
@@ -332,13 +332,13 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
correlationIdHeader: correlationIdHeader,
correlationId: correlationId,
client: client,
handleError: handleError,
})
}()