mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-14 08:13:18 +01:00
feat(telemetry): record backing-binary versions on spans, captured at build time
This commit is contained in:
50
pkg/gotenberg/buildversions.go
Normal file
50
pkg/gotenberg/buildversions.go
Normal file
@@ -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
|
||||
}
|
||||
81
pkg/gotenberg/buildversions_test.go
Normal file
81
pkg/gotenberg/buildversions_test.go
Normal file
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user