refactor(pdfengines): embeds => attachments

This commit is contained in:
Julien Neuhart
2026-03-08 15:49:09 +01:00
parent 7af3cd1ff5
commit cf9fd7eedc
92 changed files with 5763 additions and 3082 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/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.
@@ -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 http.Header, errored bool) error {
url := c.url
if errored {
url = c.errorUrl
@@ -37,7 +44,7 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
method = c.errorMethod
}
req, err := retryablehttp.NewRequest(method, url, body)
req, err := retryablehttp.NewRequestWithContext(ctx, method, url, body)
if err != nil {
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
}
@@ -51,33 +58,53 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
// Middleware caller's headers > extra HTTP headers from the user.
contentLength, ok := headers[echo.HeaderContentLength]
if ok {
// 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.
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
bodySize, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
return fmt.Errorf("parse content length entry: %w", err)
}
for key, value := range headers {
req.Header.Set(key, value)
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)
}
@@ -85,7 +112,7 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
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 +120,18 @@ 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)
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.Warn("request to webhook with error details handled", fields...)
c.logger.WarnContext(ctx, "request to webhook with error details handled", fields...)
return nil
}
c.logger.Info("request to webhook handled", fields...)
c.logger.InfoContext(ctx, "request to webhook handled", fields...)
return nil
}

View File

