feat(webhook): add events

This commit is contained in:
Julien Neuhart
2026-03-28 19:00:07 +01:00
parent 043b1588de
commit 385cbe6590
26 changed files with 224 additions and 5 deletions

View File

@@ -2,6 +2,7 @@ package webhook
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
@@ -26,6 +27,7 @@ type client struct {
method string
errorUrl string
errorMethod string
eventsUrl string
extraHttpHeaders map[string]string
startTime time.Time
@@ -144,3 +146,66 @@ func (c client) send(ctx context.Context, body io.Reader, headers map[string]str
return nil
}
// sendEvent sends a structured JSON event to the events URL. It is
// fire-and-forget: failures are logged but do not propagate.
func (c client) sendEvent(ctx context.Context, correlationIdHeader, correlationId string, event map[string]any) {
if c.eventsUrl == "" {
return
}
b, err := json.Marshal(event)
if err != nil {
c.logger.ErrorContext(ctx, fmt.Sprintf("marshal webhook event: %s", err))
return
}
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "POST Webhook Event",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(c.eventsUrl)),
)
defer span.End()
req, err := retryablehttp.NewRequest(http.MethodPost, c.eventsUrl, b)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
c.logger.ErrorContext(ctx, fmt.Sprintf("create webhook event request: %s", err))
return
}
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
req.Header.Set("User-Agent", "Gotenberg")
for key, value := range c.extraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(correlationIdHeader, correlationId)
resp, err := c.client.Do(req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': %s", c.eventsUrl, err))
return
}
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", c.eventsUrl, err))
}
}()
if resp.StatusCode >= http.StatusBadRequest {
span.RecordError(fmt.Errorf("webhook event: got status '%s'", resp.Status))
span.SetStatus(codes.Error, resp.Status)
c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': got status '%s'", c.eventsUrl, resp.Status))
return
}
span.SetStatus(codes.Ok, "")
c.logger.InfoContext(ctx, fmt.Sprintf("webhook event sent to '%s'", c.eventsUrl))
}

View File

@@ -85,7 +85,14 @@ func webhookMiddleware(w *Webhook) api.Middleware {
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
return
}
params.client.sendEvent(params.ctx, params.correlationIdHeader, params.correlationId, map[string]any{
"event": "webhook.success",
"correlationId": params.correlationId,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
})
}
return func(c echo.Context) error {
@@ -176,6 +183,15 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
// What about the events URL?
webhookEventsUrl := c.Request().Header.Get("Gotenberg-Webhook-Events-Url")
if webhookEventsUrl != "" {
err = gotenberg.FilterDeadline(w.allowList, w.denyList, webhookEventsUrl, deadline)
if err != nil {
return fmt.Errorf("filter webhook events URL: %w", err)
}
}
// Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
@@ -187,6 +203,7 @@ func webhookMiddleware(w *Webhook) api.Middleware {
method: webhookMethod,
errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod,
eventsUrl: webhookEventsUrl,
extraHttpHeaders: extraHttpHeaders,
startTime: startTime,
@@ -233,6 +250,16 @@ func webhookMiddleware(w *Webhook) api.Middleware {
if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
client.sendEvent(ctx, correlationIdHeader, correlationId, map[string]any{
"event": "webhook.error",
"correlationId": correlationId,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"error": map[string]any{
"status": status,
"message": message,
},
})
}
if w.enableSyncMode {