diff --git a/pkg/modules/chromium/attrs_test.go b/pkg/modules/chromium/attrs_test.go new file mode 100644 index 00000000..3ab9edb5 --- /dev/null +++ b/pkg/modules/chromium/attrs_test.go @@ -0,0 +1,55 @@ +package chromium + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/modules/api" +) + +func TestConversionInputAttrs(t *testing.T) { + tmp := filepath.Join(t.TempDir(), "index.html") + content := []byte("") + if err := os.WriteFile(tmp, content, 0o600); err != nil { + t.Fatalf("write temp file: %v", err) + } + + t.Run("file URL with non-api context", func(t *testing.T) { + got := map[string]int64{} + for _, kv := range conversionInputAttrs(context.Background(), "file://"+tmp) { + got[string(kv.Key)] = kv.Value.AsInt64() + } + + if _, ok := got["gotenberg.conversion.input.files.count"]; ok { + t.Error("did not expect files.count for a non-api context") + } + if got["gotenberg.conversion.input.html.bytes"] != int64(len(content)) { + t.Errorf("expected html.bytes=%d, got %d", len(content), got["gotenberg.conversion.input.html.bytes"]) + } + }) + + t.Run("remote URL yields no html.bytes", func(t *testing.T) { + for _, kv := range conversionInputAttrs(context.Background(), "https://example.com") { + if string(kv.Key) == "gotenberg.conversion.input.html.bytes" { + t.Error("did not expect html.bytes for a remote URL") + } + } + }) + + t.Run("api context yields files.count", func(t *testing.T) { + var found bool + for _, kv := range conversionInputAttrs(&api.Context{}, "https://example.com") { + if string(kv.Key) == "gotenberg.conversion.input.files.count" { + found = true + if kv.Value.AsInt64() != 0 { + t.Errorf("expected files.count=0, got %d", kv.Value.AsInt64()) + } + } + } + if !found { + t.Error("expected files.count for an api context") + } + }) +} diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 468f45ae..d8ee2162 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -802,12 +802,17 @@ func (mod *Chromium) Routes() ([]api.Route, error) { // //nolint:dupl func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error { + // Read input attributes before Start rebinds ctx to the span context, which + // would shadow the underlying [api.Context]. + inputAttrs := conversionInputAttrs(ctx, url) + ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf", trace.WithSpanKind(trace.SpanKindClient), trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)), ) defer span.End() + span.SetAttributes(inputAttrs...) span.SetAttributes( attribute.Int64("gotenberg.queue.depth_at_arrival", mod.supervisor.ReqQueueSize()), attribute.Int64("gotenberg.conversions_since_last_restart", mod.supervisor.ConversionsSinceRestart()), @@ -863,6 +868,7 @@ func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPa if err == nil { if fileInfo, statErr := os.Stat(outputPath); statErr == nil { mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size()) + span.SetAttributes(attribute.Int64("gotenberg.conversion.output.bytes", fileInfo.Size())) } span.SetStatus(codes.Ok, "") @@ -950,6 +956,26 @@ func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, o return err } +// conversionInputAttrs derives low-cardinality input attributes for a +// conversion span: the number of received files (when ctx is an [api.Context]) +// and the size of the local HTML input (when url is a file:// URL). Remote URL +// conversions yield no html.bytes. +func conversionInputAttrs(ctx context.Context, url string) []attribute.KeyValue { + var attrs []attribute.KeyValue + + if apiCtx, ok := ctx.(*api.Context); ok { + attrs = append(attrs, attribute.Int("gotenberg.conversion.input.files.count", apiCtx.FileCount())) + } + + if strings.HasPrefix(url, "file://") { + if info, err := os.Stat(strings.TrimPrefix(url, "file://")); err == nil { + attrs = append(attrs, attribute.Int64("gotenberg.conversion.input.html.bytes", info.Size())) + } + } + + return attrs +} + // chromiumErrorType maps a conversion error to chromium's bounded reason value, // reused as the span error.type. queueReason preserves the historical, // route-specific label for a saturated queue: Pdf collapses it into