diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 1b00bf5e..69315210 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -192,6 +192,9 @@ 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/startup_test.go b/pkg/gotenberg/startup_test.go new file mode 100644 index 00000000..02df71f7 --- /dev/null +++ b/pkg/gotenberg/startup_test.go @@ -0,0 +1,59 @@ +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 b9b98430..d009b660 100644 --- a/pkg/gotenberg/telemetry.go +++ b/pkg/gotenberg/telemetry.go @@ -9,6 +9,7 @@ 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" @@ -188,6 +189,46 @@ 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 {