diff --git a/build/Dockerfile b/build/Dockerfile index cfe82a8..8076217 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -190,6 +190,19 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu +# Capture backing-binary versions at build time so the running process reports +# them on traces without spawning the binaries at startup or per request. +# See pkg/gotenberg/buildversions.go. Chromium and LibreOffice are captured in +# the variant stages below, where they are installed. +ENV GOTENBERG_VERSIONS_DIR_PATH=/opt/gotenberg/versions + +COPY --link build/capture-version.sh /opt/gotenberg/capture-version.sh + +RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" qpdf "$QPDF_BIN_PATH" --version \ + && bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" exiftool "$EXIFTOOL_BIN_PATH" -ver \ + && bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdftk "$PDFTK_BIN_PATH" --version \ + && bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" pdfcpu "$PDFCPU_BIN_PATH" version + # OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled). ENV OTEL_TRACES_EXPORTER=none ENV OTEL_METRICS_EXPORTER=none @@ -269,6 +282,10 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter +# Capture Chromium and LibreOffice versions now that both are installed. +RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version \ + && bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version + USER gotenberg WORKDIR /home/gotenberg @@ -329,6 +346,9 @@ ENV CHROMIUM_HYPHEN_DATA_DIR_PATH=/opt/gotenberg/chromium-hyphen-data # No LibreOffice in this variant; override the default to use all available engines. ENV PDFENGINES_CONVERT_ENGINES= +# Capture the Chromium version now that it is installed. +RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" chromium "$CHROMIUM_BIN_PATH" --version + USER gotenberg WORKDIR /home/gotenberg @@ -383,6 +403,9 @@ COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverte ENV LIBREOFFICE_BIN_PATH=/usr/lib/libreoffice/program/soffice.bin ENV UNOCONVERTER_BIN_PATH=/usr/bin/unoconverter +# Capture the LibreOffice version now that it is installed. +RUN bash /opt/gotenberg/capture-version.sh "$GOTENBERG_VERSIONS_DIR_PATH" libreoffice-api "$LIBREOFFICE_BIN_PATH" --version + USER gotenberg WORKDIR /home/gotenberg diff --git a/build/capture-version.sh b/build/capture-version.sh new file mode 100644 index 0000000..9322f45 --- /dev/null +++ b/build/capture-version.sh @@ -0,0 +1,34 @@ +#!/usr/bin/env bash +# Captures the version of a backing binary into a per-module file that the +# running Gotenberg process reads via gotenberg.BuildVersion, so it never spawns +# the binary just to report a version. This keeps cold start and the first +# request cheap, which matters on serverless platforms. +# +# Failure-tolerant by design: a probe that errors writes an empty file, and the +# runtime falls back to detecting the version live. A failing probe must never +# fail the image build. +# +# Usage: capture-version.sh [args...] +set -u + +dir="$1" +id="$2" +shift 2 + +mkdir -p "$dir" + +# Run the probe once. On failure, keep going with empty output. +raw="$("$@" 2>/dev/null)" || raw="" + +case "$id" in +pdfcpu) + # pdfcpu prints "pdfcpu: "; keep only the part the runtime parser + # keeps so the recorded value matches the live-detection fallback. + version="$(printf '%s\n' "$raw" | grep -m1 '^pdfcpu:' | sed 's/^pdfcpu:[[:space:]]*//')" + ;; +*) + version="$(printf '%s\n' "$raw" | head -n1)" + ;; +esac + +printf '%s' "$version" | tr -d '\r' >"$dir/$id" diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 676c74f..d178610 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -194,9 +194,6 @@ func Run() { if parsedFlags.MustBool("gotenberg-build-debug-data") { // Build the debug data. gotenberg.BuildDebug(ctx) - - // Surface engine versions per trace once modules have reported them. - gotenberg.EmitStartupSpan(context.Background()) } quit := make(chan os.Signal, 1) diff --git a/pkg/gotenberg/buildversions.go b/pkg/gotenberg/buildversions.go new file mode 100644 index 0000000..672759d --- /dev/null +++ b/pkg/gotenberg/buildversions.go @@ -0,0 +1,50 @@ +package gotenberg + +import ( + "os" + "path/filepath" + "strings" +) + +// BuildVersionsDirPathEnvVar names the environment variable holding the +// absolute path to a directory of build-time version files. The Gotenberg image +// writes one file per module there, named by module ID and holding the version +// string of that module's backing binary, captured right after the binary is +// installed. The running process reads these files instead of executing the +// binaries, which keeps startup and the first request cheap. +const BuildVersionsDirPathEnvVar = "GOTENBERG_VERSIONS_DIR_PATH" + +// BuildVersion returns the build-time version captured for the module with the +// given ID. The boolean is false when no version was captured, which is the +// case for local or non-Docker builds where the directory is absent. A module +// uses it to avoid spawning its backing binary just to report a version. +// +// It is defensive: an unset variable, a missing or unreadable file, or an empty +// value all yield ("", false), so the caller falls back to detecting the +// version at runtime. See [BuildVersionsDirPathEnvVar]. +func BuildVersion(moduleID string) (string, bool) { + dir := os.Getenv(BuildVersionsDirPathEnvVar) + if dir == "" { + return "", false + } + + // Module IDs are fixed internal constants, never paths. Guard anyway so a + // stray separator can't escape the versions directory. + if moduleID != filepath.Base(moduleID) { + return "", false + } + + // The directory comes from a trusted operator-set environment variable, + // mirroring how engines exec their env-configured binaries. + b, err := os.ReadFile(filepath.Join(dir, moduleID)) //nolint:gosec + if err != nil { + return "", false + } + + version := strings.TrimSpace(string(b)) + if version == "" { + return "", false + } + + return version, true +} diff --git a/pkg/gotenberg/buildversions_test.go b/pkg/gotenberg/buildversions_test.go new file mode 100644 index 0000000..6a9ed0a --- /dev/null +++ b/pkg/gotenberg/buildversions_test.go @@ -0,0 +1,81 @@ +package gotenberg + +import ( + "os" + "path/filepath" + "testing" +) + +func TestBuildVersion(t *testing.T) { + for _, tc := range []struct { + scenario string + fileBody string + writeFile bool + setEnv bool + moduleID string + wantValue string + wantOk bool + }{ + { + scenario: "version present", + fileBody: "Chromium 146.0", + writeFile: true, + setEnv: true, + moduleID: "chromium", + wantValue: "Chromium 146.0", + wantOk: true, + }, + { + scenario: "value trimmed", + fileBody: " qpdf version 11.9.0 \n", + writeFile: true, + setEnv: true, + moduleID: "qpdf", + wantValue: "qpdf version 11.9.0", + wantOk: true, + }, + { + scenario: "empty file falls back", + fileBody: " \n", + writeFile: true, + setEnv: true, + moduleID: "pdftk", + wantValue: "", + wantOk: false, + }, + { + scenario: "missing file falls back", + writeFile: false, + setEnv: true, + moduleID: "exiftool", + wantValue: "", + wantOk: false, + }, + { + scenario: "env unset falls back", + setEnv: false, + moduleID: "chromium", + wantValue: "", + wantOk: false, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + if tc.setEnv { + dir := t.TempDir() + if tc.writeFile { + if err := os.WriteFile(filepath.Join(dir, tc.moduleID), []byte(tc.fileBody), 0o600); err != nil { + t.Fatalf("write version file: %v", err) + } + } + t.Setenv(BuildVersionsDirPathEnvVar, dir) + } else { + t.Setenv(BuildVersionsDirPathEnvVar, "") + } + + value, ok := BuildVersion(tc.moduleID) + if value != tc.wantValue || ok != tc.wantOk { + t.Errorf("BuildVersion(%q) = (%q, %t), want (%q, %t)", tc.moduleID, value, ok, tc.wantValue, tc.wantOk) + } + }) + } +} diff --git a/pkg/gotenberg/startup_test.go b/pkg/gotenberg/startup_test.go deleted file mode 100644 index 02df71f..0000000 --- a/pkg/gotenberg/startup_test.go +++ /dev/null @@ -1,59 +0,0 @@ -package gotenberg - -import ( - "context" - "testing" -) - -func TestDebugModuleVersion(t *testing.T) { - info := DebugInfo{ - ModulesAdditionalData: map[string]map[string]any{ - "chromium": {"version": "Chromium 145.0"}, - "broken": {"version": 42}, - }, - } - - if got := debugModuleVersion(info, "chromium"); got != "Chromium 145.0" { - t.Errorf("expected chromium version, got %q", got) - } - if got := debugModuleVersion(info, "missing"); got != "" { - t.Errorf("expected empty for a missing module, got %q", got) - } - if got := debugModuleVersion(info, "broken"); got != "" { - t.Errorf("expected empty for a non-string version, got %q", got) - } -} - -func TestEmitStartupSpan(t *testing.T) { - recorder := newTestSpanRecorder(t) - - debugMu.Lock() - previous := debug - debug = &DebugInfo{ - Version: "v8.0.0", - ModulesAdditionalData: map[string]map[string]any{ - "chromium": {"version": "Chromium 145.0"}, - "libreoffice-api": {"version": "LibreOffice 24.8"}, - }, - } - debugMu.Unlock() - t.Cleanup(func() { - debugMu.Lock() - debug = previous - debugMu.Unlock() - }) - - EmitStartupSpan(context.Background()) - - span := findSpan(recorder, "gotenberg.startup") - if span == nil { - t.Fatal("expected a gotenberg.startup span to be recorded") - } - - if v, ok := spanAttr(span, "gotenberg.chromium.version"); !ok || v.AsString() != "Chromium 145.0" { - t.Errorf("expected gotenberg.chromium.version=Chromium 145.0, got %q (present=%t)", v.AsString(), ok) - } - if v, ok := spanAttr(span, "gotenberg.libreoffice.version"); !ok || v.AsString() != "LibreOffice 24.8" { - t.Errorf("expected gotenberg.libreoffice.version=LibreOffice 24.8, got %q (present=%t)", v.AsString(), ok) - } -} diff --git a/pkg/gotenberg/telemetry.go b/pkg/gotenberg/telemetry.go index 37399f7..44e3856 100644 --- a/pkg/gotenberg/telemetry.go +++ b/pkg/gotenberg/telemetry.go @@ -9,7 +9,6 @@ import ( "github.com/hashicorp/go-retryablehttp" "go.opentelemetry.io/otel" - "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/metric" "go.opentelemetry.io/otel/trace" @@ -205,46 +204,6 @@ func Meter() metric.Meter { ) } -// EmitStartupSpan records a single gotenberg.startup span carrying static, -// process-wide attributes that are only known once modules are provisioned, -// such as the chromium and libreoffice binary versions gathered by -// [BuildDebug]. It surfaces version data per trace without re-detecting it on -// every conversion. The engine versions live here, on a span, rather than on -// the resource because the resource is built before modules report them. -func EmitStartupSpan(ctx context.Context) { - info := Debug() - - var attrs []attribute.KeyValue - if v := debugModuleVersion(info, "chromium"); v != "" { - attrs = append(attrs, attribute.String("gotenberg.chromium.version", v)) - } - if v := debugModuleVersion(info, "libreoffice-api"); v != "" { - attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v)) - } - - _, span := Tracer().Start(ctx, "gotenberg.startup", - trace.WithSpanKind(trace.SpanKindInternal), - trace.WithAttributes(attrs...), - ) - span.End() -} - -// debugModuleVersion returns the "version" entry reported by the module with -// the given ID, or an empty string when it is missing. -func debugModuleVersion(info DebugInfo, moduleID string) string { - data, ok := info.ModulesAdditionalData[moduleID] - if !ok { - return "" - } - - version, ok := data["version"].(string) - if !ok { - return "" - } - - return version -} - // LeveledLogger is a wrapper around a [slog.Logger] so that it may be used by a // [retryablehttp.Client]. type LeveledLogger struct { diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index b228ed4..7aaeb80 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "strings" + "sync" "syscall" "time" @@ -101,6 +102,9 @@ type Chromium struct { supervisor gotenberg.ProcessSupervisor engine gotenberg.PdfEngine + version string + versionOnce sync.Once + reqsCounter metric.Int64Counter errsCounter metric.Int64Counter conversionDurationCounter metric.Float64Histogram @@ -717,19 +721,46 @@ func (mod *Chromium) Stop(ctx context.Context) error { // Debug returns additional debug data. func (mod *Chromium) Debug() map[string]any { - debug := make(map[string]any) + return map[string]any{"version": mod.detectVersion()} +} - cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +// detectVersion resolves the Chromium version once, preferring the value +// captured at image build time so it never spawns Chromium at runtime. It falls +// back to running chromium --version for local or non-Docker builds. +func (mod *Chromium) detectVersion() string { + mod.versionOnce.Do(func() { + if v, ok := gotenberg.BuildVersion("chromium"); ok { + mod.version = v + return + } - output, err := cmd.Output() - if err != nil { - debug["version"] = err.Error() - return debug + cmd := exec.Command(mod.args.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + mod.version = err.Error() + return + } + + mod.version = strings.TrimSpace(string(output)) + }) + + return mod.version +} + +// spanAttrs returns the client-span attributes for a Chromium invocation: the +// server address and the Chromium version, plus any extra attributes. The +// version rides on every conversion span so a trace records which Chromium +// rendered the document. +func (mod *Chromium) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 2+len(extra)) + attrs = append(attrs, semconv.ServerAddress(mod.args.binPath)) + if v := mod.detectVersion(); v != "" { + attrs = append(attrs, attribute.String("gotenberg.chromium.version", v)) } - debug["version"] = strings.TrimSpace(string(output)) - return debug + return append(attrs, extra...) } // Metrics returns the metrics. @@ -828,7 +859,7 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)), + trace.WithAttributes(mod.spanAttrs()...), ) defer span.End() @@ -909,7 +940,7 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)), + trace.WithAttributes(mod.spanAttrs()...), ) defer span.End() diff --git a/pkg/modules/chromium/version_test.go b/pkg/modules/chromium/version_test.go new file mode 100644 index 0000000..ee572c3 --- /dev/null +++ b/pkg/modules/chromium/version_test.go @@ -0,0 +1,37 @@ +package chromium + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +func TestChromiumDetectVersion(t *testing.T) { + t.Run("prefers the build-time version without executing Chromium", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "chromium"), []byte("Chromium 146.0.7680.80\n"), 0o600); err != nil { + t.Fatalf("write version file: %v", err) + } + t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir) + + // A bogus binPath would error if executed, so a correct result proves + // the build-time file is used instead of running Chromium. + mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}} + if got := mod.Debug()["version"]; got != "Chromium 146.0.7680.80" { + t.Errorf("Debug()[version] = %v, want the build-time value", got) + } + }) + + t.Run("falls back to executing Chromium when no build-time version", func(t *testing.T) { + t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "") + + // With no build-time file and a bogus binPath, the exec fallback runs + // and records its error rather than a build-time value. + mod := &Chromium{args: browserArguments{binPath: "/nonexistent/chromium"}} + if got := mod.Debug()["version"]; got == "Chromium 146.0.7680.80" { + t.Errorf("Debug()[version] = %v, expected the exec fallback", got) + } + }) +} diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index e639291..b4b0470 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -10,8 +10,10 @@ import ( "os/exec" "regexp" "strings" + "sync" "syscall" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "go.opentelemetry.io/otel/trace" @@ -159,6 +161,9 @@ func buildExifToolWriteArgs(metadata map[string]any) ([]string, error) { // [gotenberg.PdfEngine] interface. type ExifTool struct { binPath string + + version string + versionOnce sync.Once } // Descriptor returns [ExifTool]'s module descriptor. @@ -193,26 +198,53 @@ func (engine *ExifTool) Validate() error { // Debug returns additional debug data. func (engine *ExifTool) Debug() map[string]any { - debug := make(map[string]any) + return map[string]any{"version": engine.detectVersion()} +} - cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +// detectVersion resolves the ExifTool version once, preferring the value +// captured at image build time so it never spawns ExifTool at runtime. It falls +// back to running exiftool -ver for local or non-Docker builds. +func (engine *ExifTool) detectVersion() string { + engine.versionOnce.Do(func() { + if v, ok := gotenberg.BuildVersion("exiftool"); ok { + engine.version = v + return + } - output, err := cmd.Output() - if err != nil { - debug["version"] = err.Error() - return debug + cmd := exec.Command(engine.binPath, "-ver") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + engine.version = err.Error() + return + } + + engine.version = strings.TrimSpace(string(output)) + }) + + return engine.version +} + +// spanAttrs returns the client-span attributes for an ExifTool invocation: the +// server address and the ExifTool version, plus any extra attributes. The +// version rides on every span so a trace records which ExifTool ran the +// operation. +func (engine *ExifTool) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 2+len(extra)) + attrs = append(attrs, semconv.ServerAddress(engine.binPath)) + if v := engine.detectVersion(); v != "" { + attrs = append(attrs, attribute.String("gotenberg.exiftool.version", v)) } - debug["version"] = strings.TrimSpace(string(output)) - return debug + return append(attrs, extra...) } // Merge is not available in this implementation. func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -226,7 +258,7 @@ func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPat func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Split", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -240,7 +272,7 @@ func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode got func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -254,7 +286,7 @@ func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputP func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -269,7 +301,7 @@ func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, format func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { _, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -326,7 +358,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, i func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -371,7 +403,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { _, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -424,7 +456,7 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inpu func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -438,7 +470,7 @@ func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { _, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -452,7 +484,7 @@ func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -466,7 +498,7 @@ func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputP func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -480,7 +512,7 @@ func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, fil func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -494,7 +526,7 @@ func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inpu func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -508,7 +540,7 @@ func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPat func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { _, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index a1d5a3d..ea73ed1 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "strings" + "sync" "syscall" "time" @@ -53,6 +54,9 @@ type Api struct { libreOffice libreOffice supervisor gotenberg.ProcessSupervisor + version string + versionOnce sync.Once + reqsCounter metric.Int64Counter errsCounter metric.Int64Counter conversionDurationCounter metric.Float64Histogram @@ -540,19 +544,46 @@ func (a *Api) Stop(ctx context.Context) error { // Debug returns additional debug data. func (a *Api) Debug() map[string]any { - debug := make(map[string]any) + return map[string]any{"version": a.detectVersion()} +} - cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +// detectVersion resolves the LibreOffice version once, preferring the value +// captured at image build time so it never spawns LibreOffice at runtime. It +// falls back to running soffice --version for local or non-Docker builds. +func (a *Api) detectVersion() string { + a.versionOnce.Do(func() { + if v, ok := gotenberg.BuildVersion("libreoffice-api"); ok { + a.version = v + return + } - output, err := cmd.Output() - if err != nil { - debug["version"] = err.Error() - return debug + cmd := exec.Command(a.args.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + a.version = err.Error() + return + } + + a.version = strings.TrimSpace(string(output)) + }) + + return a.version +} + +// spanAttrs returns the client-span attributes for a LibreOffice invocation: +// the server address and the LibreOffice version, plus any extra attributes. +// The version rides on every conversion span so a trace records which +// LibreOffice rendered the document. +func (a *Api) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 2+len(extra)) + attrs = append(attrs, semconv.ServerAddress(a.args.binPath)) + if v := a.detectVersion(); v != "" { + attrs = append(attrs, attribute.String("gotenberg.libreoffice.version", v)) } - debug["version"] = strings.TrimSpace(string(output)) - return debug + return append(attrs, extra...) } // Metrics returns the metrics. @@ -628,7 +659,7 @@ func (a *Api) LibreOffice() (Uno, 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", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(a.args.binPath)), + trace.WithAttributes(a.spanAttrs()...), ) defer span.End() diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 68e27ae..503f778 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -13,8 +13,10 @@ import ( "sort" "strconv" "strings" + "sync" "syscall" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "go.opentelemetry.io/otel/trace" @@ -30,6 +32,9 @@ func init() { // [gotenberg.PdfEngine] interface. type PdfCpu struct { binPath string + + version string + versionOnce sync.Once } type pdfcpuBookmark struct { @@ -74,35 +79,60 @@ func (engine *PdfCpu) Validate() error { // Debug returns additional debug data. func (engine *PdfCpu) Debug() map[string]any { - debug := make(map[string]any) + return map[string]any{"version": engine.detectVersion()} +} - cmd := exec.Command(engine.binPath, "version") //nolint:gosec - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} - - output, err := cmd.Output() - if err != nil { - debug["version"] = err.Error() - return debug - } - - debug["version"] = "Unable to determine pdfcpu version" - - lines := strings.SplitSeq(string(output), "\n") - for line := range lines { - if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok { - debug["version"] = strings.TrimSpace(after) - break +// detectVersion resolves the pdfcpu version once, preferring the value captured +// at image build time so it never spawns pdfcpu at runtime. It falls back to +// running pdfcpu version for local or non-Docker builds. +func (engine *PdfCpu) detectVersion() string { + engine.versionOnce.Do(func() { + if v, ok := gotenberg.BuildVersion("pdfcpu"); ok { + engine.version = v + return } + + cmd := exec.Command(engine.binPath, "version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + engine.version = err.Error() + return + } + + engine.version = "Unable to determine pdfcpu version" + + lines := strings.SplitSeq(string(output), "\n") + for line := range lines { + if after, ok := strings.CutPrefix(line, "pdfcpu:"); ok { + engine.version = strings.TrimSpace(after) + break + } + } + }) + + return engine.version +} + +// spanAttrs returns the client-span attributes for a pdfcpu invocation: the +// server address and the pdfcpu version, plus any extra attributes. The version +// rides on every span so a trace records which pdfcpu ran the operation. +func (engine *PdfCpu) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 2+len(extra)) + attrs = append(attrs, semconv.ServerAddress(engine.binPath)) + if v := engine.detectVersion(); v != "" { + attrs = append(attrs, attribute.String("gotenberg.pdfcpu.version", v)) } - return debug + return append(attrs, extra...) } // Merge combines multiple PDFs into a single PDF. func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -134,7 +164,7 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -203,7 +233,7 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode goten func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -217,7 +247,7 @@ func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPat func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -231,7 +261,7 @@ func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { _, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -245,7 +275,7 @@ func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inp func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -259,7 +289,7 @@ func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, me func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { _, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -273,7 +303,7 @@ func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputP func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -376,7 +406,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, in func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -467,7 +497,7 @@ func (engine *PdfCpu) ReadPdfAConformance(ctx context.Context, logger *slog.Logg func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -506,7 +536,7 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, fileP func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -561,7 +591,7 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPat func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -580,7 +610,7 @@ func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputP func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -599,7 +629,7 @@ func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 244351d..694f498 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -9,8 +9,10 @@ import ( "os" "os/exec" "path/filepath" + "sync" "syscall" + "go.opentelemetry.io/otel/attribute" "go.opentelemetry.io/otel/codes" semconv "go.opentelemetry.io/otel/semconv/v1.40.0" "go.opentelemetry.io/otel/trace" @@ -26,6 +28,9 @@ func init() { // interface. type PdfTk struct { binPath string + + version string + versionOnce sync.Once } // Descriptor returns a [PdfTk]'s module descriptor. @@ -60,32 +65,58 @@ func (engine *PdfTk) Validate() error { // Debug returns additional debug data. func (engine *PdfTk) Debug() map[string]any { - debug := make(map[string]any) + return map[string]any{"version": engine.detectVersion()} +} - cmd := exec.Command(engine.binPath, "--version") //nolint:gosec - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +// detectVersion resolves the PDFtk version once, preferring the value captured +// at image build time so it never spawns the PDFtk JVM at runtime. It falls +// back to running pdftk --version for local or non-Docker builds. +func (engine *PdfTk) detectVersion() string { + engine.versionOnce.Do(func() { + if v, ok := gotenberg.BuildVersion("pdftk"); ok { + engine.version = v + return + } - output, err := cmd.Output() - if err != nil { - debug["version"] = err.Error() - return debug + cmd := exec.Command(engine.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + engine.version = err.Error() + return + } + + lines := bytes.SplitN(output, []byte("\n"), 2) + if len(lines) > 0 { + engine.version = string(lines[0]) + return + } + + engine.version = "Unable to determine PDFtk version" + }) + + return engine.version +} + +// spanAttrs returns the client-span attributes for a PDFtk invocation: the +// server address and the PDFtk version, plus any extra attributes. The version +// rides on every span so a trace records which PDFtk ran the operation. +func (engine *PdfTk) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 2+len(extra)) + attrs = append(attrs, semconv.ServerAddress(engine.binPath)) + if v := engine.detectVersion(); v != "" { + attrs = append(attrs, attribute.String("gotenberg.pdftk.version", v)) } - lines := bytes.SplitN(output, []byte("\n"), 2) - if len(lines) > 0 { - debug["version"] = string(lines[0]) - } else { - debug["version"] = "Unable to determine PDFtk version" - } - - return debug + return append(attrs, extra...) } // Split splits a given PDF file. func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -132,7 +163,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenb func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -164,7 +195,7 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -178,7 +209,7 @@ func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -192,7 +223,7 @@ func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats g func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { _, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -206,7 +237,7 @@ func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inpu func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -220,7 +251,7 @@ func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, met func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { _, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -234,7 +265,7 @@ func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPa func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { _, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -248,7 +279,7 @@ func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, in func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { _, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -262,7 +293,7 @@ func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inp func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -322,7 +353,7 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -339,7 +370,7 @@ func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePa func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -389,7 +420,7 @@ func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPa func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -438,7 +469,7 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath s func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index a02bfd3..2229664 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -14,6 +14,7 @@ import ( "path/filepath" "regexp" "strings" + "sync" "syscall" "go.opentelemetry.io/otel/attribute" @@ -33,6 +34,9 @@ func init() { type QPdf struct { binPath string globalArgs []string + + version string + versionOnce sync.Once } // Descriptor returns a [QPdf]'s module descriptor. @@ -69,32 +73,58 @@ func (engine *QPdf) Validate() error { // Debug returns additional debug data. func (engine *QPdf) Debug() map[string]any { - debug := make(map[string]any) + return map[string]any{"version": engine.detectVersion()} +} - cmd := exec.Command(engine.binPath, "--version") //nolint:gosec - cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} +// detectVersion resolves the qpdf version once, preferring the value captured +// at image build time so it never spawns qpdf at runtime. It falls back to +// running qpdf --version for local or non-Docker builds. +func (engine *QPdf) detectVersion() string { + engine.versionOnce.Do(func() { + if v, ok := gotenberg.BuildVersion("qpdf"); ok { + engine.version = v + return + } - output, err := cmd.Output() - if err != nil { - debug["version"] = err.Error() - return debug + cmd := exec.Command(engine.binPath, "--version") //nolint:gosec + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + + output, err := cmd.Output() + if err != nil { + engine.version = err.Error() + return + } + + lines := bytes.SplitN(output, []byte("\n"), 2) + if len(lines) > 0 { + engine.version = string(lines[0]) + return + } + + engine.version = "Unable to determine QPDF version" + }) + + return engine.version +} + +// spanAttrs returns the client-span attributes for a qpdf invocation: the +// server address and the qpdf version, plus any extra attributes. The version +// rides on every span so a trace records which qpdf ran the operation. +func (engine *QPdf) spanAttrs(extra ...attribute.KeyValue) []attribute.KeyValue { + attrs := make([]attribute.KeyValue, 0, 2+len(extra)) + attrs = append(attrs, semconv.ServerAddress(engine.binPath)) + if v := engine.detectVersion(); v != "" { + attrs = append(attrs, attribute.String("gotenberg.qpdf.version", v)) } - lines := bytes.SplitN(output, []byte("\n"), 2) - if len(lines) > 0 { - debug["version"] = string(lines[0]) - } else { - debug["version"] = "Unable to determine QPDF version" - } - - return debug + return append(attrs, extra...) } // Split splits a given PDF file. func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -144,7 +174,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenbe func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -180,7 +210,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths [ func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -215,7 +245,7 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -229,7 +259,7 @@ func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats go func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { _, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -243,7 +273,7 @@ func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, input func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -257,7 +287,7 @@ func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, meta func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { _, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -271,7 +301,7 @@ func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPat func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -285,7 +315,7 @@ func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inp func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { _, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -329,7 +359,7 @@ func qpdfPermissionArgs(p gotenberg.PdfPermissions) []string { func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath string, opts gotenberg.EncryptOptions) error { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -378,7 +408,7 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -395,7 +425,7 @@ func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePat func (engine *QPdf) EmbedFilesMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]map[string]string, inputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFilesMetadata", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -692,11 +722,10 @@ const facturXNamespaceURI = "urn:factur-x:pdfa:CrossIndustryDocument:invoice:1p0 func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, facturX gotenberg.FacturX, inputPath string) error { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.InjectFacturXXMP", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes( - semconv.ServerAddress(engine.binPath), + trace.WithAttributes(engine.spanAttrs( attribute.String("gotenberg.facturx.conformance_level", facturX.ConformanceLevel), attribute.String("gotenberg.facturx.document_type", facturX.DocumentType), - ), + )...), ) defer span.End() @@ -776,7 +805,7 @@ func (engine *QPdf) InjectFacturXXMP(ctx context.Context, logger *slog.Logger, f func (engine *QPdf) ReadPdfAConformance(ctx context.Context, logger *slog.Logger, inputPath string) (string, string, error) { ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadPdfAConformance", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -1066,7 +1095,7 @@ func xmlEscape(s string) string { func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -1080,7 +1109,7 @@ func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPat func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() @@ -1094,7 +1123,7 @@ func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath st func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { _, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate", trace.WithSpanKind(trace.SpanKindClient), - trace.WithAttributes(semconv.ServerAddress(engine.binPath)), + trace.WithAttributes(engine.spanAttrs()...), ) defer span.End() diff --git a/pkg/modules/qpdf/version_test.go b/pkg/modules/qpdf/version_test.go new file mode 100644 index 0000000..050e34c --- /dev/null +++ b/pkg/modules/qpdf/version_test.go @@ -0,0 +1,58 @@ +package qpdf + +import ( + "os" + "path/filepath" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +func TestQPdfDetectVersion(t *testing.T) { + t.Run("prefers the build-time version without executing qpdf", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 11.9.0\n"), 0o600); err != nil { + t.Fatalf("write version file: %v", err) + } + t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir) + + // A bogus binPath would error if executed, so a correct result proves + // the build-time file is used instead of running qpdf. + engine := &QPdf{binPath: "/nonexistent/qpdf"} + if got := engine.Debug()["version"]; got != "qpdf version 11.9.0" { + t.Errorf("Debug()[version] = %v, want the build-time value", got) + } + }) + + t.Run("falls back to executing qpdf when no build-time version", func(t *testing.T) { + t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, "") + + // With no build-time file and a bogus binPath, the exec fallback runs + // and records its error rather than a build-time value. + engine := &QPdf{binPath: "/nonexistent/qpdf"} + if got := engine.Debug()["version"]; got == "qpdf version 11.9.0" { + t.Errorf("Debug()[version] = %v, expected the exec fallback", got) + } + }) + + t.Run("exposes the version as a span attribute", func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, "qpdf"), []byte("qpdf version 12.2.0"), 0o600); err != nil { + t.Fatalf("write version file: %v", err) + } + t.Setenv(gotenberg.BuildVersionsDirPathEnvVar, dir) + + // spanAttrs is what gets handed to trace.WithAttributes, so its output + // is exactly what a span carries. + engine := &QPdf{binPath: "/nonexistent/qpdf"} + var got string + for _, attr := range engine.spanAttrs() { + if attr.Key == "gotenberg.qpdf.version" { + got = attr.Value.AsString() + } + } + if got != "qpdf version 12.2.0" { + t.Errorf("span attribute gotenberg.qpdf.version = %q, want the build-time value", got) + } + }) +}