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

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
@@ -12,8 +13,9 @@ import (
"github.com/alexliesenfeld/health"
flag "github.com/spf13/pflag"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -45,7 +47,7 @@ type Api struct {
autoStart bool
args libreOfficeArguments
logger *zap.Logger
logger *slog.Logger
libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor
}
@@ -187,7 +189,7 @@ func DefaultOptions() Options {
// Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
Extensions() []string
}
@@ -241,20 +243,42 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger.Named("libreoffice")
a.logger = gotenberg.Logger(a).With(slog.String("logger", "libreoffice"))
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
// OpenTelemetry.
meter := gotenberg.Meter()
_, err := meter.Int64ObservableCounter(
"libreoffice.process.restarts.total",
metric.WithDescription("Current number of LibreOffice restarts."),
metric.WithUnit("{restart}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := a.supervisor.RestartsCount()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create process restarts observable counter: %w", err)
}
_, err = meter.Int64ObservableGauge(
"libreoffice.requests.queue_size",
metric.WithDescription("Current number of LibreOffice conversion requests waiting to be treated."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := a.supervisor.ReqQueueSize()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create requests queue size observable gauge: %w", err)
}
return nil
}
@@ -303,7 +327,7 @@ func (a *Api) StartupMessage() string {
func (a *Api) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully
// stop before we do a shutdown.
a.logger.Debug("wait for the end of grace duration")
a.logger.DebugContext(ctx, "wait for the end of grace duration")
<-ctx.Done()
@@ -332,26 +356,6 @@ func (a *Api) Debug() map[string]any {
return debug
}
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "libreoffice_requests_queue_size",
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
Read: func() float64 {
return float64(a.supervisor.ReqQueueSize())
},
},
{
Name: "libreoffice_restarts_count",
Description: "Current number of LibreOffice restarts.",
Read: func() float64 {
return float64(a.supervisor.RestartsCount())
},
},
}, nil
}
// Checks adds a health check that verifies if LibreOffice is healthy.
func (a *Api) Checks() ([]health.CheckerOption, error) {
return []health.CheckerOption{
@@ -402,7 +406,10 @@ func (a *Api) LibreOffice() (Uno, error) {
}
// Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
ctx, span := gotenberg.Tracer().Start(ctx, "LibreOffice.Pdf")
defer span.End()
err := a.supervisor.Run(ctx, logger, func() error {
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
@@ -413,11 +420,20 @@ func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
err = a.Pdf(ctx, logger, inputPath, outputPath, options)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
}
return fmt.Errorf("supervisor run task: %w", err)
err = fmt.Errorf("supervisor run task: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Extensions returns the file extensions available for conversions.
@@ -559,13 +575,12 @@ func (a *Api) Extensions() []string {
// Interface guards.
var (
_ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
_ gotenberg.Debuggable = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)
_ Provider = (*Api)(nil)
_ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil)
_ gotenberg.Debuggable = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil)
_ Uno = (*Api)(nil)
_ Provider = (*Api)(nil)
)

View File

@@ -1,14 +1,14 @@
package api
import (
"context"
"fmt"
"log/slog"
"net"
"strconv"
"go.uber.org/zap"
)
func freePort(logger *zap.Logger) (int, error) {
func freePort(logger *slog.Logger) (int, error) {
netListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("listen on the local network address: %w", err)
@@ -16,7 +16,7 @@ func freePort(logger *zap.Logger) (int, error) {
defer func() {
err := netListener.Close()
if err != nil {
logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
logger.ErrorContext(context.Background(), fmt.Sprintf("close network listener: %s", err.Error()))
}
}()

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"strings"
@@ -11,14 +12,17 @@ import (
"sync/atomic"
"time"
"go.uber.org/zap"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type libreOffice interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
}
type libreOfficeArguments struct {
@@ -48,7 +52,7 @@ func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
return p
}
func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
if p.isStarted.Load() {
return errors.New("LibreOffice is already started")
}
@@ -86,7 +90,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("execute LibreOffice: %w", err)
}
logger.Debug("got exit code 81, e.g., LibreOffice first start")
logger.DebugContext(ctx, "got exit code 81, e.g., LibreOffice first start")
// Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
@@ -123,7 +127,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
connChan <- nil
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
logger.DebugContext(ctx, fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
}
break
@@ -148,19 +152,19 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err))
logger.DebugContext(context.Background(), fmt.Sprintf("kill LibreOffice process: %v", err))
}
// And the user profile directory is deleted.
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
}
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}()
logger.Debug("waiting for the LibreOffice socket to be available...")
logger.DebugContext(ctx, "waiting for the LibreOffice socket to be available...")
for {
select {
@@ -169,7 +173,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("LibreOffice socket not available: %w", err)
}
logger.Debug("LibreOffice socket available")
logger.DebugContext(ctx, "LibreOffice socket available")
success = true
return nil
@@ -179,7 +183,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
}
}
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
func (p *libreOfficeProcess) Stop(logger *slog.Logger) error {
if !p.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
@@ -192,15 +196,15 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} else {
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}
// Also, remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil {
logger.Error(err.Error())
logger.ErrorContext(context.Background(), err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
@@ -221,7 +225,7 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
return nil
}
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
func (p *libreOfficeProcess) Healthy(logger *slog.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
@@ -237,7 +241,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
if err == nil {
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
logger.DebugContext(context.Background(), fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
}
return true
@@ -246,7 +250,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
return false
}
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
if !p.isStarted.Load() {
return errors.New("LibreOffice not started, cannot handle PDF conversion")
}
@@ -259,8 +263,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
if checkedEntry != nil {
if logger.Enabled(ctx, slog.LevelDebug) {
args = append(args, "-vvv")
}
@@ -332,14 +335,35 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...)
p.cfgMu.RLock()
clientCtx, clientSpan := gotenberg.Tracer().Start(ctx, "uno.execute",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
semconv.ServerAddress("127.0.0.1"),
semconv.ServerPort(p.socketPort),
semconv.ServicePeerName("libreoffice"),
// Legacy attribute for older APMs (Datadog, Jaeger) to draw the
// dependency graph.
attribute.String("peer.service", "libreoffice"),
),
)
p.cfgMu.RUnlock()
cmd, err := gotenberg.CommandContext(clientCtx, logger, p.arguments.unoBinPath, args...)
if err != nil {
return fmt.Errorf("create uno command: %w", err)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
logger.DebugContext(clientCtx, fmt.Sprintf("print to PDF with: %+v", options))
exitCode, err := cmd.Exec()
if err != nil {
clientSpan.RecordError(err)
clientSpan.SetStatus(codes.Error, err.Error())
}
clientSpan.End()
if err == nil {
return nil
}

View File

@@ -3,19 +3,18 @@ package api
import (
"context"
"errors"
"go.uber.org/zap"
"log/slog"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// ApiMock is a mock for the [Uno] interface.
type ApiMock struct {
PdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
PdfMock func(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
ExtensionsMock func() []string
}
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (api *ApiMock) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
return api.PdfMock(ctx, logger, inputPath, outputPath, options)
}
@@ -37,10 +36,10 @@ type libreOfficeMock struct {
errCoreDumpedCount int
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
pdfMock func(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
}
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (b *libreOfficeMock) pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
if errors.Is(err, ErrCoreDumped) {
b.errCoreDumpedCount += 1