mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-08-15 11:52:14 +01:00
feat(otel): add OpenTelemetry support
This commit is contained in:
@@ -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/attribute"
|
||||
"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,9 +47,15 @@ type Api struct {
|
||||
autoStart bool
|
||||
args libreOfficeArguments
|
||||
|
||||
logger *zap.Logger
|
||||
logger *slog.Logger
|
||||
libreOffice libreOffice
|
||||
supervisor gotenberg.ProcessSupervisor
|
||||
|
||||
reqsCounter metric.Int64Counter
|
||||
errsCounter metric.Int64Counter
|
||||
conversionDurationCounter metric.Float64Histogram
|
||||
queueWaitDurationCounter metric.Float64Histogram
|
||||
pdfOutputSizeCounter metric.Int64Histogram
|
||||
}
|
||||
|
||||
// Options gathers available options when converting a document to PDF.
|
||||
@@ -216,7 +224,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
|
||||
}
|
||||
|
||||
@@ -270,20 +278,108 @@ 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)
|
||||
|
||||
// Metrics.
|
||||
meter := gotenberg.Meter()
|
||||
|
||||
// Observable gauges.
|
||||
var err error
|
||||
|
||||
_, err = meter.Int64ObservableGauge(
|
||||
"libreoffice.requests.active",
|
||||
metric.WithDescription("Current number of active LibreOffice requests"),
|
||||
metric.WithUnit("{request}"),
|
||||
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
|
||||
o.Observe(a.supervisor.ActiveTasksCount())
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.requests.active gauge: %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 {
|
||||
o.Observe(a.supervisor.ReqQueueSize())
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.requests.queue_size gauge: %w", err)
|
||||
}
|
||||
|
||||
_, 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 {
|
||||
o.Observe(a.supervisor.RestartsCount())
|
||||
return nil
|
||||
}),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.process.restarts.total counter: %w", err)
|
||||
}
|
||||
|
||||
// Counters.
|
||||
a.reqsCounter, err = meter.Int64Counter(
|
||||
"libreoffice.requests.total",
|
||||
metric.WithDescription("Total number of LibreOffice conversion requests"),
|
||||
metric.WithUnit("{request}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.requests.total counter: %w", err)
|
||||
}
|
||||
|
||||
a.errsCounter, err = meter.Int64Counter(
|
||||
"libreoffice.errors.total",
|
||||
metric.WithDescription("Total number of LibreOffice conversion errors"),
|
||||
metric.WithUnit("{error}"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.errors.total counter: %w", err)
|
||||
}
|
||||
|
||||
// Histograms.
|
||||
durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60)
|
||||
|
||||
a.conversionDurationCounter, err = meter.Float64Histogram(
|
||||
"libreoffice.conversion.duration",
|
||||
metric.WithDescription("Duration of LibreOffice conversions"),
|
||||
metric.WithUnit("s"),
|
||||
durationBuckets,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.conversion.duration histogram: %w", err)
|
||||
}
|
||||
|
||||
a.queueWaitDurationCounter, err = meter.Float64Histogram(
|
||||
"libreoffice.queue.wait.duration",
|
||||
metric.WithDescription("Duration of waiting in queue for LibreOffice conversions"),
|
||||
metric.WithUnit("s"),
|
||||
durationBuckets,
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.queue.wait.duration histogram: %w", err)
|
||||
}
|
||||
|
||||
a.pdfOutputSizeCounter, err = meter.Int64Histogram(
|
||||
"libreoffice.pdf.output.size",
|
||||
metric.WithDescription("Size of PDF output from LibreOffice conversions"),
|
||||
metric.WithUnit("By"),
|
||||
)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -332,7 +428,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()
|
||||
|
||||
@@ -431,18 +527,64 @@ 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 {
|
||||
start := time.Now()
|
||||
var conversionStart time.Time
|
||||
|
||||
err := a.supervisor.Run(ctx, logger, func() error {
|
||||
conversionStart = time.Now()
|
||||
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
|
||||
})
|
||||
|
||||
// Determine status and error reason.
|
||||
status := "success"
|
||||
reason := ""
|
||||
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, context.DeadlineExceeded):
|
||||
status = "timeout"
|
||||
reason = "timeout"
|
||||
case errors.Is(err, context.Canceled):
|
||||
status = "error"
|
||||
reason = "context_cancelled"
|
||||
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
|
||||
status = "error"
|
||||
reason = "libreoffice_unavailable"
|
||||
default:
|
||||
status = "error"
|
||||
reason = "unknown"
|
||||
}
|
||||
}
|
||||
|
||||
// Record metrics.
|
||||
attrs := metric.WithAttributes(attribute.String("status", status))
|
||||
a.reqsCounter.Add(ctx, 1, attrs)
|
||||
|
||||
if reason != "" {
|
||||
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
|
||||
}
|
||||
|
||||
if !conversionStart.IsZero() {
|
||||
queueWait := conversionStart.Sub(start).Seconds()
|
||||
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
|
||||
|
||||
conversionDuration := time.Since(conversionStart).Seconds()
|
||||
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
stat, statErr := os.Stat(outputPath)
|
||||
if statErr == nil {
|
||||
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// See https://github.com/gotenberg/gotenberg/issues/639.
|
||||
if errors.Is(err, ErrCoreDumped) {
|
||||
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
|
||||
return a.Pdf(ctx, logger, inputPath, outputPath, options)
|
||||
}
|
||||
|
||||
|
||||
@@ -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()))
|
||||
}
|
||||
}()
|
||||
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"net"
|
||||
"os"
|
||||
"strings"
|
||||
@@ -11,14 +12,12 @@ import (
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
|
||||
"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 +47,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 +85,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(context.Background(), "got exit code 81, e.g., LibreOffice first start")
|
||||
|
||||
// Second start (daemon).
|
||||
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
|
||||
@@ -123,7 +122,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(context.Background(), fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
|
||||
}
|
||||
|
||||
break
|
||||
@@ -148,19 +147,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(context.Background(), "waiting for the LibreOffice socket to be available...")
|
||||
|
||||
for {
|
||||
select {
|
||||
@@ -169,7 +168,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(context.Background(), "LibreOffice socket available")
|
||||
success = true
|
||||
|
||||
return nil
|
||||
@@ -179,7 +178,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 +191,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 +220,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 +236,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 +245,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 +258,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")
|
||||
}
|
||||
|
||||
@@ -362,7 +360,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
|
||||
return fmt.Errorf("create uno command: %w", err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", options))
|
||||
|
||||
exitCode, err := cmd.Exec()
|
||||
if err == nil {
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user