@@ -15,336 +15,317 @@ import (
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
)
type sendOutputFileParams struct {
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
traceHeader string
trace string
client *client
handleError func(error)
}
func webhookMiddleware(w *Webhook) api.Middleware {
return api.Middleware{
Stack: api.MultipartStack,
Handler: func() echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
sendOutputFile := func(params sendOutputFileParams) {
outputFile, err := os.Open(params.outputPath)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("open output file: %s", err))
params.handleError(err)
return
}
defer func() {
err := outputFile.Close()
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("close output file: %s", err))
}
}()
fileHeader := make([]byte, 512)
_, err = outputFile.Read(fileHeader)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err))
params.handleError(err)
return
}
fileStat, err := outputFile.Stat()
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err))
params.handleError(err)
return
}
_, err = outputFile.Seek(0, 0)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err))
params.handleError(err)
return
}
headers := map[string]string{
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.traceHeader: params.trace,
}
_, 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)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
}
}
return func(c echo.Context) error {
webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url")
if webhookUrl == "" {
// No webhook URL, call the next middleware in the chain.
return next(c)
}
ctx := c.Get("context").(*api.Context)
cancel := c.Get("cancel").(context.CancelFunc)
// Do we have a webhook error URL in case of... error?
webhookErrorUrl := c.Request().Header.Get("Gotenberg-Webhook-Error-Url")
if webhookErrorUrl == "" {
return api.WrapError(
errors.New("empty webhook error URL"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"),
)
}
deadline, ok := ctx.Deadline()
if !ok {
return errors.New("context has no deadline")
}
// Let's check if the webhook URLs are acceptable according to our
// allowed/denied lists.
err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline)
// Parse all configurations.
cfg, err := parseWebhookConfig(c, w, deadline, webhookUrl)
if err != nil {
return fmt.Errorf("filter webhook URL: %w", err)
return err
}
err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline)
if err != nil {
return fmt.Errorf("filter webhook error URL: %w", err)
}
// Let's check the HTTP methods for calling the webhook URLs.
methodFromHeader := func(header string) (string, error) {
method := c.Request().Header.Get(header)
if method == "" {
return http.MethodPost, nil
}
method = strings.ToUpper(method)
switch method {
case http.MethodPost:
return method, nil
case http.MethodPatch:
return method, nil
case http.MethodPut:
return method, nil
}
return "", api.WrapError(
fmt.Errorf("webhook method '%s' is not '%s', '%s' or '%s'", method, http.MethodPost, http.MethodPatch, http.MethodPut),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Invalid '%s' header value: expected '%s', '%s' or '%s', but got '%s'", header, http.MethodPost, http.MethodPatch, http.MethodPut, method),
),
)
}
webhookMethod, err := methodFromHeader("Gotenberg-Webhook-Method")
if err != nil {
return fmt.Errorf("get method to use for webhook: %w", err)
}
webhookErrorMethod, err := methodFromHeader("Gotenberg-Webhook-Error-Method")
if err != nil {
return fmt.Errorf("get method to use for webhook error: %w", err)
}
// What about extra HTTP headers?
var extraHttpHeaders map[string]string
extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers")
if extraHttpHeadersJson != "" {
err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders)
if err != nil {
return api.WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Gotenberg-Webhook-Extra-Http-Headers' header value: %s", err.Error())),
)
}
}
// Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
// Set up Headers and Tracing.
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,
method: webhookMethod,
errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod,
extraHttpHeaders: extraHttpHeaders,
headers := make(http.Header)
headers.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
headers.Set(correlationIdHeader, correlationId)
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(headers))
// Initialize the Webhook Client.
webhookClient := &client{
url: cfg.URL,
method: cfg.Method,
errorUrl: cfg.ErrorURL,
errorMethod: cfg.ErrorMethod,
extraHttpHeaders: cfg.ExtraHTTPHeaders,
startTime: startTime,
client: &retryablehttp.Client{
HTTPClient: &http.Client{
Timeout: w.clientTimeout,
},
HTTPClient: &http.Client{Timeout: w.clientTimeout},
RetryMax: w.maxRetry,
RetryWaitMin: w.retryMinWait,
RetryWaitMax: w.retryMaxWait,
Logger: gotenberg.NewLeveledLogger(ctx.Log()),
Logger: gotenberg.NewLeveledLogger(ctx.Log()).WithContext(ctx),
CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff,
},
logger: ctx.Log(),
}
// This method parses an "asynchronous" error and sends a
// request to the webhook error URL with a JSON body
// containing the status and the error message.
handleError := func(err error) {
status, message := api.ParseError(err)
body := struct {
Status int `json:"status"`
Message string `json:"message"`
}{
Status: status,
Message: message,
}
b, err := json.Marshal(body)
if err != nil {
ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error()))
return
}
headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON,
traceHeader: trace,
}
err = client.send(bytes.NewReader(b), headers, true)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
handleErrFunc := func(err error) {
sendWebhookError(ctx, webhookClient, headers, err)
}
// Execute Sync Flow.
if w.enableSyncMode {
err := next(c)
if err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
handleError(api.WrapError(
errNoOutputFile,
api.NewSentinelHttpError(
http.StatusBadRequest,
"The webhook middleware can only work with multipart/form-data routes that results in output files",
),
))
return nil
}
ctx.Log().Error(err.Error())
handleError(err)
return nil
}
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
handleError(err)
return nil
}
// 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,
})
return c.NoContent(http.StatusNoContent)
return handleSyncWebhook(c, next, ctx, webhookClient, headers, cfg.ExtraHTTPHeaders, handleErrFunc)
}
if deadline, ok := ctx.Deadline(); ok {
// Create a new context derived from Background (detached from Request)
// but with the same deadline as the original context.
detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline)
// Replace the embedded context in the api.Context struct.
// The modules downstream will now use this detached context.
ctx.Context = detachedCtx
// We must wrap the cancel function.
// 1. detachedCancel() cleans up our new detached context.
// 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory.
originalCancel := cancel
cancel = func() {
detachedCancel()
originalCancel()
}
} else {
// Fallback if no deadline was set (rare, as newContext enforces it).
ctx.Context = context.Background()
}
// As a webhook URL has been given, we handle the request in a
// goroutine and return immediately.
w.asyncCount.Add(1)
go func() {
defer cancel()
defer w.asyncCount.Add(-1)
// Call the next middleware in the chain.
err := next(c)
if err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err)
handleError(api.WrapError(
errNoOutputFile,
api.NewSentinelHttpError(
http.StatusBadRequest,
"The webhook middleware can only work with multipart/form-data routes that results in output files",
),
))
return
}
// The process failed for whatever reason. Let's send the
// details to the webhook.
ctx.Log().Error(err.Error())
handleError(err)
return
}
// No error, let's get to build the output file.
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().Error(fmt.Sprintf("build output file: %s", err))
handleError(err)
return
}
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
})
}()
return api.ErrAsyncProcess
// Execute Async Flow.
return handleAsyncWebhook(c, next, w, ctx, cancel, deadline, webhookClient, headers, cfg.ExtraHTTPHeaders, handleErrFunc)
}
}
}(),
}
}
func handleSyncWebhook(c echo.Context, next echo.HandlerFunc, ctx *api.Context, client *client, headers http.Header, extraHeaders map[string]string, handleErr func(error)) error {
if err := next(c); err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
handleErr(api.WrapError(
fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err),
api.NewSentinelHttpError(http.StatusBadRequest, "The webhook middleware can only work with multipart/form-data routes that results in output files"),
))
return nil
}
ctx.Log().ErrorContext(ctx, err.Error())
handleErr(err)
return nil
}
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().ErrorContext(ctx, fmt.Sprintf("build output file: %s", err))
handleErr(err)
return nil
}
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHeaders,
headers: headers,
client: client,
handleError: handleErr,
})
return c.NoContent(http.StatusNoContent)
}
func handleAsyncWebhook(c echo.Context, next echo.HandlerFunc, w *Webhook, ctx *api.Context, cancel context.CancelFunc, deadline time.Time, client *client, headers http.Header, extraHeaders map[string]string, handleErr func(error)) error {
// Detach context for async processing.
detachedCtx := context.WithoutCancel(ctx.Context)
detachedCtx, detachedCancel := context.WithDeadline(detachedCtx, deadline)
ctx.Context = detachedCtx
originalCancel := cancel
cancel = func() {
detachedCancel()
originalCancel()
}
w.asyncCount.Add(1)
go func() {
defer cancel()
defer w.asyncCount.Add(-1)
if err := next(c); err != nil {
if errors.Is(err, api.ErrNoOutputFile) {
handleErr(api.WrapError(
fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err),
api.NewSentinelHttpError(http.StatusBadRequest, "The webhook middleware can only work with multipart/form-data routes that results in output files"),
))
return
}
ctx.Log().ErrorContext(ctx, err.Error())
handleErr(err)
return
}
outputPath, err := ctx.BuildOutputFile()
if err != nil {
ctx.Log().ErrorContext(ctx, fmt.Sprintf("build output file: %s", err))
handleErr(err)
return
}
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHeaders,
headers: headers,
client: client,
handleError: handleErr,
})
}()
return api.ErrAsyncProcess
}
type webhookConfig struct {
URL string
ErrorURL string
Method string
ErrorMethod string
ExtraHTTPHeaders map[string]string
}
func parseWebhookConfig(c echo.Context, w *Webhook, deadline time.Time, webhookUrl string) (*webhookConfig, error) {
errorUrl := c.Request().Header.Get("Gotenberg-Webhook-Error-Url")
if errorUrl == "" {
return nil, api.WrapError(
errors.New("empty webhook error URL"),
api.NewSentinelHttpError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"),
)
}
if err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline); err != nil {
return nil, fmt.Errorf("filter webhook URL: %w", err)
}
if err := gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, errorUrl, deadline); err != nil {
return nil, fmt.Errorf("filter webhook error URL: %w", err)
}
method, err := methodFromHeader(c, "Gotenberg-Webhook-Method")
if err != nil {
return nil, fmt.Errorf("get method to use for webhook: %w", err)
}
errorMethod, err := methodFromHeader(c, "Gotenberg-Webhook-Error-Method")
if err != nil {
return nil, fmt.Errorf("get method to use for webhook error: %w", err)
}
var extraHeaders map[string]string
if extraJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers"); extraJson != "" {
if err := json.Unmarshal([]byte(extraJson), &extraHeaders); err != nil {
return nil, api.WrapError(
fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Gotenberg-Webhook-Extra-Http-Headers' header value: %s", err.Error())),
)
}
}
return &webhookConfig{
URL: webhookUrl,
ErrorURL: errorUrl,
Method: method,
ErrorMethod: errorMethod,
ExtraHTTPHeaders: extraHeaders,
}, nil
}
func methodFromHeader(c echo.Context, header string) (string, error) {
method := c.Request().Header.Get(header)
if method == "" {
return http.MethodPost, nil
}
method = strings.ToUpper(method)
switch method {
case http.MethodPost, http.MethodPatch, http.MethodPut:
return method, nil
}
return "", api.WrapError(
fmt.Errorf("webhook method '%s' is not '%s', '%s' or '%s'", method, http.MethodPost, http.MethodPatch, http.MethodPut),
api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid '%s' header value: expected '%s', '%s' or '%s', but got '%s'", header, http.MethodPost, http.MethodPatch, http.MethodPut, method)),
)
}
func sendWebhookError(ctx *api.Context, c *client, headers http.Header, processErr error) {
status, message := api.ParseError(processErr)
body := struct {
Status int `json:"status"`
Message string `json:"message"`
}{
Status: status,
Message: message,
}
b, err := json.Marshal(body)
if err != nil {
ctx.Log().ErrorContext(ctx, fmt.Sprintf("marshal JSON: %s", err.Error()))
return
}
headers.Set(echo.HeaderContentLength, strconv.Itoa(len(b)))
if err := c.send(ctx, bytes.NewReader(b), headers, true); err != nil {
ctx.Log().ErrorContext(ctx, fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
}
type sendOutputFileParams struct {
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
headers http.Header
client *client
handleError func(error)
}
func sendOutputFile(params sendOutputFileParams) {
outputFile, err := os.Open(params.outputPath)
if err != nil {
params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("open output file: %s", err))
params.handleError(err)
return
}
defer func() {
if err := outputFile.Close(); err != nil {
params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("close output file: %s", err))
}
}()
fileHeader := make([]byte, 512)
_, err = outputFile.Read(fileHeader)
if err != nil {
params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("read header of output file: %s", err))
params.handleError(err)
return
}
fileStat, err := outputFile.Stat()
if err != nil {
params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("get stat from output file: %s", err))
params.handleError(err)
return
}
_, err = outputFile.Seek(0, 0)
if err != nil {
params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("reset output file reader: %s", err))
params.handleError(err)
return
}
params.headers.Set(echo.HeaderContentType, http.DetectContentType(fileHeader))
params.headers.Set(echo.HeaderContentLength, strconv.FormatInt(fileStat.Size(), 10))
_, ok := params.extraHttpHeaders[echo.HeaderContentDisposition]
if !ok {
params.headers.Set(echo.HeaderContentDisposition, fmt.Sprintf("attachments; filename=%q", params.ctx.OutputFilename(params.outputPath)))
}
err = params.client.send(params.ctx, bufio.NewReader(outputFile), params.headers, false)
if err != nil {
params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
}
}