diff --git a/Makefile b/Makefile index 2c1bccab..91fbf94e 100644 --- a/Makefile +++ b/Makefile @@ -20,16 +20,19 @@ API_BIND_IP= API_START_TIMEOUT=30s API_TIMEOUT=30s API_BODY_LIMIT= -API_ROOT_PATH="/" -API_TRACE_HEADER=Gotenberg-Trace +API_ROOT_PATH=/ +API_CORRELATION_ID_HEADER=Gotenberg-Trace API_ENABLE_BASIC_AUTH=false GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_PASSWORD= -API-DOWNLOAD-FROM-ALLOW-LIST= -API-DOWNLOAD-FROM-DENY-LIST= -API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 -API-DISABLE-DOWNLOAD-FROM=false -API_DISABLE_HEALTH_CHECK_LOGGING=false +API_DOWNLOAD_FROM_ALLOW_LIST= +API_DOWNLOAD_FROM_DENY_LIST= +API_DOWNLOAD_FROM_MAX_RETRY=4 +API_DISABLE_DOWNLOAD_FROM=false +API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=false +API_DISABLE_ROOT_ROUTE_TELEMETRY=false +API_DISABLE_VERSION_ROUTE_TELEMETRY=false +API_DISABLE_DEBUG_ROUTE_TELEMETRY=false API_ENABLE_DEBUG_ROUTE=false CHROMIUM_RESTART_AFTER=100 CHROMIUM_MAX_QUEUE_SIZE=0 @@ -54,25 +57,25 @@ LIBREOFFICE_AUTO_START=false LIBREOFFICE_START_TIMEOUT=20s LIBREOFFICE_DISABLE_ROUTES=false LOG_LEVEL=info -LOG_FORMAT=auto LOG_FIELDS_PREFIX= -LOG_ENABLE_GCP_FIELDS=false -PDFENGINES_DISABLE_ROUTES=false +LOG_STD_FORMAT=auto +LOG_STD_ENABLE_GCP_FIELDS=false PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk PDFENGINES_FLATTEN_ENGINES=qpdf PDFENGINES_CONVERT_ENGINES=libreoffice-pdfengine PDFENGINES_READ_METADATA_ENGINES=exiftool PDFENGINES_WRITE_METADATA_ENGINES=exiftool -PDFENGINES_READ_BOOKMARKS_ENGINES=pdfcpu -PDFENGINES_WRITE_BOOKMARKS_ENGINES=pdfcpu PDFENGINES_ENCRYPT_ENGINES=qpdf,pdfcpu,pdftk -PDFENGINES_EMBED_ENGINES=pdfcpu -PROMETHEUS_NAMESPACE=gotenberg -PROMETHEUS_COLLECT_INTERVAL=1s -PROMETHEUS_DISABLE_ROUTE_LOGGING=false -PROMETHEUS_DISABLE_COLLECT=false -PROMETHEUS_METRICS_PATH=/prometheus/metrics +PDFENGINES_DISABLE_ROUTES=false +PDFENGINES_ADD_ATTACHMENTS_ENGINES=pdfcpu +OTEL_SERVICE_NAME=gotenberg +OTEL_TRACES_EXPORTER=none +OTEL_METRICS_EXPORTER=none +OTEL_LOGS_EXPORTER=none +OTEL_EXPORTER_OTLP_PROTOCOL=grpc +OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317 +OTEL_EXPORTER_OTLP_INSECURE=true WEBHOOK_ENABLE_SYNC_MODE=false WEBHOOK_ALLOW_LIST= WEBHOOK_DENY_LIST= @@ -84,85 +87,20 @@ WEBHOOK_RETRY_MAX_WAIT=30s WEBHOOK_CLIENT_TIMEOUT=30s WEBHOOK_DISABLE=false +# Export all variables so they are available to Compose +export + .PHONY: run -run: ## Start a Gotenberg container - docker run --rm -it \ - -p $(API_PORT):$(API_PORT) \ - -e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \ - -e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ - -e TZ=$(TZ) \ - $(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \ - gotenberg \ - --gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \ - --gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ - --gotenberg-build-debug-data="$(GOTENBERG_BUILD_DEBUG_DATA)" \ - --api-port=$(API_PORT) \ - --api-port-from-env=$(API_PORT_FROM_ENV) \ - --api-bind-ip=$(API_BIND_IP) \ - --api-start-timeout=$(API_START_TIMEOUT) \ - --api-timeout=$(API_TIMEOUT) \ - --api-body-limit="$(API_BODY_LIMIT)" \ - --api-root-path=$(API_ROOT_PATH) \ - --api-trace-header=$(API_TRACE_HEADER) \ - --api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \ - --api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \ - --api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \ - --api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \ - --api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \ - --api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \ - --api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \ - --chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \ - --chromium-auto-start=$(CHROMIUM_AUTO_START) \ - --chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \ - --chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \ - --chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \ - --chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \ - --chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \ - --chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \ - --chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \ - --chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \ - --chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \ - --chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \ - --chromium-deny-list="$(CHROMIUM_DENY_LIST)" \ - --chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \ - --chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \ - --chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \ - --chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \ - --libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \ - --libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \ - --libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \ - --libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \ - --libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \ - --log-level=$(LOG_LEVEL) \ - --log-format=$(LOG_FORMAT) \ - --log-fields-prefix=$(LOG_FIELDS_PREFIX) \ - --log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \ - --pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \ - --pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \ - --pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \ - --pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \ - --pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \ - --pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \ - --pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \ - --pdfengines-read-bookmarks-engines=$(PDFENGINES_READ_BOOKMARKS_ENGINES) \ - --pdfengines-write-bookmarks-engines=$(PDFENGINES_WRITE_BOOKMARKS_ENGINES) \ - --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ - --pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \ - --prometheus-namespace=$(PROMETHEUS_NAMESPACE) \ - --prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \ - --prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \ - --prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \ - --prometheus-metrics-path=$(PROMETHEUS_METRICS_PATH) \ - --webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \ - --webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \ - --webhook-deny-list="$(WEBHOOK_DENY_LIST)" \ - --webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \ - --webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \ - --webhook-max-retry=$(WEBHOOK_MAX_RETRY) \ - --webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \ - --webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \ - --webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \ - --webhook-disable=$(WEBHOOK_DISABLE) +run: ## Start a Gotenberg container via Compose + docker compose up gotenberg + +.PHONY: telemetry +telemetry: ## Start an OpenTelemetry collector and OpenObserve containers via Compose + docker compose up otel-collector openobserve + +.PHONY: down +down: ## Stop all containers + docker compose down -v .PHONY: test-unit test-unit: ## Run unit tests @@ -183,8 +121,8 @@ NO_CONCURRENCY=false # output-filename # pdfengines # pdfengines-convert -# pdfengines-embed -# embed +# pdfengines-attachments +# attachments # pdfengines-encrypt # encrypt # pdfengines-flatten @@ -195,13 +133,11 @@ NO_CONCURRENCY=false # metadata # pdfengines-split # split -# pdfengines-bookmarks -# bookmarks -# prometheus-metrics # root # version # webhook # download-from +# telemetry TAGS= .PHONY: test-integration diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 16a9a620..7fd34d2b 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -43,6 +43,10 @@ func Run() { fs.Bool("gotenberg-hide-banner", false, "Hide the banner") fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration") fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed") + fs.String("log-level", gotenberg.InfoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", gotenberg.ErrorLoggingLevel, gotenberg.WarnLoggingLevel, gotenberg.InfoLoggingLevel, gotenberg.DebugLoggingLevel)) + fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs") + fs.String("log-std-format", gotenberg.AutoLoggingFormat, fmt.Sprintf("Specify the format of standard logging. Options include %s, %s, or %s", gotenberg.AutoLoggingFormat, gotenberg.JsonLoggingFormat, gotenberg.TextLoggingFormat)) + fs.Bool("log-std-enable-gcp-fields", false, "Enable Google Cloud Platform fields for standard logging - namely: time, message, severity") descriptors := gotenberg.GetModuleDescriptors() var modsInfo strings.Builder @@ -91,14 +95,43 @@ func Run() { hideBanner := parsedFlags.MustBool("gotenberg-hide-banner") gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration") + serviceName := os.Getenv("OTEL_SERVICE_NAME") + if serviceName == "" { + serviceName = "gotenberg" + } + + telemetryConfig := gotenberg.TelemetryConfig{ + // OpenTelemetry. + ServiceName: serviceName, + ServiceVersion: Version, + // Logging. + LogLevel: parsedFlags.MustString("log-level"), + LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"), + LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"), + LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"), + } + if !hideBanner { fmt.Printf(banner, Version) } - fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String()) + err = telemetryConfig.Validate() + if err != nil { + fmt.Printf("[FATAL] telemetry: %s\n", err) + os.Exit(1) + } + + // Telemetry. + shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryConfig) + if err != nil { + fmt.Printf("[FATAL] telemetry: %s\n", err) + os.Exit(1) + } + + // Modules. + fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String()) ctx := gotenberg.NewContext(parsedFlags, descriptors) - // Start application modules. apps, err := ctx.Modules(new(gotenberg.App)) if err != nil { fmt.Printf("[FATAL] %s\n", err) @@ -194,5 +227,12 @@ func Run() { os.Exit(1) } + err = shutdownTelemetry(gracefulShutdownCtx) + if err != nil { + fmt.Printf("[FATAL] telemetry: %v\n", err) + os.Exit(1) + } + fmt.Printf("[SYSTEM] telemetry: shutdown success\n") + os.Exit(0) } diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 00000000..fc852fff --- /dev/null +++ b/compose.yaml @@ -0,0 +1,103 @@ +services: + gotenberg: + image: ${DOCKER_REGISTRY}/${DOCKER_REPOSITORY}:${GOTENBERG_VERSION} + ports: + - "${API_PORT}:${API_PORT}" + environment: + GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_API_BASIC_AUTH_USERNAME} + GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_API_BASIC_AUTH_PASSWORD} + OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME} + OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER} + OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER} + OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER} + OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL} + OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT} + OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE} + command: + - "gotenberg" + - "--gotenberg-hide-banner=${GOTENBERG_HIDE_BANNER}" + - "--gotenberg-graceful-shutdown-duration=${GOTENBERG_GRACEFUL_SHUTDOWN_DURATION}" + - "--gotenberg-build-debug-data=${GOTENBERG_BUILD_DEBUG_DATA}" + - "--api-port=${API_PORT}" + - "--api-port-from-env=${API_PORT_FROM_ENV}" + - "--api-bind-ip=${API_BIND_IP}" + - "--api-start-timeout=${API_START_TIMEOUT}" + - "--api-timeout=${API_TIMEOUT}" + - "--api-body-limit=${API_BODY_LIMIT}" + - "--api-root-path=${API_ROOT_PATH}" + - "--api-correlation-id-header=${API_CORRELATION_ID_HEADER}" + - "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}" + - "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}" + - "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}" + - "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}" + - "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}" + - "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}" + - "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}" + - "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}" + - "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}" + - "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}" + - "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}" + - "--chromium-auto-start=${CHROMIUM_AUTO_START}" + - "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}" + - "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}" + - "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}" + - "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}" + - "--chromium-ignore-certificate-errors=${CHROMIUM_IGNORE_CERTIFICATE_ERRORS}" + - "--chromium-disable-web-security=${CHROMIUM_DISABLE_WEB_SECURITY}" + - "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}" + - "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}" + - "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}" + - "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}" + - "--chromium-deny-list=${CHROMIUM_DENY_LIST}" + - "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}" + - "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}" + - "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}" + - "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}" + - "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}" + - "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}" + - "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}" + - "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}" + - "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}" + - "--log-level=${LOG_LEVEL}" + - "--log-fields-prefix=${LOG_FIELDS_PREFIX}" + - "--log-std-format=${LOG_STD_FORMAT}" + - "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}" + - "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}" + - "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}" + - "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}" + - "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}" + - "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}" + - "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}" + - "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}" + - "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}" + - "--pdfengines-add-attachments-engines=${PDFENGINES_ADD_ATTACHMENTS_ENGINES}" + - "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}" + - "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}" + - "--webhook-deny-list=${WEBHOOK_DENY_LIST}" + - "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}" + - "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}" + - "--webhook-max-retry=${WEBHOOK_MAX_RETRY}" + - "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}" + - "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}" + - "--webhook-client-timeout=${WEBHOOK_CLIENT_TIMEOUT}" + - "--webhook-disable=${WEBHOOK_DISABLE}" + + otel-collector: + image: otel/opentelemetry-collector-contrib:latest + command: ["--config=/etc/otel-collector-config.yaml"] + volumes: + - ./otel-collector-config.yaml:/etc/otel-collector-config.yaml + ports: + - "4317:4317" # OTLP gRPC receiver + depends_on: + - openobserve + + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + restart: always + ports: + - "5080:5080" + - "5081:5081" # OTLP gRPC ingestion + environment: + ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev + ZO_ROOT_USER_PASSWORD: telemetry diff --git a/go.mod b/go.mod index b5ce8934..b55202e9 100644 --- a/go.mod +++ b/go.mod @@ -18,13 +18,21 @@ require ( github.com/labstack/gommon v0.4.2 github.com/mholt/archives v0.1.5 github.com/microcosm-cc/bluemonday v1.0.27 - github.com/prometheus/client_golang v1.23.2 github.com/shirou/gopsutil/v4 v4.26.1 github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.40.0 + go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 + go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 + go.opentelemetry.io/otel v1.42.0 + go.opentelemetry.io/otel/log v0.18.0 + go.opentelemetry.io/otel/metric v1.42.0 + go.opentelemetry.io/otel/sdk v1.42.0 + go.opentelemetry.io/otel/sdk/log v0.18.0 + go.opentelemetry.io/otel/sdk/metric v1.42.0 + go.opentelemetry.io/otel/trace v1.42.0 go.uber.org/multierr v1.11.0 - go.uber.org/zap v1.27.1 - golang.org/x/net v0.50.0 + golang.org/x/net v0.51.0 golang.org/x/sync v0.19.0 golang.org/x/term v0.40.0 golang.org/x/text v0.34.0 @@ -42,6 +50,7 @@ require ( github.com/bodgit/sevenzip v1.6.1 // indirect github.com/bodgit/windows v1.0.1 // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect + github.com/cenkalti/backoff/v5 v5.0.3 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect github.com/chromedp/sysutil v1.1.0 // indirect github.com/containerd/errdefs v1.0.0 // indirect @@ -55,7 +64,7 @@ require ( github.com/distribution/reference v0.6.0 // indirect github.com/docker/go-units v0.5.0 // indirect github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 // indirect - github.com/ebitengine/purego v0.9.1 // indirect + github.com/ebitengine/purego v0.10.0 // indirect github.com/felixge/httpsnoop v1.0.4 // indirect github.com/go-json-experiment/json v0.0.0-20260214004413-d219187c3433 // indirect github.com/go-logr/logr v1.4.3 // indirect @@ -66,6 +75,7 @@ require ( github.com/gobwas/ws v1.4.0 // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gorilla/css v1.0.1 // indirect + github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect @@ -95,13 +105,14 @@ require ( github.com/pkg/errors v0.9.1 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect + github.com/prometheus/client_golang v1.23.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // indirect - github.com/prometheus/procfs v0.19.2 // indirect + github.com/prometheus/otlptranslator v1.0.0 // indirect + github.com/prometheus/procfs v0.20.1 // indirect github.com/sirupsen/logrus v1.9.4 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/spf13/afero v1.15.0 // indirect - github.com/stretchr/testify v1.11.1 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/numcpus v0.11.0 // indirect github.com/ulikunitz/xz v0.5.15 // indirect @@ -109,17 +120,28 @@ require ( github.com/valyala/fasttemplate v1.2.2 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect + go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect - go.opentelemetry.io/otel v1.40.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.40.0 // indirect - go.opentelemetry.io/otel/trace v1.40.0 // indirect - go.opentelemetry.io/proto/otlp v1.7.1 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect + go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect + go.opentelemetry.io/proto/otlp v1.9.0 // indirect go.yaml.in/yaml/v2 v2.4.3 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.org/x/crypto v0.48.0 // indirect golang.org/x/sys v0.41.0 // indirect golang.org/x/time v0.14.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect + google.golang.org/grpc v1.79.2 // indirect google.golang.org/protobuf v1.36.11 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect ) diff --git a/go.sum b/go.sum index a7c6c333..9b438954 100644 --- a/go.sum +++ b/go.sum @@ -26,6 +26,8 @@ github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4= github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= github.com/cenkalti/backoff/v4 v4.3.0/go.mod h1:Y3VNntkOUPxTVeUxJ/G5vcM//AlwfmyYozVcomhLiZE= +github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1xcsSM= +github.com/cenkalti/backoff/v5 v5.0.3/go.mod h1:rkhZdG3JZukswDf7f0cwqPNk4K0sa+F97BxZthm/crw= github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU= @@ -70,8 +72,8 @@ github.com/docker/go-units v0.5.0/go.mod h1:fgPhTUdO+D/Jk86RDLlptpiXQzgHJF7gydDD github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707 h1:2tV76y6Q9BB+NEBasnqvs7e49aEBFI8ejC89PSnWH+4= github.com/dsnet/compress v0.0.2-0.20230904184137-39efe44ab707/go.mod h1:qssHWj60/X5sZFNxpG4HBPDHVqxNm4DfnCKgrbZOT+s= github.com/dsnet/golib v0.0.0-20171103203638-1ea166775780/go.mod h1:Lj+Z9rebOhdfkVLjJ8T6VcRQv3SXugXy999NBtR9aFY= -github.com/ebitengine/purego v0.9.1 h1:a/k2f2HQU3Pi399RPW1MOaZyhKJL9w/xFpKAg4q1s0A= -github.com/ebitengine/purego v0.9.1/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= +github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= +github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= github.com/fatih/color v1.16.0 h1:zmkK9Ngbjj+K0yRhTVONQh1p/HknKYSlNT+vZCzyokM= github.com/fatih/color v1.16.0/go.mod h1:fL2Sau1YI5c0pdGEVCbKQbLXB6edEj1ZgiY4NijnWvE= github.com/felixge/httpsnoop v1.0.4 h1:NFTV2Zj1bL4mc9sqWACXbQFVBBg2W3GPvqp8/ESS2Wg= @@ -96,6 +98,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= @@ -105,8 +109,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= -github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs= +github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= @@ -208,8 +212,10 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= -github.com/prometheus/procfs v0.19.2 h1:zUMhqEW66Ex7OXIiDkll3tl9a1ZdilUOd/F6ZXw4Vws= -github.com/prometheus/procfs v0.19.2/go.mod h1:M0aotyiemPhBCM0z5w87kL22CxfcH05ZpYlu+b4J7mw= +github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos= +github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM= +github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= +github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= @@ -257,38 +263,66 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4= +go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk= +go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8= +go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0= -go.opentelemetry.io/otel v1.40.0 h1:oA5YeOcpRTXq6NN7frwmwFR0Cn3RhTVZvXsP4duvCms= -go.opentelemetry.io/otel v1.40.0/go.mod h1:IMb+uXZUKkMXdPddhwAHm6UfOwJyh4ct1ybIlV14J0g= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= -go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0 h1:IeMeyr1aBvBiPVYihXIaeIZba6b8E1bYp7lbdxK8CQg= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.19.0/go.mod h1:oVdCUtjq9MK9BlS7TtucsQwUcXcymNiEDjgDD2jMtZU= -go.opentelemetry.io/otel/metric v1.40.0 h1:rcZe317KPftE2rstWIBitCdVp89A2HqjkxR3c11+p9g= -go.opentelemetry.io/otel/metric v1.40.0/go.mod h1:ib/crwQH7N3r5kfiBZQbwrTge743UDc7DTFVZrrXnqc= -go.opentelemetry.io/otel/sdk v1.40.0 h1:KHW/jUzgo6wsPh9At46+h4upjtccTmuZCFAc9OJ71f8= -go.opentelemetry.io/otel/sdk v1.40.0/go.mod h1:Ph7EFdYvxq72Y8Li9q8KebuYUr2KoeyHx0DRMKrYBUE= -go.opentelemetry.io/otel/sdk/metric v1.40.0 h1:mtmdVqgQkeRxHgRv4qhyJduP3fYJRMX4AtAlbuWdCYw= -go.opentelemetry.io/otel/sdk/metric v1.40.0/go.mod h1:4Z2bGMf0KSK3uRjlczMOeMhKU2rhUqdWNoKcYrtcBPg= -go.opentelemetry.io/otel/trace v1.40.0 h1:WA4etStDttCSYuhwvEa8OP8I5EWu24lkOzp+ZYblVjw= -go.opentelemetry.io/otel/trace v1.40.0/go.mod h1:zeAhriXecNGP/s2SEG3+Y8X9ujcJOTqQ5RgdEJcawiA= -go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= +go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs= +go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA= +go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o= +go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs= +go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s= +go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8= +go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw= +go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs= +go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg= +go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI= +go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= +go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= +go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= +go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= +go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw= +go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA= +go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw= +go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= +go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= +go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= +go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= +go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A= +go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= -go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= -go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos= -golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60= -golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM= +golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo= +golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= @@ -305,13 +339,14 @@ golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= -google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= -google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= -google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= -google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= -google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= +gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk= +gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4= +google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU= +google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/otel-collector-config.yaml b/otel-collector-config.yaml new file mode 100644 index 00000000..cec12edd --- /dev/null +++ b/otel-collector-config.yaml @@ -0,0 +1,32 @@ +receivers: + otlp: + protocols: + grpc: + endpoint: "0.0.0.0:4317" + +processors: + batch: + +exporters: + otlp/openobserve: + endpoint: "openobserve:5081" + tls: + insecure: true + headers: + Authorization: "Basic dGVsZW1ldHJ5QGdvdGVuYmVyZy5kZXY6dGVsZW1ldHJ5" + organization: "default" + +service: + pipelines: + traces: + receivers: [otlp] + processors: [batch] + exporters: [otlp/openobserve] + metrics: + receivers: [otlp] + processors: [batch] + exporters: [otlp/openobserve] + logs: + receivers: [otlp] + processors: [batch] + exporters: [otlp/openobserve] diff --git a/pkg/gotenberg/cmd.go b/pkg/gotenberg/cmd.go index 83dd3ba2..8a1febd6 100644 --- a/pkg/gotenberg/cmd.go +++ b/pkg/gotenberg/cmd.go @@ -6,17 +6,16 @@ import ( "errors" "fmt" "io" + "log/slog" "os/exec" "strings" "syscall" - - "go.uber.org/zap" ) // Cmd wraps an [exec.Cmd]. type Cmd struct { ctx context.Context - logger *zap.Logger + logger *slog.Logger process *exec.Cmd } @@ -25,13 +24,13 @@ type Cmd struct { // children without creating orphans. // // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. -func Command(logger *zap.Logger, binPath string, args ...string) *Cmd { +func Command(logger *slog.Logger, binPath string, args ...string) *Cmd { cmd := exec.Command(binPath, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} return &Cmd{ ctx: nil, - logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), + logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))), process: cmd, } } @@ -41,7 +40,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) *Cmd { // children without creating orphans. // // See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. -func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (*Cmd, error) { +func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, args ...string) (*Cmd, error) { if ctx == nil { return nil, errors.New("nil context") } @@ -51,7 +50,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg return &Cmd{ ctx: ctx, - logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), + logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))), process: cmd, }, nil } @@ -63,7 +62,7 @@ func (cmd *Cmd) Start() error { return fmt.Errorf("pipe unix process output: %w", err) } - cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " "))) + cmd.logger.DebugContext(cmd.ctx, fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " "))) err = cmd.process.Start() if err != nil { @@ -110,7 +109,7 @@ func (cmd *Cmd) Exec() (int, error) { case err = <-errChan: errProc := cmd.Kill() if errProc != nil { - cmd.logger.Error(errProc.Error()) + cmd.logger.ErrorContext(cmd.ctx, errProc.Error()) } if err == nil { @@ -125,7 +124,7 @@ func (cmd *Cmd) Exec() (int, error) { case <-cmd.ctx.Done(): errProc := cmd.Kill() if errProc != nil { - cmd.logger.Error(errProc.Error()) + cmd.logger.ErrorContext(cmd.ctx, errProc.Error()) } return 62, fmt.Errorf("context done: %w", cmd.ctx.Err()) @@ -135,8 +134,7 @@ func (cmd *Cmd) Exec() (int, error) { // pipeOutput creates logs entries according to the process stdout and stderr. // It does nothing if the logging level is not debug. func (cmd *Cmd) pipeOutput() error { - checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output") - if checkedEntry == nil { + if !cmd.logger.Enabled(cmd.ctx, slog.LevelDebug) { return nil } @@ -152,12 +150,12 @@ func (cmd *Cmd) pipeOutput() error { // logCommandOutput creates logs entries according to a reader // (either stdout or stderr). - logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) { + logCommandOutput := func(logger *slog.Logger, reader io.ReadCloser) { r := bufio.NewReader(reader) defer func(reader io.ReadCloser) { err := reader.Close() if err != nil && !strings.Contains(err.Error(), "file already closed") { - logger.Error(fmt.Sprintf("close reader: %s", err)) + logger.ErrorContext(cmd.ctx, fmt.Sprintf("close reader: %s", err)) } }(reader) @@ -165,20 +163,20 @@ func (cmd *Cmd) pipeOutput() error { line, _, err := r.ReadLine() if err != nil { if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { - logger.Error(fmt.Sprintf("pipe unix process output error: %s", err)) + logger.ErrorContext(cmd.ctx, fmt.Sprintf("pipe unix process output error: %s", err)) } break } if len(line) != 0 { - logger.Debug(string(line)) + logger.DebugContext(cmd.ctx, string(line)) } } } - go logCommandOutput(cmd.logger.Named("stdout"), stdout) - go logCommandOutput(cmd.logger.Named("stderr"), stderr) + go logCommandOutput(cmd.logger.With(slog.String("logger", "stdout")), stdout) + go logCommandOutput(cmd.logger.With(slog.String("logger", "stderr")), stderr) return nil } @@ -196,13 +194,13 @@ func (cmd *Cmd) Kill() error { err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL) if err == nil { - cmd.logger.Debug("unix process killed") + cmd.logger.DebugContext(cmd.ctx, "unix process killed") return nil } // If the process does not exist anymore, the error is irrelevant. if strings.Contains(err.Error(), "no such process") { - cmd.logger.Debug("unix process already killed") + cmd.logger.DebugContext(cmd.ctx, "unix process already killed") return nil } diff --git a/pkg/gotenberg/gc.go b/pkg/gotenberg/gc.go index 169dd508..1f09a4ea 100644 --- a/pkg/gotenberg/gc.go +++ b/pkg/gotenberg/gc.go @@ -1,19 +1,19 @@ package gotenberg import ( + "context" "fmt" + "log/slog" "os" "path/filepath" "strings" "time" - - "go.uber.org/zap" ) // GarbageCollect scans the root path and deletes files or directories with // names containing specific substrings and before a given expiration time. -func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error { - logger = logger.Named("gc") +func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error { + logger = logger.With(slog.String("logger", "gc")) // To make sure that the next Walk method stays on // the root level of the considered path, we have to @@ -43,7 +43,7 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, return fmt.Errorf("garbage collect '%s': %w", path, err) } - logger.Debug(fmt.Sprintf("'%s' removed", path)) + logger.DebugContext(ctx, fmt.Sprintf("'%s' removed", path)) return skipDirOrNil(info) } diff --git a/pkg/gotenberg/gc_test.go b/pkg/gotenberg/gc_test.go index 833a0a6f..b415bf9c 100644 --- a/pkg/gotenberg/gc_test.go +++ b/pkg/gotenberg/gc_test.go @@ -1,14 +1,15 @@ package gotenberg import ( + "context" "fmt" + "log/slog" "os" "path" "testing" "time" "github.com/google/uuid" - "go.uber.org/zap" ) func TestGarbageCollect(t *testing.T) { @@ -66,7 +67,7 @@ func TestGarbageCollect(t *testing.T) { } }() - err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now()) + err := GarbageCollect(context.Background(), slog.New(slog.NewJSONHandler(os.Stdout, nil)), tc.rootPath, tc.includeSubstr, time.Now()) if !tc.expectError && err != nil { t.Fatalf("expected no error but got: %v", err) diff --git a/pkg/modules/logging/color.go b/pkg/gotenberg/internal/log/color.go similarity index 53% rename from pkg/modules/logging/color.go rename to pkg/gotenberg/internal/log/color.go index e9a9d97b..840abfb4 100644 --- a/pkg/modules/logging/color.go +++ b/pkg/gotenberg/internal/log/color.go @@ -1,9 +1,8 @@ -package logging +package log import ( "fmt" - - "go.uber.org/zap/zapcore" + "log/slog" ) // Foreground colors. @@ -25,23 +24,15 @@ func (c color) Add(s string) string { return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) } -func levelToColor(l zapcore.Level) color { +func levelToColor(l slog.Level) color { switch l { - case zapcore.DebugLevel: + case slog.LevelDebug: return cyan - case zapcore.InfoLevel: + case slog.LevelInfo: return blue - case zapcore.WarnLevel: + case slog.LevelWarn: return yellow - case zapcore.ErrorLevel: - return red - case zapcore.DPanicLevel: - return red - case zapcore.PanicLevel: - return red - case zapcore.FatalLevel: - return red - case zapcore.InvalidLevel: + case slog.LevelError: return red default: return red diff --git a/pkg/gotenberg/internal/log/doc.go b/pkg/gotenberg/internal/log/doc.go new file mode 100644 index 00000000..453895bc --- /dev/null +++ b/pkg/gotenberg/internal/log/doc.go @@ -0,0 +1,2 @@ +// Package log gathers internal logging utilities. +package log diff --git a/pkg/gotenberg/internal/log/gcp.go b/pkg/gotenberg/internal/log/gcp.go new file mode 100644 index 00000000..70d91b69 --- /dev/null +++ b/pkg/gotenberg/internal/log/gcp.go @@ -0,0 +1,18 @@ +package log + +import "log/slog" + +func gcpSeverity(l slog.Level) string { + switch { + case l < slog.LevelInfo: + return "DEBUG" + case l < slog.LevelWarn: + return "INFO" + case l < slog.LevelError: + return "WARNING" + case l >= slog.LevelError: + return "ERROR" + default: + return "DEFAULT" + } +} diff --git a/pkg/gotenberg/internal/log/handler.go b/pkg/gotenberg/internal/log/handler.go new file mode 100644 index 00000000..0793d425 --- /dev/null +++ b/pkg/gotenberg/internal/log/handler.go @@ -0,0 +1,171 @@ +package log + +import ( + "context" + "errors" + "log/slog" +) + +type gotenbergHandler struct { + slog.Handler + fieldsPrefix string + loggerName string +} + +func NewGotenbergHandler(next slog.Handler, prefix string) slog.Handler { + return &gotenbergHandler{Handler: next, fieldsPrefix: prefix} +} + +func (h *gotenbergHandler) Handle(ctx context.Context, r slog.Record) error { + var newAttrs []slog.Attr + + if h.loggerName != "" { + newAttrs = append(newAttrs, slog.String("logger", h.loggerName)) + } + + var needsNewRecord bool + if len(newAttrs) > 0 { + needsNewRecord = true + } + + if h.fieldsPrefix != "" { + r.Attrs(func(a slog.Attr) bool { + if a.Key == "logger" || a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" { + newAttrs = append(newAttrs, a) + needsNewRecord = true + return true + } + a.Key = h.fieldsPrefix + "_" + a.Key + newAttrs = append(newAttrs, a) + needsNewRecord = true + return true + }) + } else if needsNewRecord { + r.Attrs(func(a slog.Attr) bool { + newAttrs = append(newAttrs, a) + return true + }) + } + + if needsNewRecord { + newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + newR.AddAttrs(newAttrs...) + r = newR + } + + return h.Handler.Handle(ctx, r) +} + +func (h *gotenbergHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + var prefixed []slog.Attr + newLoggerName := h.loggerName + + for _, a := range attrs { + if a.Key == "logger" { + if newLoggerName == "" { + newLoggerName = a.Value.String() + } else { + newLoggerName = newLoggerName + "." + a.Value.String() + } + continue + } + + if h.fieldsPrefix != "" { + if a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" { + // Don't prefix these keys + } else { + a.Key = h.fieldsPrefix + "_" + a.Key + } + } + prefixed = append(prefixed, a) + } + + newHandler := h.Handler + if len(prefixed) > 0 { + newHandler = h.Handler.WithAttrs(prefixed) + } + + return &gotenbergHandler{ + Handler: newHandler, + fieldsPrefix: h.fieldsPrefix, + loggerName: newLoggerName, + } +} + +func (h *gotenbergHandler) WithGroup(name string) slog.Handler { + return &gotenbergHandler{ + Handler: h.Handler.WithGroup(name), + fieldsPrefix: h.fieldsPrefix, + loggerName: h.loggerName, + } +} + +type multiHandler struct { + handlers []slog.Handler +} + +func FanOut(handlers ...slog.Handler) slog.Handler { + return &multiHandler{handlers: handlers} +} + +func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool { + for _, h := range m.handlers { + if h.Enabled(ctx, level) { + return true + } + } + return false +} + +func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error { + var errs []error + for _, h := range m.handlers { + if h.Enabled(ctx, r.Level) { + if err := h.Handle(ctx, r.Clone()); err != nil { + errs = append(errs, err) + } + } + } + return errors.Join(errs...) +} + +func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + cloned := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + cloned[i] = h.WithAttrs(attrs) + } + return &multiHandler{handlers: cloned} +} + +func (m *multiHandler) WithGroup(name string) slog.Handler { + cloned := make([]slog.Handler, len(m.handlers)) + for i, h := range m.handlers { + cloned[i] = h.WithGroup(name) + } + return &multiHandler{handlers: cloned} +} + +type levelHandler struct { + slog.Handler + level slog.Level +} + +func LevelFilter(next slog.Handler, level slog.Level) slog.Handler { + return &levelHandler{Handler: next, level: level} +} + +func (h *levelHandler) Enabled(ctx context.Context, level slog.Level) bool { + return level >= h.level && h.Handler.Enabled(ctx, level) +} + +func (h *levelHandler) Handle(ctx context.Context, r slog.Record) error { + return h.Handler.Handle(ctx, r) +} + +func (h *levelHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return &levelHandler{Handler: h.Handler.WithAttrs(attrs), level: h.level} +} + +func (h *levelHandler) WithGroup(name string) slog.Handler { + return &levelHandler{Handler: h.Handler.WithGroup(name), level: h.level} +} diff --git a/pkg/gotenberg/internal/log/log.go b/pkg/gotenberg/internal/log/log.go new file mode 100644 index 00000000..3a431054 --- /dev/null +++ b/pkg/gotenberg/internal/log/log.go @@ -0,0 +1,37 @@ +package log + +import ( + "log/slog" + "sync" +) + +// InitLogger initializes the global logger. +func InitLogger(handler slog.Handler) { + if logger != nil { + return + } + + mu.Lock() + defer mu.Unlock() + + // Double check: ensure it wasn't initialized while we waited for the lock. + if logger != nil { + return + } + + logger = slog.New(handler) +} + +// Logger returns the global logger. +func Logger() *slog.Logger { + mu.Lock() + defer mu.Unlock() + + return logger +} + +// logger is Singleton so that we instantiate our [slog.Logger] only once. +var ( + logger *slog.Logger + mu sync.Mutex +) diff --git a/pkg/gotenberg/internal/log/stdhandler.go b/pkg/gotenberg/internal/log/stdhandler.go new file mode 100644 index 00000000..31010a8f --- /dev/null +++ b/pkg/gotenberg/internal/log/stdhandler.go @@ -0,0 +1,127 @@ +package log + +import ( + "context" + "log/slog" + "os" + "strings" + + "go.opentelemetry.io/otel/trace" + "golang.org/x/term" +) + +const ( + autoLoggingFormat = "auto" + jsonLoggingFormat = "json" + textLoggingFormat = "text" +) + +// traceContextHandler is an internal wrapper that specifically injects +// trace_id and span_id into the log record before they are written to the standard output. +type traceContextHandler struct { + slog.Handler +} + +func (h traceContextHandler) Handle(ctx context.Context, r slog.Record) error { + if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() { + // Since slog.Record is immutable with regards to adding attributes in-place, + // we must clone and add them. + newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC) + r.Attrs(func(a slog.Attr) bool { + newR.AddAttrs(a) + return true + }) + newR.AddAttrs( + slog.String("trace_id", spanCtx.TraceID().String()), + slog.String("span_id", spanCtx.SpanID().String()), + ) + r = newR + } + + return h.Handler.Handle(ctx, r) +} + +func (h traceContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return traceContextHandler{Handler: h.Handler.WithAttrs(attrs)} +} + +func (h traceContextHandler) WithGroup(name string) slog.Handler { + return traceContextHandler{Handler: h.Handler.WithGroup(name)} +} + +// NewStdHandler returns a [slog.Handler] instance for the standard output. +func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) { + // #nosec: G115 + isTerminal := term.IsTerminal(int(os.Stdout.Fd())) + + // Normalize the log format based on the output device. + if format == autoLoggingFormat { + if isTerminal { + format = textLoggingFormat + } else { + format = jsonLoggingFormat + } + } + + opts := &slog.HandlerOptions{ + Level: level, + } + + opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr { + // Configure level encoding based on format and GCP settings. + if a.Key == slog.LevelKey { + l := a.Value.Any().(slog.Level) + if format == textLoggingFormat && isTerminal { + if enableGcpFields { + a.Value = slog.StringValue(gcpSeverityColorEncoder(l)) + } else { + a.Value = slog.StringValue(levelToColor(l).Add(l.String())) + } + } else if enableGcpFields && format != textLoggingFormat { + a.Key = "severity" + a.Value = slog.StringValue(gcpSeverity(l)) + } else { + a.Value = slog.StringValue(strings.ToLower(l.String())) + } + } + + if a.Key == slog.TimeKey { + if enableGcpFields && format != textLoggingFormat { + a.Key = "time" + } else { + a.Key = "ts" + } + + if isTerminal { + a.Value = slog.StringValue(a.Value.Time().Local().Format("2006/01/02 15:04:05.000")) + } else if !enableGcpFields { + a.Value = slog.Float64Value(float64(a.Value.Time().UnixNano()) / 1e9) + } + } + + if a.Key == slog.MessageKey { + if enableGcpFields && format != textLoggingFormat { + a.Key = "message" + } else { + a.Key = "msg" + } + } + + return a + } + + var handler slog.Handler + if format == textLoggingFormat { + handler = slog.NewTextHandler(os.Stderr, opts) + } else { + handler = slog.NewJSONHandler(os.Stderr, opts) + } + + return traceContextHandler{Handler: handler}, nil +} + +func gcpSeverityColorEncoder(l slog.Level) string { + severity := gcpSeverity(l) + c := levelToColor(l) + return c.Add(severity) +} diff --git a/pkg/gotenberg/internal/otel/doc.go b/pkg/gotenberg/internal/otel/doc.go new file mode 100644 index 00000000..dd12008f --- /dev/null +++ b/pkg/gotenberg/internal/otel/doc.go @@ -0,0 +1,8 @@ +// Package otel gathers initialization utilities for OpenTelemetry +// instrumentation. +// +// This package has been significantly inspired by +// https://github.com/lucavallin/gotel. +// +// See: https://opentelemetry.io/. +package otel diff --git a/pkg/gotenberg/internal/otel/otel.go b/pkg/gotenberg/internal/otel/otel.go new file mode 100644 index 00000000..d11bd601 --- /dev/null +++ b/pkg/gotenberg/internal/otel/otel.go @@ -0,0 +1,166 @@ +package otel + +import ( + "context" + "fmt" + "log/slog" + "os" + "sync/atomic" + + "go.opentelemetry.io/contrib/bridges/otelslog" + "go.opentelemetry.io/contrib/exporters/autoexport" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/log/global" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/sdk/log" + "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/resource" + "go.opentelemetry.io/otel/sdk/trace" + semconv "go.opentelemetry.io/otel/semconv/v1.40.0" +) + +// InitTracerProvider initializes the OpenTelemetry tracer provider. +func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) { + initOtelLogger(logger) + + ctx := context.Background() + hostname, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("get hostname: %w", err) + } + + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(serviceName), + semconv.ServiceVersion(serviceVersion), + semconv.HostName(hostname), + ), + ) + if err != nil { + return nil, fmt.Errorf("merge resource: %w", err) + } + + traceOpts := []trace.TracerProviderOption{ + trace.WithResource(res), + } + + traceExporter, err := autoexport.NewSpanExporter(ctx) + if err != nil { + return nil, err + } + + if !autoexport.IsNoneSpanExporter(traceExporter) { + traceOpts = append(traceOpts, trace.WithBatcher(traceExporter)) + } + + traceProvider := trace.NewTracerProvider(traceOpts...) + otel.SetTracerProvider(traceProvider) + + otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator( + propagation.TraceContext{}, + propagation.Baggage{}, + )) + + return traceProvider.Shutdown, nil +} + +// InitMeterProvider initializes the OpenTelemetry meter provider. +func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) { + initOtelLogger(logger) + + ctx := context.Background() + hostname, err := os.Hostname() + if err != nil { + return nil, fmt.Errorf("get hostname: %w", err) + } + + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(serviceName), + semconv.ServiceVersion(serviceVersion), + semconv.HostName(hostname), + ), + ) + if err != nil { + return nil, fmt.Errorf("merge resource: %w", err) + } + + metricOpts := []metric.Option{ + metric.WithResource(res), + } + + metricReader, err := autoexport.NewMetricReader(ctx) + if err != nil { + return nil, err + } + + if !autoexport.IsNoneMetricReader(metricReader) { + metricOpts = append(metricOpts, metric.WithReader(metricReader)) + } + + meterProvider := metric.NewMeterProvider(metricOpts...) + otel.SetMeterProvider(meterProvider) + + return meterProvider.Shutdown, nil +} + +// InitLoggerProvider initializes the OpenTelemetry logger provider. +func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) { + initOtelLogger(logger) + + ctx := context.Background() + hostname, err := os.Hostname() + if err != nil { + return nil, nil, fmt.Errorf("get hostname: %w", err) + } + + res, err := resource.Merge( + resource.Default(), + resource.NewWithAttributes( + semconv.SchemaURL, + semconv.ServiceName(serviceName), + semconv.ServiceVersion(serviceVersion), + semconv.HostName(hostname), + ), + ) + if err != nil { + return nil, nil, fmt.Errorf("merge resource: %w", err) + } + + logOpts := []log.LoggerProviderOption{ + log.WithResource(res), + } + + logExporter, err := autoexport.NewLogExporter(ctx) + if err != nil { + return nil, nil, err + } + + if !autoexport.IsNoneLogExporter(logExporter) { + logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter))) + } + + loggerProvider := log.NewLoggerProvider(logOpts...) + otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider)) + global.SetLoggerProvider(loggerProvider) + + return loggerProvider.Shutdown, otelHandler, nil +} + +func initOtelLogger(logger *slog.Logger) { + if otlpLoggerInitialized.Load() { + return + } + + otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) { + logger.Error(err.Error()) + })) + + otlpLoggerInitialized.Store(true) +} + +var otlpLoggerInitialized atomic.Bool diff --git a/pkg/gotenberg/logging.go b/pkg/gotenberg/logging.go deleted file mode 100644 index be502c2b..00000000 --- a/pkg/gotenberg/logging.go +++ /dev/null @@ -1,57 +0,0 @@ -package gotenberg - -import ( - "fmt" - - "github.com/hashicorp/go-retryablehttp" - "go.uber.org/zap" -) - -// LoggerProvider is an interface for a module that supplies a method for -// creating a [zap.Logger] instance for use by other modules. -// -// func (m *YourModule) Provision(ctx *gotenberg.Context) error { -// provider, _ := ctx.Module(new(gotenberg.LoggerProvider)) -// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m) -// } -type LoggerProvider interface { - Logger(mod Module) (*zap.Logger, error) -} - -// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a -// [retryablehttp.Client]. -type LeveledLogger struct { - logger *zap.Logger -} - -// NewLeveledLogger instantiates a [LeveledLogger]. -func NewLeveledLogger(logger *zap.Logger) *LeveledLogger { - return &LeveledLogger{ - logger: logger, - } -} - -// Error logs a message at the error level using the wrapped zap.Logger. -func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) { - leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues)) -} - -// Warn logs a message at the warning level using the wrapped zap.Logger. -func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) { - leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues)) -} - -// Info logs a message at the info level using the wrapped zap.Logger. -func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) { - leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues)) -} - -// Debug logs a message at the debug level using the wrapped zap.Logger. -func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) { - leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues)) -} - -// Interface guards. -var ( - _ retryablehttp.LeveledLogger = (*LeveledLogger)(nil) -) diff --git a/pkg/gotenberg/metrics.go b/pkg/gotenberg/metrics.go deleted file mode 100644 index 74f3619a..00000000 --- a/pkg/gotenberg/metrics.go +++ /dev/null @@ -1,26 +0,0 @@ -package gotenberg - -// Metric represents a unitary metric. -type Metric struct { - // Name is the unique identifier. - // Required. - Name string - - // Description describes the metric. - // Optional. - Description string - - // Read returns the current value. - // Required. - Read func() float64 -} - -// MetricsProvider is a module interface which provides a list of [Metric]. -// -// func (m *YourModule) Provision(ctx *gotenberg.Context) error { -// provider, _ := ctx.Module(new(gotenberg.MetricsProvider)) -// metrics, _ := provider.(gotenberg.MetricsProvider).Metrics() -// } -type MetricsProvider interface { - Metrics() ([]Metric, error) -} diff --git a/pkg/gotenberg/mocks.go b/pkg/gotenberg/mocks.go index 1df5f30f..dc25c9b1 100644 --- a/pkg/gotenberg/mocks.go +++ b/pkg/gotenberg/mocks.go @@ -2,9 +2,8 @@ package gotenberg import ( "context" + "log/slog" "os" - - "go.uber.org/zap" ) // ModuleMock is a mock for the [Module] interface. @@ -46,56 +45,46 @@ func (mod *DebuggableMock) Debug() map[string]any { // //nolint:dupl type PdfEngineMock struct { - MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error - SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) - FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error - ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error - ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) - WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error - ReadBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) - EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error - EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error - WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error + MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error + SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) + FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error + ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error + ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) + WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error + EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error + AddAttachmentsMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error } -func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { +func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { return engine.MergeMock(ctx, logger, inputPaths, outputPath) } -func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) { +func (engine *PdfEngineMock) Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) { return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath) } -func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { +func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { return engine.FlattenMock(ctx, logger, inputPath) } -func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error { +func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error { return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath) } -func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { return engine.ReadMetadataMock(ctx, logger, inputPath) } -func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { return engine.WriteMetadataMock(ctx, logger, metadata, inputPath) } -func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) { - return engine.ReadBookmarksMock(ctx, logger, inputPath) -} - -func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword) } -func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { - return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath) -} - -func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error { - return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks) +func (engine *PdfEngineMock) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + return engine.AddAttachmentsMock(ctx, logger, filePaths, inputPath) } // PdfEngineProviderMock is a mock for the [PdfEngineProvider] interface. @@ -109,20 +98,20 @@ func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) { // ProcessMock is a mock for the [Process] interface. type ProcessMock struct { - StartMock func(logger *zap.Logger) error - StopMock func(logger *zap.Logger) error - HealthyMock func(logger *zap.Logger) bool + StartMock func(logger *slog.Logger) error + StopMock func(logger *slog.Logger) error + HealthyMock func(logger *slog.Logger) bool } -func (p *ProcessMock) Start(logger *zap.Logger) error { +func (p *ProcessMock) Start(logger *slog.Logger) error { return p.StartMock(logger) } -func (p *ProcessMock) Stop(logger *zap.Logger) error { +func (p *ProcessMock) Stop(logger *slog.Logger) error { return p.StopMock(logger) } -func (p *ProcessMock) Healthy(logger *zap.Logger) bool { +func (p *ProcessMock) Healthy(logger *slog.Logger) bool { return p.HealthyMock(logger) } @@ -131,7 +120,7 @@ type ProcessSupervisorMock struct { LaunchMock func() error ShutdownMock func() error HealthyMock func() bool - RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error + RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error ReqQueueSizeMock func() int64 RestartsCountMock func() int64 } @@ -148,7 +137,7 @@ func (s *ProcessSupervisorMock) Healthy() bool { return s.HealthyMock() } -func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error { +func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *slog.Logger, task func() error) error { return s.RunMock(ctx, logger, task) } @@ -160,24 +149,6 @@ func (s *ProcessSupervisorMock) RestartsCount() int64 { return s.RestartsCountMock() } -// LoggerProviderMock is a mock for the [LoggerProvider] interface. -type LoggerProviderMock struct { - LoggerMock func(mod Module) (*zap.Logger, error) -} - -func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) { - return provider.LoggerMock(mod) -} - -// MetricsProviderMock is a mock for the [MetricsProvider] interface. -type MetricsProviderMock struct { - MetricsMock func() ([]Metric, error) -} - -func (provider *MetricsProviderMock) Metrics() ([]Metric, error) { - return provider.MetricsMock() -} - // MkdirAllMock is a mock for the [MkdirAll] interface. type MkdirAllMock struct { MkdirAllMock func(path string, perm os.FileMode) error @@ -204,8 +175,6 @@ var ( _ PdfEngineProvider = (*PdfEngineProviderMock)(nil) _ Process = (*ProcessMock)(nil) _ ProcessSupervisor = (*ProcessSupervisorMock)(nil) - _ LoggerProvider = (*LoggerProviderMock)(nil) - _ MetricsProvider = (*MetricsProviderMock)(nil) _ MkdirAll = (*MkdirAllMock)(nil) _ PathRename = (*PathRenameMock)(nil) ) diff --git a/pkg/gotenberg/pdfengine.go b/pkg/gotenberg/pdfengine.go index 2cd50c0f..38973adb 100644 --- a/pkg/gotenberg/pdfengine.go +++ b/pkg/gotenberg/pdfengine.go @@ -4,8 +4,7 @@ import ( "context" "errors" "fmt" - - "go.uber.org/zap" + "log/slog" ) var ( @@ -109,14 +108,6 @@ type PdfFormats struct { PdfUa bool } -// Bookmark represents a node in the PDF document's outline -// (table of contents). -type Bookmark struct { - Title string `json:"title"` - Page int `json:"page"` - Children []Bookmark `json:"children,omitempty"` -} - // PdfEngine provides an interface for operations on PDFs. Implementations // can use various tools like PDFtk, or implement functionality directly in // Go. @@ -125,44 +116,36 @@ type Bookmark struct { type PdfEngine interface { // Merge combines multiple PDFs into a single PDF. The resulting page order // is determined by the order of files provided in inputPaths. - Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error + Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error // Split splits a given PDF file. - Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) + Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) // Flatten merges existing annotation appearances with page content, // effectively deleting the original annotations. This process can flatten // forms as well as forms share a relationship with annotations. Note that // this operation is irreversible. - Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error + Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error // Convert transforms a given PDF to the specified formats defined in // PdfFormats. If no format, it does nothing. - Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error + Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error // ReadMetadata extracts the metadata of a given PDF file. - ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) + ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) // WriteMetadata writes the metadata into a given PDF file. - WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error - - // ReadBookmarks reads the document outline (bookmarks) of a PDF file. - ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) - - // WriteBookmarks adds a document outline (bookmarks) to a PDF file. - // The bookmarks parameter represents the hierarchical tree of the outline. - WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error + WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error // Encrypt adds password protection to a PDF file. // The userPassword is required to open the document. // The ownerPassword provides full access to the document. // If the ownerPassword is empty, it defaults to the userPassword. - Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error + Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error - // EmbedFiles embeds files into a PDF. All files are embedded as file attachments - // without modifying the main PDF content. - // TODO: attachments instead? Rename the route? - EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error + // AddAttachments adds attachments into a PDF. All files are attached as + // file attachments without modifying the main PDF content. + AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error } // PdfEngineProvider offers an interface to instantiate a [PdfEngine]. diff --git a/pkg/gotenberg/semconv/bench_test.go b/pkg/gotenberg/semconv/bench_test.go new file mode 100644 index 00000000..8b52b028 --- /dev/null +++ b/pkg/gotenberg/semconv/bench_test.go @@ -0,0 +1,48 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/bench_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv + +import ( + "net/http" + "net/url" + "testing" + + "go.opentelemetry.io/otel/attribute" +) + +var benchHTTPServerRequestResults []attribute.KeyValue + +// BenchmarkHTTPServerRequest allows comparison between different version of the HTTP server. +// To use an alternative start this test with OTEL_SEMCONV_STABILITY_OPT_IN set to the +// version under test. +func BenchmarkHTTPServerRequest(b *testing.B) { + // Request was generated from TestHTTPServerRequest request. + req := &http.Request{ + Method: http.MethodGet, + URL: &url.URL{ + Path: "/", + }, + Proto: "HTTP/1.1", + ProtoMajor: 1, + ProtoMinor: 1, + Header: http.Header{ + "User-Agent": []string{"Go-http-client/1.1"}, + "Accept-Encoding": []string{"gzip"}, + }, + Body: http.NoBody, + Host: "127.0.0.1:39093", + RemoteAddr: "127.0.0.1:38738", + RequestURI: "/", + } + serv := NewHTTPServer(nil) + + b.ReportAllocs() + b.ResetTimer() + for range b.N { + benchHTTPServerRequestResults = serv.RequestTraceAttrs("", req, RequestTraceAttrsOpts{}) + } +} diff --git a/pkg/gotenberg/semconv/client.go b/pkg/gotenberg/semconv/client.go new file mode 100644 index 00000000..1df3a0d2 --- /dev/null +++ b/pkg/gotenberg/semconv/client.go @@ -0,0 +1,308 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/client.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package semconv provides OpenTelemetry semantic convention types and +// functionality. +package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv" + +import ( + "context" + "fmt" + "net/http" + "reflect" + "slices" + "strconv" + "strings" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/semconv/v1.39.0/httpconv" +) + +type HTTPClient struct { + requestBodySize httpconv.ClientRequestBodySize + requestDuration httpconv.ClientRequestDuration +} + +func NewHTTPClient(meter metric.Meter) HTTPClient { + client := HTTPClient{} + + var err error + client.requestBodySize, err = httpconv.NewClientRequestBodySize(meter) + handleErr(err) + + client.requestDuration, err = httpconv.NewClientRequestDuration( + meter, + metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10), + ) + handleErr(err) + + return client +} + +func (n HTTPClient) Status(code int) (codes.Code, string) { + if code == 0 { + return codes.Error, "No HTTP status code" + } + if code < 100 || code >= 600 { + return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code) + } + if code >= 400 { + return codes.Error, http.StatusText(code) + } + return codes.Ok, "" +} + +// RequestTraceAttrs returns trace attributes for an HTTP request made by a client. +func (n HTTPClient) RequestTraceAttrs(req *http.Request) []attribute.KeyValue { + /* + below attributes are returned: + - http.request.method + - http.request.method.original + - url.full + - server.address + - server.port + - network.protocol.name + - network.protocol.version + */ + numOfAttributes := 3 // URL, server address, proto, and method. + + var urlHost string + if req.URL != nil { + urlHost = req.URL.Host + } + var requestHost string + var requestPort int + for _, hostport := range []string{urlHost, req.Header.Get("Host")} { + requestHost, requestPort = SplitHostPort(hostport) + if requestHost != "" || requestPort > 0 { + break + } + } + + eligiblePort := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort) + if eligiblePort > 0 { + numOfAttributes++ + } + useragent := req.UserAgent() + if useragent != "" { + numOfAttributes++ + } + + protoName, protoVersion := netProtocol(req.Proto) + if protoName != "" && protoName != "http" { + numOfAttributes++ + } + if protoVersion != "" { + numOfAttributes++ + } + + method, originalMethod := n.method(req.Method) + if originalMethod != (attribute.KeyValue{}) { + numOfAttributes++ + } + + attrs := make([]attribute.KeyValue, 0, numOfAttributes) + + attrs = append(attrs, method) + if originalMethod != (attribute.KeyValue{}) { + attrs = append(attrs, originalMethod) + } + + var u string + if req.URL != nil { + // Remove any username/password info that may be in the URL. + userinfo := req.URL.User + req.URL.User = nil + u = req.URL.String() + // Restore any username/password info that was removed. + req.URL.User = userinfo + } + attrs = append(attrs, semconv.URLFull(u)) + + attrs = append(attrs, semconv.ServerAddress(requestHost)) + if eligiblePort > 0 { + attrs = append(attrs, semconv.ServerPort(eligiblePort)) + } + + if protoName != "" && protoName != "http" { + attrs = append(attrs, semconv.NetworkProtocolName(protoName)) + } + if protoVersion != "" { + attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion)) + } + + return attrs +} + +// ResponseTraceAttrs returns trace attributes for an HTTP response made by a client. +func (n HTTPClient) ResponseTraceAttrs(resp *http.Response) []attribute.KeyValue { + /* + below attributes are returned: + - http.response.status_code + - error.type + */ + var count int + if resp.StatusCode > 0 { + count++ + } + + if isErrorStatusCode(resp.StatusCode) { + count++ + } + + attrs := make([]attribute.KeyValue, 0, count) + if resp.StatusCode > 0 { + attrs = append(attrs, semconv.HTTPResponseStatusCode(resp.StatusCode)) + } + + if isErrorStatusCode(resp.StatusCode) { + errorType := strconv.Itoa(resp.StatusCode) + attrs = append(attrs, semconv.ErrorTypeKey.String(errorType)) + } + return attrs +} + +func (n HTTPClient) ErrorType(err error) attribute.KeyValue { + t := reflect.TypeOf(err) + var value string + if t.PkgPath() == "" && t.Name() == "" { + // Likely a builtin type. + value = t.String() + } else { + value = fmt.Sprintf("%s.%s", t.PkgPath(), t.Name()) + } + + if value == "" { + return semconv.ErrorTypeOther + } + + return semconv.ErrorTypeKey.String(value) +} + +func (n HTTPClient) method(method string) (attribute.KeyValue, attribute.KeyValue) { + if method == "" { + return semconv.HTTPRequestMethodGet, attribute.KeyValue{} + } + if attr, ok := methodLookup[method]; ok { + return attr, attribute.KeyValue{} + } + + orig := semconv.HTTPRequestMethodOriginal(method) + if attr, ok := methodLookup[strings.ToUpper(method)]; ok { + return attr, orig + } + return semconv.HTTPRequestMethodGet, orig +} + +func (n HTTPClient) MetricAttributes(req *http.Request, statusCode int, additionalAttributes []attribute.KeyValue) []attribute.KeyValue { + num := len(additionalAttributes) + 2 + var h string + if req.URL != nil { + h = req.URL.Host + } + var requestHost string + var requestPort int + for _, hostport := range []string{h, req.Header.Get("Host")} { + requestHost, requestPort = SplitHostPort(hostport) + if requestHost != "" || requestPort > 0 { + break + } + } + + port := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort) + if port > 0 { + num++ + } + + protoName, protoVersion := netProtocol(req.Proto) + if protoName != "" { + num++ + } + if protoVersion != "" { + num++ + } + + if statusCode > 0 { + num++ + } + + attributes := slices.Grow(additionalAttributes, num) + attributes = append(attributes, + semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)), + semconv.ServerAddress(requestHost), + n.scheme(req), + ) + + if port > 0 { + attributes = append(attributes, semconv.ServerPort(port)) + } + if protoName != "" { + attributes = append(attributes, semconv.NetworkProtocolName(protoName)) + } + if protoVersion != "" { + attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion)) + } + + if statusCode > 0 { + attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode)) + } + return attributes +} + +type MetricOpts struct { + measurement metric.MeasurementOption + addOptions metric.AddOption +} + +func (o MetricOpts) MeasurementOption() metric.MeasurementOption { + return o.measurement +} + +func (o MetricOpts) AddOptions() metric.AddOption { + return o.addOptions +} + +func (n HTTPClient) MetricOptions(ma MetricAttributes) map[string]MetricOpts { + opts := map[string]MetricOpts{} + + attributes := n.MetricAttributes(ma.Req, ma.StatusCode, ma.AdditionalAttributes) + set := metric.WithAttributeSet(attribute.NewSet(attributes...)) + opts["new"] = MetricOpts{ + measurement: set, + addOptions: set, + } + + return opts +} + +func (n HTTPClient) RecordMetrics(ctx context.Context, md MetricData, opts map[string]MetricOpts) { + n.requestBodySize.Inst().Record(ctx, md.RequestSize, opts["new"].MeasurementOption()) + n.requestDuration.Inst().Record(ctx, md.ElapsedTime/1000, opts["new"].MeasurementOption()) +} + +// TraceAttributes returns attributes for httptrace. +func (n HTTPClient) TraceAttributes(host string) []attribute.KeyValue { + return []attribute.KeyValue{ + semconv.ServerAddress(host), + } +} + +func (n HTTPClient) scheme(req *http.Request) attribute.KeyValue { + if req.URL != nil && req.URL.Scheme != "" { + return semconv.URLScheme(req.URL.Scheme) + } + if req.TLS != nil { + return semconv.URLScheme("https") + } + return semconv.URLScheme("http") +} + +func isErrorStatusCode(code int) bool { + return code >= 400 || code < 100 +} diff --git a/pkg/gotenberg/semconv/client_test.go b/pkg/gotenberg/semconv/client_test.go new file mode 100644 index 00000000..8228ddfa --- /dev/null +++ b/pkg/gotenberg/semconv/client_test.go @@ -0,0 +1,182 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/client_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv + +import ( + "net/http" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" +) + +func TestHTTPClientStatus(t *testing.T) { + tests := []struct { + code int + stat codes.Code + msg bool + }{ + {0, codes.Error, true}, + {http.StatusContinue, codes.Ok, false}, + {http.StatusSwitchingProtocols, codes.Ok, false}, + {http.StatusProcessing, codes.Ok, false}, + {http.StatusEarlyHints, codes.Ok, false}, + {http.StatusOK, codes.Ok, false}, + {http.StatusCreated, codes.Ok, false}, + {http.StatusAccepted, codes.Ok, false}, + {http.StatusNonAuthoritativeInfo, codes.Ok, false}, + {http.StatusNoContent, codes.Ok, false}, + {http.StatusResetContent, codes.Ok, false}, + {http.StatusPartialContent, codes.Ok, false}, + {http.StatusMultiStatus, codes.Ok, false}, + {http.StatusAlreadyReported, codes.Ok, false}, + {http.StatusIMUsed, codes.Ok, false}, + {http.StatusMultipleChoices, codes.Ok, false}, + {http.StatusMovedPermanently, codes.Ok, false}, + {http.StatusFound, codes.Ok, false}, + {http.StatusSeeOther, codes.Ok, false}, + {http.StatusNotModified, codes.Ok, false}, + {http.StatusUseProxy, codes.Ok, false}, + {306, codes.Ok, false}, + {http.StatusTemporaryRedirect, codes.Ok, false}, + {http.StatusPermanentRedirect, codes.Ok, false}, + {http.StatusBadRequest, codes.Error, true}, + {http.StatusUnauthorized, codes.Error, true}, + {http.StatusPaymentRequired, codes.Error, true}, + {http.StatusForbidden, codes.Error, true}, + {http.StatusNotFound, codes.Error, true}, + {http.StatusMethodNotAllowed, codes.Error, true}, + {http.StatusNotAcceptable, codes.Error, true}, + {http.StatusProxyAuthRequired, codes.Error, true}, + {http.StatusRequestTimeout, codes.Error, true}, + {http.StatusConflict, codes.Error, true}, + {http.StatusGone, codes.Error, true}, + {http.StatusLengthRequired, codes.Error, true}, + {http.StatusPreconditionFailed, codes.Error, true}, + {http.StatusRequestEntityTooLarge, codes.Error, true}, + {http.StatusRequestURITooLong, codes.Error, true}, + {http.StatusUnsupportedMediaType, codes.Error, true}, + {http.StatusRequestedRangeNotSatisfiable, codes.Error, true}, + {http.StatusExpectationFailed, codes.Error, true}, + {http.StatusTeapot, codes.Error, true}, + {http.StatusMisdirectedRequest, codes.Error, true}, + {http.StatusUnprocessableEntity, codes.Error, true}, + {http.StatusLocked, codes.Error, true}, + {http.StatusFailedDependency, codes.Error, true}, + {http.StatusTooEarly, codes.Error, true}, + {http.StatusUpgradeRequired, codes.Error, true}, + {http.StatusPreconditionRequired, codes.Error, true}, + {http.StatusTooManyRequests, codes.Error, true}, + {http.StatusRequestHeaderFieldsTooLarge, codes.Error, true}, + {http.StatusUnavailableForLegalReasons, codes.Error, true}, + {499, codes.Error, false}, + {http.StatusInternalServerError, codes.Error, true}, + {http.StatusNotImplemented, codes.Error, true}, + {http.StatusBadGateway, codes.Error, true}, + {http.StatusServiceUnavailable, codes.Error, true}, + {http.StatusGatewayTimeout, codes.Error, true}, + {http.StatusHTTPVersionNotSupported, codes.Error, true}, + {http.StatusVariantAlsoNegotiates, codes.Error, true}, + {http.StatusInsufficientStorage, codes.Error, true}, + {http.StatusLoopDetected, codes.Error, true}, + {http.StatusNotExtended, codes.Error, true}, + {http.StatusNetworkAuthenticationRequired, codes.Error, true}, + {600, codes.Error, true}, + } + + for _, test := range tests { + t.Run(strconv.Itoa(test.code), func(t *testing.T) { + c, msg := HTTPClient{}.Status(test.code) + assert.Equal(t, test.stat, c) + if test.msg && msg == "" { + t.Errorf("expected non-empty message for %d", test.code) + } else if !test.msg && msg != "" { + t.Errorf("expected empty message for %d, got: %s", test.code, msg) + } + }) + } +} + +func TestHTTPClient_MetricAttributes(t *testing.T) { + defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody) + require.NoError(t, err) + httpsRequest, err := http.NewRequest("GET", "https://example.com/path?query=test", http.NoBody) + require.NoError(t, err) + + tests := []struct { + name string + server string + req *http.Request + statusCode int + additionalAttributes []attribute.KeyValue + wantFunc func(t *testing.T, attrs []attribute.KeyValue) + }{ + { + name: "routine testing", + req: defaultRequest, + statusCode: 200, + additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")}, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 7) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("server.address", "example.com"), + attribute.String("url.scheme", "http"), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + attribute.String("test", "test"), + }, attrs) + }, + }, + { + name: "use server address", + req: defaultRequest, + statusCode: 200, + additionalAttributes: nil, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 6) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("server.address", "example.com"), + attribute.String("url.scheme", "http"), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + }, attrs) + }, + }, + { + name: "https scheme", + req: httpsRequest, + statusCode: 200, + additionalAttributes: nil, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 6) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("server.address", "example.com"), + attribute.String("url.scheme", "https"), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + }, attrs) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HTTPClient{}.MetricAttributes(tt.req, tt.statusCode, tt.additionalAttributes) + tt.wantFunc(t, got) + }) + } +} diff --git a/pkg/gotenberg/semconv/common_test.go b/pkg/gotenberg/semconv/common_test.go new file mode 100644 index 00000000..f372ec82 --- /dev/null +++ b/pkg/gotenberg/semconv/common_test.go @@ -0,0 +1,71 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/common_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv_test + +import ( + "net/http" + "net/http/httptest" + "net/url" + "strconv" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv" + "go.opentelemetry.io/otel/attribute" +) + +type testServerReq struct { + hostname string + serverPort int + peerAddr string + peerPort int + clientIP string +} + +func testTraceRequest(t *testing.T, serv semconv.HTTPServer, want func(testServerReq) []attribute.KeyValue) { + t.Helper() + + got := make(chan *http.Request, 1) + handler := func(w http.ResponseWriter, r *http.Request) { + got <- r + close(got) + w.WriteHeader(http.StatusOK) + } + + srv := httptest.NewServer(http.HandlerFunc(handler)) + defer srv.Close() + + srvURL, err := url.Parse(srv.URL) + require.NoError(t, err) + srvPort, err := strconv.ParseInt(srvURL.Port(), 10, 32) + require.NoError(t, err) + + resp, err := srv.Client().Get(srv.URL) + require.NoError(t, err) + require.NoError(t, resp.Body.Close()) + + req := <-got + peer, peerPort := semconv.SplitHostPort(req.RemoteAddr) + + const user = "alice" + req.SetBasicAuth(user, "pswrd") + + const clientIP = "127.0.0.5" + req.Header.Add("X-Forwarded-For", clientIP) + + srvReq := testServerReq{ + hostname: srvURL.Hostname(), + serverPort: int(srvPort), + peerAddr: peer, + peerPort: peerPort, + clientIP: clientIP, + } + + assert.ElementsMatch(t, want(srvReq), serv.RequestTraceAttrs("", req, semconv.RequestTraceAttrsOpts{})) +} diff --git a/pkg/gotenberg/semconv/doc.go b/pkg/gotenberg/semconv/doc.go new file mode 100644 index 00000000..2a6751b7 --- /dev/null +++ b/pkg/gotenberg/semconv/doc.go @@ -0,0 +1,7 @@ +// Package semconv is a copy/paste of utilities that are currently not exposed +// in the OpenTelemery Go SDK. +// +// This package MUST be removed once an "official" API is provided. +// +// See: https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580. +package semconv diff --git a/pkg/gotenberg/semconv/gen.go b/pkg/gotenberg/semconv/gen.go new file mode 100644 index 00000000..a8a0d58d --- /dev/null +++ b/pkg/gotenberg/semconv/gen.go @@ -0,0 +1,15 @@ +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv" + +// Generate semconv package: +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/bench_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=bench_test.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/common_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=common_test.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/server.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=server.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/server_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=server_test.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/client.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=client.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/client_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=client_test.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/httpconvtest_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=httpconvtest_test.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/util.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=util.go +//go:generate gotmpl --body=../../../../../../internal/shared/semconv/util_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=util_test.go diff --git a/pkg/gotenberg/semconv/httpconvtest_test.go b/pkg/gotenberg/semconv/httpconvtest_test.go new file mode 100644 index 00000000..24b79426 --- /dev/null +++ b/pkg/gotenberg/semconv/httpconvtest_test.go @@ -0,0 +1,455 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/httpconv_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv_test + +import ( + "errors" + "fmt" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/sdk/instrumentation" + sdkmetric "go.opentelemetry.io/otel/sdk/metric" + "go.opentelemetry.io/otel/sdk/metric/metricdata" + "go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest" +) + +func TestNewTraceRequest(t *testing.T) { + serv := semconv.NewHTTPServer(nil) + want := func(req testServerReq) []attribute.KeyValue { + return []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("url.scheme", "http"), + attribute.String("server.address", req.hostname), + attribute.Int("server.port", req.serverPort), + attribute.String("network.peer.address", req.peerAddr), + attribute.Int("network.peer.port", req.peerPort), + attribute.String("user_agent.original", "Go-http-client/1.1"), + attribute.String("client.address", req.clientIP), + attribute.String("network.protocol.version", "1.1"), + attribute.String("url.path", "/"), + } + } + testTraceRequest(t, serv, want) +} + +func TestNewServerRecordMetrics(t *testing.T) { + oldAttrs := attribute.NewSet( + attribute.String("http.scheme", "http"), + attribute.String("http.method", "POST"), + attribute.Int64("http.status_code", 301), + attribute.String("key", "value"), + attribute.String("net.host.name", "stuff"), + attribute.String("net.protocol.name", "http"), + attribute.String("net.protocol.version", "1.1"), + ) + + currAttrs := attribute.NewSet( + attribute.String("http.request.method", "POST"), + attribute.Int64("http.response.status_code", 301), + attribute.String("key", "value"), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.String("server.address", "stuff"), + attribute.String("url.scheme", "http"), + ) + + // the HTTPServer version + expectedCurrentScopeMetric := metricdata.ScopeMetrics{ + Scope: instrumentation.Scope{ + Name: "test", + }, + Metrics: []metricdata.Metrics{ + { + Name: "http.server.request.body.size", + Description: "Size of HTTP server request bodies.", + Unit: "By", + Data: metricdata.Histogram[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[int64]{ + { + Attributes: currAttrs, + }, + }, + }, + }, + { + Name: "http.server.response.body.size", + Description: "Size of HTTP server response bodies.", + Unit: "By", + Data: metricdata.Histogram[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[int64]{ + { + Attributes: currAttrs, + }, + }, + }, + }, + { + Name: "http.server.request.duration", + Description: "Duration of HTTP server requests.", + Unit: "s", + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: currAttrs, + }, + }, + }, + }, + }, + } + + // The OldHTTPServer version + expectedOldScopeMetric := expectedCurrentScopeMetric + expectedOldScopeMetric.Metrics = append(expectedOldScopeMetric.Metrics, []metricdata.Metrics{ + { + Name: "http.server.request.size", + Description: "Measures the size of HTTP request messages.", + Unit: "By", + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: oldAttrs, + }, + }, + }, + }, + { + Name: "http.server.response.size", + Description: "Measures the size of HTTP response messages.", + Unit: "By", + Data: metricdata.Sum[int64]{ + Temporality: metricdata.CumulativeTemporality, + IsMonotonic: true, + DataPoints: []metricdata.DataPoint[int64]{ + { + Attributes: oldAttrs, + }, + }, + }, + }, + { + Name: "http.server.duration", + Description: "Measures the duration of inbound HTTP requests.", + Unit: "ms", + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: oldAttrs, + }, + }, + }, + }, + }...) + + tests := []struct { + name string + serverFunc func(metric.MeterProvider) semconv.HTTPServer + wantFunc func(t *testing.T, rm metricdata.ResourceMetrics) + }{ + { + name: "No Meter", + serverFunc: func(metric.MeterProvider) semconv.HTTPServer { + return semconv.NewHTTPServer(nil) + }, + wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) { + assert.Empty(t, rm.ScopeMetrics) + }, + }, + { + name: "With Meter", + serverFunc: func(mp metric.MeterProvider) semconv.HTTPServer { + return semconv.NewHTTPServer(mp.Meter("test")) + }, + wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) { + require.Len(t, rm.ScopeMetrics, 1) + + // because of OldHTTPServer + require.Len(t, rm.ScopeMetrics[0].Metrics, 3) + metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + server := tt.serverFunc(mp) + req, err := http.NewRequest("POST", "http://example.com", http.NoBody) + assert.NoError(t, err) + + server.RecordMetrics(t.Context(), semconv.ServerMetricData{ + ServerName: "stuff", + ResponseSize: 200, + MetricAttributes: semconv.MetricAttributes{ + Req: req, + StatusCode: 301, + AdditionalAttributes: []attribute.KeyValue{ + attribute.String("key", "value"), + }, + }, + MetricData: semconv.MetricData{ + RequestSize: 100, + ElapsedTime: 300, + }, + }) + + rm := metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(t.Context(), &rm)) + tt.wantFunc(t, rm) + }) + } +} + +func TestNewTraceResponse(t *testing.T) { + testCases := []struct { + name string + resp semconv.ResponseTelemetry + want []attribute.KeyValue + }{ + { + name: "empty", + resp: semconv.ResponseTelemetry{}, + want: nil, + }, + { + name: "no errors", + resp: semconv.ResponseTelemetry{ + StatusCode: 200, + ReadBytes: 701, + WriteBytes: 802, + }, + want: []attribute.KeyValue{ + attribute.Int("http.request.body.size", 701), + attribute.Int("http.response.body.size", 802), + attribute.Int("http.response.status_code", 200), + }, + }, + { + name: "with errors", + resp: semconv.ResponseTelemetry{ + StatusCode: 200, + ReadBytes: 701, + ReadError: fmt.Errorf("read error"), + WriteBytes: 802, + WriteError: fmt.Errorf("write error"), + }, + want: []attribute.KeyValue{ + attribute.Int("http.request.body.size", 701), + attribute.Int("http.response.body.size", 802), + attribute.Int("http.response.status_code", 200), + }, + }, + } + + for _, tt := range testCases { + t.Run(tt.name, func(t *testing.T) { + got := semconv.HTTPServer{}.ResponseTraceAttrs(tt.resp) + assert.ElementsMatch(t, tt.want, got) + }) + } +} + +func TestNewTraceRequest_Client(t *testing.T) { + body := strings.NewReader("Hello, world!") + url := "https://example.com:8888/foo/bar?stuff=morestuff" + req := httptest.NewRequest("pOST", url, body) + req.Header.Set("User-Agent", "go-test-agent") + + want := []attribute.KeyValue{ + attribute.String("http.request.method", "POST"), + attribute.String("http.request.method_original", "pOST"), + attribute.String("url.full", url), + attribute.String("server.address", "example.com"), + attribute.Int("server.port", 8888), + attribute.String("network.protocol.version", "1.1"), + } + client := semconv.NewHTTPClient(nil) + assert.ElementsMatch(t, want, client.RequestTraceAttrs(req)) +} + +func TestNewTraceResponse_Client(t *testing.T) { + testcases := []struct { + resp http.Response + want []attribute.KeyValue + }{ + {resp: http.Response{StatusCode: 200, ContentLength: 123}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 200)}}, + {resp: http.Response{StatusCode: 404, ContentLength: 0}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 404), attribute.String("error.type", "404")}}, + } + + for _, tt := range testcases { + client := semconv.NewHTTPClient(nil) + assert.ElementsMatch(t, tt.want, client.ResponseTraceAttrs(&tt.resp)) + } +} + +func TestClientRequest(t *testing.T) { + body := strings.NewReader("Hello, world!") + url := "https://example.com:8888/foo/bar?stuff=morestuff" + req := httptest.NewRequest("pOST", url, body) + req.Header.Set("User-Agent", "go-test-agent") + + want := []attribute.KeyValue{ + attribute.String("http.request.method", "POST"), + attribute.String("http.request.method_original", "pOST"), + attribute.String("url.full", url), + attribute.String("server.address", "example.com"), + attribute.Int("server.port", 8888), + attribute.String("network.protocol.version", "1.1"), + } + got := semconv.HTTPClient{}.RequestTraceAttrs(req) + assert.ElementsMatch(t, want, got) +} + +func TestClientResponse(t *testing.T) { + testcases := []struct { + resp http.Response + want []attribute.KeyValue + }{ + {resp: http.Response{StatusCode: 200, ContentLength: 123}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 200)}}, + {resp: http.Response{StatusCode: 404, ContentLength: 0}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 404), attribute.String("error.type", "404")}}, + } + + for _, tt := range testcases { + got := semconv.HTTPClient{}.ResponseTraceAttrs(&tt.resp) + assert.ElementsMatch(t, tt.want, got) + } +} + +func TestRequestErrorType(t *testing.T) { + testcases := []struct { + err error + want attribute.KeyValue + }{ + {err: errors.New("http: nil Request.URL"), want: attribute.String("error.type", "*errors.errorString")}, + {err: customError{}, want: attribute.String("error.type", "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv_test.customError")}, + } + + for _, tt := range testcases { + got := semconv.HTTPClient{}.ErrorType(tt.err) + assert.Equal(t, tt.want, got) + } +} + +func TestNewClientRecordMetrics(t *testing.T) { + currAttrs := attribute.NewSet( + attribute.String("http.request.method", "POST"), + attribute.Int64("http.response.status_code", 301), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.String("server.address", "example.com"), + attribute.String("url.scheme", "http"), + ) + + // the HTTPClient version + expectedCurrentScopeMetric := metricdata.ScopeMetrics{ + Scope: instrumentation.Scope{ + Name: "test", + }, + Metrics: []metricdata.Metrics{ + { + Name: "http.client.request.body.size", + Description: "Size of HTTP client request bodies.", + Unit: "By", + Data: metricdata.Histogram[int64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[int64]{ + { + Attributes: currAttrs, + }, + }, + }, + }, + { + Name: "http.client.request.duration", + Description: "Duration of HTTP client requests.", + Unit: "s", + Data: metricdata.Histogram[float64]{ + Temporality: metricdata.CumulativeTemporality, + DataPoints: []metricdata.HistogramDataPoint[float64]{ + { + Attributes: currAttrs, + }, + }, + }, + }, + }, + } + + tests := []struct { + name string + clientFunc func(metric.MeterProvider) semconv.HTTPClient + wantFunc func(t *testing.T, rm metricdata.ResourceMetrics) + }{ + { + name: "No environment variable set, and no Meter", + clientFunc: func(metric.MeterProvider) semconv.HTTPClient { + return semconv.NewHTTPClient(nil) + }, + wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) { + assert.Empty(t, rm.ScopeMetrics) + }, + }, + { + name: "With Meter", + clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient { + return semconv.NewHTTPClient(mp.Meter("test")) + }, + wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) { + require.Len(t, rm.ScopeMetrics, 1) + + require.Len(t, rm.ScopeMetrics[0].Metrics, 2) + metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue()) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + reader := sdkmetric.NewManualReader() + mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader)) + + client := tt.clientFunc(mp) + req, err := http.NewRequest("POST", "http://example.com", http.NoBody) + assert.NoError(t, err) + + client.RecordMetrics(t.Context(), semconv.MetricData{ + RequestSize: 100, + ElapsedTime: 300, + }, client.MetricOptions(semconv.MetricAttributes{ + Req: req, + StatusCode: 301, + })) + + rm := metricdata.ResourceMetrics{} + require.NoError(t, reader.Collect(t.Context(), &rm)) + tt.wantFunc(t, rm) + }) + } +} + +type customError struct{} + +func (customError) Error() string { + return "custom error" +} diff --git a/pkg/gotenberg/semconv/server.go b/pkg/gotenberg/semconv/server.go new file mode 100644 index 00000000..9b3c63b7 --- /dev/null +++ b/pkg/gotenberg/semconv/server.go @@ -0,0 +1,405 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/server.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +// Package semconv provides OpenTelemetry semantic convention types and +// functionality. +package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv" + +import ( + "context" + "fmt" + "net/http" + "slices" + "strings" + "sync" + + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/semconv/v1.39.0/httpconv" +) + +type RequestTraceAttrsOpts struct { + // If set, this is used as value for the "http.client_ip" attribute. + HTTPClientIP string +} + +type ResponseTelemetry struct { + StatusCode int + ReadBytes int64 + ReadError error + WriteBytes int64 + WriteError error +} + +type HTTPServer struct { + requestBodySizeHistogram httpconv.ServerRequestBodySize + responseBodySizeHistogram httpconv.ServerResponseBodySize + requestDurationHistogram httpconv.ServerRequestDuration +} + +func NewHTTPServer(meter metric.Meter) HTTPServer { + server := HTTPServer{} + + var err error + server.requestBodySizeHistogram, err = httpconv.NewServerRequestBodySize(meter) + handleErr(err) + + server.responseBodySizeHistogram, err = httpconv.NewServerResponseBodySize(meter) + handleErr(err) + + server.requestDurationHistogram, err = httpconv.NewServerRequestDuration( + meter, + metric.WithExplicitBucketBoundaries( + 0.005, 0.01, 0.025, 0.05, 0.075, 0.1, + 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10, + ), + ) + handleErr(err) + return server +} + +// Status returns a span status code and message for an HTTP status code +// value returned by a server. +func (n HTTPServer) Status(code int) (codes.Code, string) { + if code == 0 { + return codes.Error, "No HTTP status code" + } + if code < 100 || code >= 600 { + return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code) + } + if code >= 400 { + return codes.Error, http.StatusText(code) + } + return codes.Ok, "" +} + +// RequestTraceAttrs returns trace attributes for an HTTP request received by a +// server. +// +// The server must be the primary server name if it is known. For example this +// would be the ServerName directive +// (https://httpd.apache.org/docs/2.4/mod/core.html#servername) for an Apache +// server, and the server_name directive +// (http://nginx.org/en/docs/http/ngx_http_core_module.html#server_name) for an +// nginx server. More generically, the primary server name would be the host +// header value that matches the default virtual host of an HTTP server. It +// should include the host identifier and if a port is used to route to the +// server that port identifier should be included as an appropriate port +// suffix. +// +// If the primary server name is not known, server should be an empty string. +// The req Host will be used to determine the server instead. +func (n HTTPServer) RequestTraceAttrs(server string, req *http.Request, opts RequestTraceAttrsOpts) []attribute.KeyValue { + count := 3 // ServerAddress, Method, Scheme + + var host string + var p int + if server == "" { + host, p = SplitHostPort(req.Host) + } else { + // Prioritize the primary server name. + host, p = SplitHostPort(server) + if p < 0 { + _, p = SplitHostPort(req.Host) + } + } + + hostPort := requiredHTTPPort(req.TLS != nil, p) + if hostPort > 0 { + count++ + } + + method, methodOriginal := n.method(req.Method) + if methodOriginal != (attribute.KeyValue{}) { + count++ + } + + scheme := n.scheme(req.TLS != nil) + + peer, peerPort := SplitHostPort(req.RemoteAddr) + if peer != "" { + // The Go HTTP server sets RemoteAddr to "IP:port", this will not be a + // file-path that would be interpreted with a sock family. + count++ + if peerPort > 0 { + count++ + } + } + + useragent := req.UserAgent() + if useragent != "" { + count++ + } + + // For client IP, use, in order: + // 1. The value passed in the options + // 2. The value in the X-Forwarded-For header + // 3. The peer address + clientIP := opts.HTTPClientIP + if clientIP == "" { + clientIP = serverClientIP(req.Header.Get("X-Forwarded-For")) + if clientIP == "" { + clientIP = peer + } + } + if clientIP != "" { + count++ + } + + if req.URL != nil && req.URL.Path != "" { + count++ + } + + protoName, protoVersion := netProtocol(req.Proto) + if protoName != "" && protoName != "http" { + count++ + } + if protoVersion != "" { + count++ + } + + route := httpRoute(req.Pattern) + if route != "" { + count++ + } + + attrs := make([]attribute.KeyValue, 0, count) + attrs = append(attrs, + semconv.ServerAddress(host), + method, + scheme, + ) + + if hostPort > 0 { + attrs = append(attrs, semconv.ServerPort(hostPort)) + } + if methodOriginal != (attribute.KeyValue{}) { + attrs = append(attrs, methodOriginal) + } + + if peer, peerPort := SplitHostPort(req.RemoteAddr); peer != "" { + // The Go HTTP server sets RemoteAddr to "IP:port", this will not be a + // file-path that would be interpreted with a sock family. + attrs = append(attrs, semconv.NetworkPeerAddress(peer)) + if peerPort > 0 { + attrs = append(attrs, semconv.NetworkPeerPort(peerPort)) + } + } + + if useragent != "" { + attrs = append(attrs, semconv.UserAgentOriginal(useragent)) + } + + if clientIP != "" { + attrs = append(attrs, semconv.ClientAddress(clientIP)) + } + + if req.URL != nil && req.URL.Path != "" { + attrs = append(attrs, semconv.URLPath(req.URL.Path)) + } + + if protoName != "" && protoName != "http" { + attrs = append(attrs, semconv.NetworkProtocolName(protoName)) + } + if protoVersion != "" { + attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion)) + } + + if route != "" { + attrs = append(attrs, n.Route(route)) + } + + return attrs +} + +func (s HTTPServer) NetworkTransportAttr(network string) []attribute.KeyValue { + attr := semconv.NetworkTransportPipe + switch network { + case "tcp", "tcp4", "tcp6": + attr = semconv.NetworkTransportTCP + case "udp", "udp4", "udp6": + attr = semconv.NetworkTransportUDP + case "unix", "unixgram", "unixpacket": + attr = semconv.NetworkTransportUnix + } + + return []attribute.KeyValue{attr} +} + +type ServerMetricData struct { + ServerName string + ResponseSize int64 + + MetricData + MetricAttributes +} + +type MetricAttributes struct { + Req *http.Request + StatusCode int + Route string + AdditionalAttributes []attribute.KeyValue +} + +type MetricData struct { + RequestSize int64 + + // The request duration, in milliseconds + ElapsedTime float64 +} + +var ( + metricAddOptionPool = &sync.Pool{ + New: func() any { + return &[]metric.AddOption{} + }, + } + + metricRecordOptionPool = &sync.Pool{ + New: func() any { + return &[]metric.RecordOption{} + }, + } +) + +func (n HTTPServer) RecordMetrics(ctx context.Context, md ServerMetricData) { + attributes := n.MetricAttributes(md.ServerName, md.Req, md.StatusCode, md.Route, md.AdditionalAttributes) + o := metric.WithAttributeSet(attribute.NewSet(attributes...)) + recordOpts := metricRecordOptionPool.Get().(*[]metric.RecordOption) + *recordOpts = append(*recordOpts, o) + n.requestBodySizeHistogram.Inst().Record(ctx, md.RequestSize, *recordOpts...) + n.responseBodySizeHistogram.Inst().Record(ctx, md.ResponseSize, *recordOpts...) + n.requestDurationHistogram.Inst().Record(ctx, md.ElapsedTime/1000.0, o) + *recordOpts = (*recordOpts)[:0] + metricRecordOptionPool.Put(recordOpts) +} + +func (n HTTPServer) method(method string) (attribute.KeyValue, attribute.KeyValue) { + if method == "" { + return semconv.HTTPRequestMethodGet, attribute.KeyValue{} + } + if attr, ok := methodLookup[method]; ok { + return attr, attribute.KeyValue{} + } + + orig := semconv.HTTPRequestMethodOriginal(method) + if attr, ok := methodLookup[strings.ToUpper(method)]; ok { + return attr, orig + } + return semconv.HTTPRequestMethodGet, orig +} + +func (n HTTPServer) scheme(https bool) attribute.KeyValue { //nolint:revive // ignore linter + if https { + return semconv.URLScheme("https") + } + return semconv.URLScheme("http") +} + +// ResponseTraceAttrs returns trace attributes for telemetry from an HTTP +// response. +// +// If any of the fields in the ResponseTelemetry are not set the attribute will +// be omitted. +func (n HTTPServer) ResponseTraceAttrs(resp ResponseTelemetry) []attribute.KeyValue { + var count int + + if resp.ReadBytes > 0 { + count++ + } + if resp.WriteBytes > 0 { + count++ + } + if resp.StatusCode > 0 { + count++ + } + + attributes := make([]attribute.KeyValue, 0, count) + + if resp.ReadBytes > 0 { + attributes = append(attributes, + semconv.HTTPRequestBodySize(int(resp.ReadBytes)), + ) + } + if resp.WriteBytes > 0 { + attributes = append(attributes, + semconv.HTTPResponseBodySize(int(resp.WriteBytes)), + ) + } + if resp.StatusCode > 0 { + attributes = append(attributes, + semconv.HTTPResponseStatusCode(resp.StatusCode), + ) + } + + return attributes +} + +// Route returns the attribute for the route. +func (n HTTPServer) Route(route string) attribute.KeyValue { + return semconv.HTTPRoute(route) +} + +func (n HTTPServer) MetricAttributes(server string, req *http.Request, statusCode int, route string, additionalAttributes []attribute.KeyValue) []attribute.KeyValue { + num := len(additionalAttributes) + 3 + var host string + var p int + if server == "" { + host, p = SplitHostPort(req.Host) + } else { + // Prioritize the primary server name. + host, p = SplitHostPort(server) + if p < 0 { + _, p = SplitHostPort(req.Host) + } + } + hostPort := requiredHTTPPort(req.TLS != nil, p) + if hostPort > 0 { + num++ + } + protoName, protoVersion := netProtocol(req.Proto) + if protoName != "" { + num++ + } + if protoVersion != "" { + num++ + } + + if statusCode > 0 { + num++ + } + + if route != "" { + num++ + } + + attributes := slices.Grow(additionalAttributes, num) + attributes = append(attributes, + semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)), + n.scheme(req.TLS != nil), + semconv.ServerAddress(host)) + + if hostPort > 0 { + attributes = append(attributes, semconv.ServerPort(hostPort)) + } + if protoName != "" { + attributes = append(attributes, semconv.NetworkProtocolName(protoName)) + } + if protoVersion != "" { + attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion)) + } + + if statusCode > 0 { + attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode)) + } + + if route != "" { + attributes = append(attributes, semconv.HTTPRoute(route)) + } + return attributes +} diff --git a/pkg/gotenberg/semconv/server_test.go b/pkg/gotenberg/semconv/server_test.go new file mode 100644 index 00000000..4c905e2f --- /dev/null +++ b/pkg/gotenberg/semconv/server_test.go @@ -0,0 +1,210 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/server_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv + +import ( + "net/http" + "net/http/httptest" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "go.opentelemetry.io/otel/attribute" +) + +func TestHTTPServer_MetricAttributes(t *testing.T) { + defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody) + require.NoError(t, err) + + tests := []struct { + name string + server string + req *http.Request + statusCode int + route string + additionalAttributes []attribute.KeyValue + wantFunc func(t *testing.T, attrs []attribute.KeyValue) + }{ + { + name: "routine testing", + server: "", + req: defaultRequest, + statusCode: 200, + route: "", + additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")}, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 7) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("url.scheme", "http"), + attribute.String("server.address", "example.com"), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + attribute.String("test", "test"), + }, attrs) + }, + }, + { + name: "use server address", + server: "example.com:9999", + req: defaultRequest, + statusCode: 200, + route: "/path/${id}", + additionalAttributes: nil, + wantFunc: func(t *testing.T, attrs []attribute.KeyValue) { + require.Len(t, attrs, 8) + assert.ElementsMatch(t, []attribute.KeyValue{ + attribute.String("http.request.method", "GET"), + attribute.String("url.scheme", "http"), + attribute.String("server.address", "example.com"), + attribute.Int("server.port", 9999), + attribute.String("network.protocol.name", "http"), + attribute.String("network.protocol.version", "1.1"), + attribute.Int64("http.response.status_code", 200), + attribute.String("http.route", "/path/${id}"), + }, attrs) + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := HTTPServer{}.MetricAttributes(tt.server, tt.req, tt.statusCode, tt.route, tt.additionalAttributes) + tt.wantFunc(t, got) + }) + } +} + +func TestNewMethod(t *testing.T) { + testCases := []struct { + method string + n int + want attribute.KeyValue + wantOrig attribute.KeyValue + }{ + { + method: http.MethodPost, + n: 1, + want: attribute.String("http.request.method", "POST"), + }, + { + method: "Put", + n: 2, + want: attribute.String("http.request.method", "PUT"), + wantOrig: attribute.String("http.request.method_original", "Put"), + }, + { + method: "Unknown", + n: 2, + want: attribute.String("http.request.method", "GET"), + wantOrig: attribute.String("http.request.method_original", "Unknown"), + }, + } + + for _, tt := range testCases { + t.Run(tt.method, func(t *testing.T) { + got, gotOrig := HTTPServer{}.method(tt.method) + assert.Equal(t, tt.want, got) + assert.Equal(t, tt.wantOrig, gotOrig) + }) + } +} + +func TestRequestTraceAttrs_HTTPRoute(t *testing.T) { + tests := []struct { + name string + pattern string + wantRoute string + }{ + { + name: "only path", + pattern: "/path/{id}", + wantRoute: "/path/{id}", + }, + { + name: "with method", + pattern: "GET /path/{id}", + wantRoute: "/path/{id}", + }, + { + name: "with domain", + pattern: "example.com/path/{id}", + wantRoute: "/path/{id}", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/path/abc123", http.NoBody) + req.Pattern = tt.pattern + + attrs := (HTTPServer{}).RequestTraceAttrs("", req, RequestTraceAttrsOpts{}) + + var gotRoute string + for _, attr := range attrs { + if attr.Key == "http.route" { + gotRoute = attr.Value.AsString() + break + } + } + require.Equal(t, tt.wantRoute, gotRoute) + }) + } +} + +func TestRequestTraceAttrs_ClientIP(t *testing.T) { + for _, tt := range []struct { + name string + requestModifierFn func(r *http.Request) + requestTraceOpts RequestTraceAttrsOpts + + wantClientIP string + }{ + { + name: "with a client IP from the network", + wantClientIP: "1.2.3.4", + }, + { + name: "with a client IP from x-forwarded-for header", + requestModifierFn: func(r *http.Request) { + r.Header.Add("X-Forwarded-For", "5.6.7.8") + }, + wantClientIP: "5.6.7.8", + }, + { + name: "with a client IP in options", + requestModifierFn: func(r *http.Request) { + r.Header.Add("X-Forwarded-For", "5.6.7.8") + }, + requestTraceOpts: RequestTraceAttrsOpts{ + HTTPClientIP: "9.8.7.6", + }, + wantClientIP: "9.8.7.6", + }, + } { + t.Run(tt.name, func(t *testing.T) { + req := httptest.NewRequest(http.MethodGet, "/example", http.NoBody) + req.RemoteAddr = "1.2.3.4:5678" + + if tt.requestModifierFn != nil { + tt.requestModifierFn(req) + } + + var found bool + for _, attr := range (HTTPServer{}).RequestTraceAttrs("", req, tt.requestTraceOpts) { + if attr.Key != "client.address" { + continue + } + found = true + assert.Equal(t, tt.wantClientIP, attr.Value.AsString()) + } + require.True(t, found) + }) + } +} diff --git a/pkg/gotenberg/semconv/util.go b/pkg/gotenberg/semconv/util.go new file mode 100644 index 00000000..131fda48 --- /dev/null +++ b/pkg/gotenberg/semconv/util.go @@ -0,0 +1,127 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/util.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv" + +import ( + "net" + "net/http" + "strconv" + "strings" + + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + semconvNew "go.opentelemetry.io/otel/semconv/v1.39.0" +) + +// SplitHostPort splits a network address hostport of the form "host", +// "host%zone", "[host]", "[host%zone], "host:port", "host%zone:port", +// "[host]:port", "[host%zone]:port", or ":port" into host or host%zone and +// port. +// +// An empty host is returned if it is not provided or unparsable. A negative +// port is returned if it is not provided or unparsable. +func SplitHostPort(hostport string) (host string, port int) { + port = -1 + + if strings.HasPrefix(hostport, "[") { + addrEnd := strings.LastIndexByte(hostport, ']') + if addrEnd < 0 { + // Invalid hostport. + return + } + if i := strings.LastIndexByte(hostport[addrEnd:], ':'); i < 0 { + host = hostport[1:addrEnd] + return + } + } else { + if i := strings.LastIndexByte(hostport, ':'); i < 0 { + host = hostport + return + } + } + + host, pStr, err := net.SplitHostPort(hostport) + if err != nil { + return + } + + p, err := strconv.ParseUint(pStr, 10, 16) + if err != nil { + return + } + return host, int(p) //nolint:gosec // Byte size checked 16 above. +} + +func requiredHTTPPort(https bool, port int) int { //nolint:revive // ignore linter + if https { + if port > 0 && port != 443 { + return port + } + } else { + if port > 0 && port != 80 { + return port + } + } + return -1 +} + +func serverClientIP(xForwardedFor string) string { + if idx := strings.IndexByte(xForwardedFor, ','); idx >= 0 { + xForwardedFor = xForwardedFor[:idx] + } + return xForwardedFor +} + +func httpRoute(pattern string) string { + if idx := strings.IndexByte(pattern, '/'); idx >= 0 { + return pattern[idx:] + } + return "" +} + +func netProtocol(proto string) (name string, version string) { + name, version, _ = strings.Cut(proto, "/") + switch name { + case "HTTP": + name = "http" + case "QUIC": + name = "quic" + case "SPDY": + name = "spdy" + default: + name = strings.ToLower(name) + } + return name, version +} + +var methodLookup = map[string]attribute.KeyValue{ + http.MethodConnect: semconvNew.HTTPRequestMethodConnect, + http.MethodDelete: semconvNew.HTTPRequestMethodDelete, + http.MethodGet: semconvNew.HTTPRequestMethodGet, + http.MethodHead: semconvNew.HTTPRequestMethodHead, + http.MethodOptions: semconvNew.HTTPRequestMethodOptions, + http.MethodPatch: semconvNew.HTTPRequestMethodPatch, + http.MethodPost: semconvNew.HTTPRequestMethodPost, + http.MethodPut: semconvNew.HTTPRequestMethodPut, + http.MethodTrace: semconvNew.HTTPRequestMethodTrace, +} + +func handleErr(err error) { + if err != nil { + otel.Handle(err) + } +} + +func standardizeHTTPMethod(method string) string { + method = strings.ToUpper(method) + switch method { + case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace: + default: + method = "_OTHER" + } + return method +} diff --git a/pkg/gotenberg/semconv/util_test.go b/pkg/gotenberg/semconv/util_test.go new file mode 100644 index 00000000..9e566092 --- /dev/null +++ b/pkg/gotenberg/semconv/util_test.go @@ -0,0 +1,75 @@ +// Code generated by gotmpl. DO NOT MODIFY. +// source: internal/shared/semconv/util_test.go.tmpl + +// Copyright The OpenTelemetry Authors +// SPDX-License-Identifier: Apache-2.0 + +package semconv + +import ( + "testing" + + "github.com/stretchr/testify/assert" +) + +func TestSplitHostPort(t *testing.T) { + tests := []struct { + hostport string + host string + port int + }{ + {"", "", -1}, + {":8080", "", 8080}, + {"127.0.0.1", "127.0.0.1", -1}, + {"www.example.com", "www.example.com", -1}, + {"127.0.0.1%25en0", "127.0.0.1%25en0", -1}, + {"[]", "", -1}, // Ensure this doesn't panic. + {"[fe80::1", "", -1}, + {"[fe80::1]", "fe80::1", -1}, + {"[fe80::1%25en0]", "fe80::1%25en0", -1}, + {"[fe80::1]:8080", "fe80::1", 8080}, + {"[fe80::1]::", "", -1}, // Too many colons. + {"127.0.0.1:", "127.0.0.1", -1}, + {"127.0.0.1:port", "127.0.0.1", -1}, + {"127.0.0.1:8080", "127.0.0.1", 8080}, + {"www.example.com:8080", "www.example.com", 8080}, + {"127.0.0.1%25en0:8080", "127.0.0.1%25en0", 8080}, + } + + for _, test := range tests { + h, p := SplitHostPort(test.hostport) + assert.Equal(t, test.host, h, test.hostport) + assert.Equal(t, test.port, p, test.hostport) + } +} + +func TestStandardizeHTTPMethod(t *testing.T) { + tests := []struct { + method string + want string + }{ + {"GET", "GET"}, + {"get", "GET"}, + {"POST", "POST"}, + {"post", "POST"}, + {"PUT", "PUT"}, + {"put", "PUT"}, + {"DELETE", "DELETE"}, + {"delete", "DELETE"}, + {"HEAD", "HEAD"}, + {"head", "HEAD"}, + {"OPTIONS", "OPTIONS"}, + {"options", "OPTIONS"}, + {"CONNECT", "CONNECT"}, + {"connect", "CONNECT"}, + {"TRACE", "TRACE"}, + {"trace", "TRACE"}, + {"PATCH", "PATCH"}, + {"patch", "PATCH"}, + {"unknown", "_OTHER"}, + {"", "_OTHER"}, + } + for _, test := range tests { + assert.Equal(t, test.want, standardizeHTTPMethod(test.method)) + } +} diff --git a/pkg/gotenberg/supervisor.go b/pkg/gotenberg/supervisor.go index 63998fe7..e63a35ef 100644 --- a/pkg/gotenberg/supervisor.go +++ b/pkg/gotenberg/supervisor.go @@ -4,10 +4,9 @@ import ( "context" "errors" "fmt" + "log/slog" "sync" "sync/atomic" - - "go.uber.org/zap" ) // ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying @@ -27,15 +26,15 @@ var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded") type Process interface { // Start initiates the process and returns an error if the process cannot // be started. - Start(logger *zap.Logger) error + Start(logger *slog.Logger) error // Stop terminates the process and returns an error if the process cannot // be stopped. - Stop(logger *zap.Logger) error + Stop(logger *slog.Logger) error // Healthy checks the health of the process. It returns true if the process // is healthy; otherwise, it returns false. - Healthy(logger *zap.Logger) bool + Healthy(logger *slog.Logger) bool } // ProcessSupervisor provides methods to manage a [Process], including @@ -66,7 +65,7 @@ type ProcessSupervisor interface { // // It returns an error if the task cannot be run or if the process state // cannot be managed properly. - Run(ctx context.Context, logger *zap.Logger, task func() error) error + Run(ctx context.Context, logger *slog.Logger, task func() error) error // ReqQueueSize returns the current size of the request queue. ReqQueueSize() int64 @@ -76,7 +75,7 @@ type ProcessSupervisor interface { } type processSupervisor struct { - logger *zap.Logger + logger *slog.Logger process Process maxReqLimit int64 maxQueueSize int64 @@ -94,7 +93,7 @@ type processSupervisor struct { } // NewProcessSupervisor initializes a new [ProcessSupervisor]. -func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor { +func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor { if maxConcurrency < 1 { maxConcurrency = 1 } @@ -117,37 +116,37 @@ func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQ } func (s *processSupervisor) Launch() error { - s.logger.Debug("start process") + s.logger.DebugContext(context.Background(), "start process") err := s.process.Start(s.logger) if err != nil { return fmt.Errorf("start process: %w", err) } s.firstStart.Store(true) - s.logger.Debug("process successfully started") + s.logger.DebugContext(context.Background(), "process successfully started") return nil } func (s *processSupervisor) Shutdown() error { - s.logger.Debug("shutdown process") + s.logger.DebugContext(context.Background(), "shutdown process") err := s.process.Stop(s.logger) if err != nil { return fmt.Errorf("shutdown process: %w", err) } - s.logger.Debug("process successfully shutdown") + s.logger.DebugContext(context.Background(), "process successfully shutdown") return nil } func (s *processSupervisor) restart() error { - s.logger.Debug("restart process") + s.logger.DebugContext(context.Background(), "restart process") err := s.Shutdown() if err != nil { // No big deal? Chances are it's already stopped. - s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err)) + s.logger.DebugContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err)) } err = s.Launch() @@ -157,7 +156,7 @@ func (s *processSupervisor) restart() error { s.reqCounter.Store(0) s.restartsCounter.Add(1) - s.logger.Debug("process successfully restarted") + s.logger.DebugContext(context.Background(), "process successfully restarted") return nil } @@ -176,7 +175,7 @@ func (s *processSupervisor) Healthy() bool { return s.process.Healthy(s.logger) } -func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error { +func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error { // A user reported a potential issue: // // "Although the counting operation is atomic, nothing prevent 2 concurrent @@ -202,7 +201,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu err := func() error { select { case s.semaphore <- struct{}{}: - logger.Debug("process lock acquired") + logger.DebugContext(ctx, "process lock acquired") // If a restart drain is in progress, release the slot // immediately so the drain can acquire it instead. @@ -219,7 +218,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu defer func() { s.activeTasks.Add(-1) if releaseSemaphore { - logger.Debug("process lock released") + logger.DebugContext(ctx, "process lock released") <-s.semaphore } }() @@ -236,7 +235,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu } if !s.Healthy() { - s.logger.Debug("process is unhealthy, cannot handle task, restarting...") + s.logger.DebugContext(ctx, "process is unhealthy, cannot handle task, restarting...") err := s.doRestart(ctx) if err != nil { return fmt.Errorf("process restart before task: %w", err) @@ -248,16 +247,16 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit { // Only one goroutine should trigger the restart. if s.restartMutex.TryLock() { - s.logger.Debug("max request limit reached, restarting eagerly...") + s.logger.DebugContext(ctx, "max request limit reached, restarting eagerly...") releaseSemaphore = false go func() { restartErr := s.doRestartLocked(context.Background()) s.restartMutex.Unlock() if restartErr != nil { - s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr)) + s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr)) } - logger.Debug("process lock released") + logger.DebugContext(context.Background(), "process lock released") <-s.semaphore }() } @@ -266,7 +265,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu // Note: no error wrapping because it leaks on Chromium console exceptions output. return err case <-ctx.Done(): - logger.Debug("failed to acquire process lock before deadline") + logger.DebugContext(ctx, "failed to acquire process lock before deadline") s.reqQueueSize.Add(-1) return fmt.Errorf("acquire process lock: %w", ctx.Err()) @@ -274,7 +273,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu }() if errors.Is(err, ErrProcessAlreadyRestarting) { - logger.Debug("process is already restarting, trying to acquire process lock again...") + logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...") continue } diff --git a/pkg/gotenberg/supervisor_test.go b/pkg/gotenberg/supervisor_test.go index 8b57150f..83abdca0 100644 --- a/pkg/gotenberg/supervisor_test.go +++ b/pkg/gotenberg/supervisor_test.go @@ -3,12 +3,12 @@ package gotenberg import ( "context" "errors" + "log/slog" + "os" "sync" "sync/atomic" "testing" "time" - - "go.uber.org/zap" ) func TestProcessSupervisor_Launch(t *testing.T) { @@ -38,10 +38,10 @@ func TestProcessSupervisor_Launch(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { return tc.startError }, } @@ -86,10 +86,10 @@ func TestProcessSupervisor_Shutdown(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { return tc.stopError }, } @@ -135,13 +135,13 @@ func TestProcessSupervisor_restart(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { return tc.startError }, - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { return tc.stopError }, } @@ -194,10 +194,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - HealthyMock: func(logger *zap.Logger) bool { + HealthyMock: func(logger *slog.Logger) bool { return tc.processHealthy }, } @@ -365,7 +365,7 @@ func TestProcessSupervisor_Run(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) var startCalls, healthyCalls, stopCalls atomic.Int64 startCalls.Store(0) @@ -373,15 +373,15 @@ func TestProcessSupervisor_Run(t *testing.T) { stopCalls.Store(0) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { startCalls.Add(1) return tc.startError }, - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { stopCalls.Add(1) return nil }, - HealthyMock: func(logger *zap.Logger) bool { + HealthyMock: func(logger *slog.Logger) bool { healthyCalls.Add(1) return tc.processHealthy }, @@ -471,7 +471,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor) + ps := NewProcessSupervisor(slog.New(slog.NewJSONHandler(os.Stdout, nil)), new(ProcessMock), 0, 0, 1).(*processSupervisor) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) if tc.ctxDone { @@ -496,12 +496,12 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) { } func TestProcessSupervisor_ReqQueueSize(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { return nil }, - HealthyMock: func(logger *zap.Logger) bool { + HealthyMock: func(logger *slog.Logger) bool { return true }, } @@ -593,13 +593,13 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { return tc.startError }, - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { return tc.stopError }, } @@ -620,18 +620,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) { } func TestProcessSupervisor_ConcurrentRun(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) var startCalls atomic.Int64 process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { startCalls.Add(1) return nil }, - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { return nil }, - HealthyMock: func(logger *zap.Logger) bool { + HealthyMock: func(logger *slog.Logger) bool { return true }, } @@ -684,16 +684,16 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) { } func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { return nil }, - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { return nil }, - HealthyMock: func(logger *zap.Logger) bool { + HealthyMock: func(logger *slog.Logger) bool { return true }, } diff --git a/pkg/gotenberg/telemetry.go b/pkg/gotenberg/telemetry.go new file mode 100644 index 00000000..dd189a0a --- /dev/null +++ b/pkg/gotenberg/telemetry.go @@ -0,0 +1,238 @@ +package gotenberg + +import ( + "context" + "errors" + "fmt" + "log/slog" + "sync" + + "github.com/hashicorp/go-retryablehttp" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/metric" + "go.opentelemetry.io/otel/trace" + "go.uber.org/multierr" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log" + internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel" +) + +const ( + AutoLoggingFormat = "auto" + JsonLoggingFormat = "json" + TextLoggingFormat = "text" +) + +const ( + ErrorLoggingLevel = "error" + WarnLoggingLevel = "warn" + InfoLoggingLevel = "info" + DebugLoggingLevel = "debug" +) + +// TelemetryConfig gathers the configuration data for Gotenberg's telemetry. +type TelemetryConfig struct { + ServiceName string + ServiceVersion string + + LogLevel string + LogFieldsPrefix string + LogStdFormat string + LogStdEnableGcpFields bool +} + +func (cfg TelemetryConfig) slogLevel() slog.Level { + var level slog.Level + err := level.UnmarshalText([]byte(cfg.LogLevel)) + if err != nil { + return slog.LevelInfo + } + return level +} + +// Validate validates the telemetry configuration. +func (cfg TelemetryConfig) Validate() error { + var err error + + if cfg.ServiceName == "" { + err = multierr.Append(err, + errors.New("service name must not be empty"), + ) + } + + if cfg.ServiceVersion == "" { + err = multierr.Append(err, + errors.New("service version must not be empty"), + ) + } + + switch cfg.LogLevel { + case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel: + break + default: + err = multierr.Append( + err, + fmt.Errorf("log level must be either %s, %s, %s or %s", ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel), + ) + } + + switch cfg.LogStdFormat { + case AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat: + break + default: + err = multierr.Append( + err, + fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat), + ) + } + + return err +} + +// StartTelemetry starts the telemetry utilities. +func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) { + var handlers []slog.Handler + + stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields) + if err != nil { + return nil, fmt.Errorf("get standard logger handler: %w", err) + } + handlers = append(handlers, stdHandler) + + // We need a logger for the other providers. + // We'll use the stdHandler for now. + bootstrapLogger := slog.New(stdHandler) + + // OpenTelemetry. + var shutdowns []func(context.Context) error + + shutdownFn, err := internalotel.InitTracerProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion) + if err != nil { + return nil, fmt.Errorf("initialize OpenTelemetry tracer provider: %w", err) + } + shutdowns = append(shutdowns, shutdownFn) + + shutdownFn, err = internalotel.InitMeterProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion) + if err != nil { + return nil, fmt.Errorf("initialize OpenTelemetry meter provider: %w", err) + } + shutdowns = append(shutdowns, shutdownFn) + + shutdownFn, otelHandler, err := internalotel.InitLoggerProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion) + if err != nil { + return nil, fmt.Errorf("initialize OpenTelemetry logger provider: %w", err) + } + handlers = append(handlers, log.LevelFilter(otelHandler, cfg.slogLevel())) + shutdowns = append(shutdowns, shutdownFn) + + // Global logger. + log.InitLogger(log.NewGotenbergHandler(log.FanOut(handlers...), cfg.LogFieldsPrefix)) + + return func(ctx context.Context) error { + filterErr := func(err error) error { + if errors.Is(err, context.Canceled) { + return nil + } + return err + } + + var wg sync.WaitGroup + var errs error + var mu sync.Mutex + + for _, fn := range shutdowns { + wg.Add(1) + + go func(shutdownFn func(context.Context) error) { + defer wg.Done() + + shutdownErr := shutdownFn(ctx) + if filterErr(shutdownErr) != nil { + mu.Lock() + errs = errors.Join(errs, shutdownErr) + mu.Unlock() + } + }(fn) + } + + wg.Wait() + return errs + }, nil +} + +// Logger returns the global logger. +func Logger(mod Module) *slog.Logger { + return log.Logger().With(slog.String("logger", mod.Descriptor().ID)) +} + +const ( + // instrumentationName is the name of the OpenTelemetry instrumentation + // library. + instrumentationName = "github.com/gotenberg/gotenberg" +) + +// Tracer returns a [trace.Tracer] with the instrumentation name and version +// already set. +func Tracer() trace.Tracer { + return otel.GetTracerProvider().Tracer( + instrumentationName, + trace.WithInstrumentationVersion(Version), + ) +} + +// Meter returns a [metric.Meter] with the instrumentation name and version +// already set. +func Meter() metric.Meter { + return otel.GetMeterProvider().Meter( + instrumentationName, + metric.WithInstrumentationVersion(Version), + ) +} + +// LeveledLogger is a wrapper around a [slog.Logger] so that it may be used by a +// [retryablehttp.Client]. +type LeveledLogger struct { + logger *slog.Logger + ctx context.Context +} + +// NewLeveledLogger instantiates a [LeveledLogger]. +func NewLeveledLogger(logger *slog.Logger) *LeveledLogger { + return &LeveledLogger{ + logger: logger, + ctx: context.Background(), + } +} + +// WithContext returns a new [LeveledLogger] with the given context. +func (leveled LeveledLogger) WithContext(ctx context.Context) *LeveledLogger { + return &LeveledLogger{ + logger: leveled.logger, + ctx: ctx, + } +} + +// Error logs a message at the error level using the wrapped slog.Logger. +func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) { + leveled.logger.ErrorContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues)) +} + +// Warn logs a message at the warning level using the wrapped slog.Logger. +func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) { + leveled.logger.WarnContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues)) +} + +// Info logs a message at the info level using the wrapped slog.Logger. +func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) { + leveled.logger.InfoContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues)) +} + +// Debug logs a message at the debug level using the wrapped slog.Logger. +func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) { + leveled.logger.DebugContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues)) +} + +// Interface guards. +var ( + _ retryablehttp.LeveledLogger = (*LeveledLogger)(nil) +) diff --git a/pkg/modules/api/api.go b/pkg/modules/api/api.go index 3863bcac..6b75e75a 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "log/slog" "net" "net/http" + "os" "sort" "strings" "time" @@ -15,7 +17,6 @@ import ( "github.com/labstack/echo/v4" flag "github.com/spf13/pflag" "go.uber.org/multierr" - "go.uber.org/zap" "golang.org/x/net/http2" "golang.org/x/sync/errgroup" @@ -27,22 +28,25 @@ func init() { } // Api is a module that provides an HTTP server. Other modules may add routes, -// middlewares or health checks. +// middlewares, or health checks. type Api struct { - port int - bindIp string - tlsCertFile string - tlsKeyFile string - startTimeout time.Duration - bodyLimit int64 - timeout time.Duration - rootPath string - traceHeader string - basicAuthUsername string - basicAuthPassword string - downloadFromCfg downloadFromConfig - disableHealthCheckLogging bool - enableDebugRoute bool + port int + bindIp string + tlsCertFile string + tlsKeyFile string + startTimeout time.Duration + bodyLimit int64 + timeout time.Duration + rootPath string + basicAuthUsername string + basicAuthPassword string + correlationIdHeader string + downloadFromCfg downloadFromConfig + disableHealthCheckRouteTelemetry bool + disableRootRouteTelemetry bool + disableVersionRouteTelemetry bool + disableDebugRouteTelemetry bool + enableDebugRoute bool routes []Route externalMiddlewares []Middleware @@ -50,7 +54,7 @@ type Api struct { readyFn []func() error asyncCounters []AsynchronousCounter fs *gotenberg.FileSystem - logger *zap.Logger + logger *slog.Logger srv *echo.Echo } @@ -80,9 +84,9 @@ type Route struct { // Optional. IsMultipart bool - // DisableLogging disables the logging for this route. + // DisableTelemetry disables the telemetry and logging for this route. // Optional. - DisableLogging bool + DisableTelemetry bool // Handler is the function that handles the request. // Required. @@ -118,29 +122,6 @@ const ( // Middleware is a middleware that can be added to the [Api]'s middlewares // chain. -// -// middleware := Middleware{ -// Handler: func() echo.MiddlewareFunc { -// return func(next echo.HandlerFunc) echo.HandlerFunc { -// return func(c echo.Context) error { -// rootPath := c.Get("rootPath").(string) -// healthURI := fmt.Sprintf("%shealth", rootPath) -// -// // Skip the middleware if health check URI. -// if c.Request().RequestURI == healthURI { -// // Call the next middleware in the chain. -// return next(c) -// } -// -// // Your middleware process. -// // ... -// -// // Call the next middleware in the chain. -// return next(c) -// } -// } -// }(), -// } type Middleware struct { // Stack tells in which stack the middleware should be located. // Default to [DefaultStack]. @@ -190,14 +171,18 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor { fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests") fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc") fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") - fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") + fs.String("api-correlation-id-header", "X-Correlation-ID", "Set the header name to use to set the correlation id in the logs") fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression") fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression") fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature") - fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") + fs.Bool("api-disable-health-check-route-telemetry", false, "Disable the health check route telemetry") + fs.Bool("api-disable-root-route-telemetry", false, "Disable the root route telemetry") + fs.Bool("api-disable-version-route-telemetry", false, "Disable the version route telemetry") + fs.Bool("api-disable-debug-route-telemetry", false, "Disable the debug route telemetry") fs.Bool("api-enable-debug-route", false, "Enable the debug route") + return fs }(), New: func() gotenberg.Module { return new(Api) }, @@ -215,14 +200,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error { a.timeout = flags.MustDuration("api-timeout") a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit") a.rootPath = flags.MustString("api-root-path") - a.traceHeader = flags.MustString("api-trace-header") + a.correlationIdHeader = flags.MustString("api-correlation-id-header") a.downloadFromCfg = downloadFromConfig{ allowList: flags.MustRegexp("api-download-from-allow-list"), denyList: flags.MustRegexp("api-download-from-deny-list"), maxRetry: flags.MustInt("api-download-from-max-retry"), disable: flags.MustBool("api-disable-download-from"), } - a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") + a.disableHealthCheckRouteTelemetry = flags.MustBool("api-disable-health-check-route-telemetry") + a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry") + a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry") + a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry") a.enableDebugRoute = flags.MustBool("api-enable-debug-route") // Port from env? @@ -328,17 +316,7 @@ 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 + a.logger = gotenberg.Logger(a) // File system. a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) @@ -378,12 +356,6 @@ func (a *Api) Validate() error { ) } - if len(strings.TrimSpace(a.traceHeader)) == 0 { - err = multierr.Append(err, - errors.New("trace header must not be empty"), - ) - } - if err != nil { return err } @@ -442,28 +414,48 @@ func (a *Api) Start() error { a.srv.HTTPErrorHandler = httpErrorHandler() // Let's prepare the modules' routes. - var disableLoggingForPaths []string + var disableTelemetryForPaths []string for i, route := range a.routes { a.routes[i].Path = strings.TrimPrefix(route.Path, "/") - if route.DisableLogging { - disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/")) + if route.DisableTelemetry { + disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/")) } } - - // Check if the user wishes to add logging entries related to the health - // check route. - if a.disableHealthCheckLogging { - disableLoggingForPaths = append(disableLoggingForPaths, "health") + // Check if the user wishes to disable telemetry for system routes. + // Note: root path will be used as prefix in the underlying middlewares. + if a.disableHealthCheckRouteTelemetry { + disableTelemetryForPaths = append(disableTelemetryForPaths, "health") } + if a.disableRootRouteTelemetry { + disableTelemetryForPaths = append(disableTelemetryForPaths, "") + } + + if a.disableVersionRouteTelemetry { + disableTelemetryForPaths = append(disableTelemetryForPaths, "version") + } + + if a.disableDebugRouteTelemetry { + disableTelemetryForPaths = append(disableTelemetryForPaths, "debug") + } + + // Always disable telemetry for favicon. + disableTelemetryForPaths = append(disableTelemetryForPaths, + "favicon.ico", + ) + // Add the API middlewares. + hostname, err := os.Hostname() + if err != nil { + return fmt.Errorf("get hostname: %w", err) + } + a.srv.Pre( latencyMiddleware(), rootPathMiddleware(a.rootPath), - traceMiddleware(a.traceHeader), outputFilenameMiddleware(), - loggerMiddleware(a.logger, disableLoggingForPaths), + telemetryMiddleware(a.logger, hostname, a.correlationIdHeader, disableTelemetryForPaths), ) // Add the modules' middlewares in their respective stacks. @@ -583,7 +575,7 @@ func (a *Api) Start() error { eg.Go(f) } - err := eg.Wait() + err = eg.Wait() if err != nil { return fmt.Errorf("waiting for modules readiness: %w", err) } @@ -600,7 +592,8 @@ func (a *Api) Start() error { err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server) } if !errors.Is(err, http.ErrServerClosed) { - a.logger.Fatal(err.Error()) + a.logger.ErrorContext(context.Background(), err.Error()) + os.Exit(1) } }() @@ -627,12 +620,12 @@ func (a *Api) Stop(ctx context.Context) error { case <-ctx.Done(): return a.srv.Shutdown(ctx) default: - a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count)) + a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count)) if count > 0 { time.Sleep(1 * time.Second) continue } - a.logger.Debug("no more asynchronous requests, continue with shutdown") + a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown") err := a.srv.Shutdown(ctx) if err != nil { return fmt.Errorf("shutdown: %w", err) diff --git a/pkg/modules/api/context.go b/pkg/modules/api/context.go index 854ea05e..5412dd53 100644 --- a/pkg/modules/api/context.go +++ b/pkg/modules/api/context.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "io" + "log/slog" "mime" "mime/multipart" "net/http" @@ -19,11 +20,14 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/labstack/echo/v4" "github.com/mholt/archives" - "go.uber.org/zap" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" "golang.org/x/sync/errgroup" "golang.org/x/text/unicode/norm" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" + semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv" ) var ( @@ -36,7 +40,7 @@ var ( ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory") ) -// Context is the request context for a "multipart/form-data" requests. +// Context is the request context for a "multipart/form-data" request. type Context struct { dirPath string values map[string][]string @@ -45,7 +49,7 @@ type Context struct { outputPaths []string cancelled bool - logger *zap.Logger + logger *slog.Logger echoCtx echo.Context mkdirAll gotenberg.MkdirAll pathRename gotenberg.PathRename @@ -81,12 +85,12 @@ type downloadFrom struct { // ExtraHttpHeaders are the HTTP headers to send alongside. ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` - // Download as embed file - Embedded bool `json:"embedded"` + // Download as an attachment file. + Attachment bool `json:"attachment"` } // newContext returns a [Context] by parsing a "multipart/form-data" request. -func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) { +func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) { processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout) // We want to make sure the multipart/form-data does not exceed a given @@ -131,12 +135,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst err := os.RemoveAll(ctx.dirPath) if err != nil { - ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err)) + ctx.logger.ErrorContext(ctx, fmt.Sprintf("remove context's working directory: %s", err)) return } - ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath)) + ctx.logger.DebugContext(ctx, fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath)) ctx.cancelled = true } }() @@ -224,9 +228,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst return fmt.Errorf("filter URL: %w", err) } - logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url)) + logger.DebugContext(ctx, fmt.Sprintf("download file from '%s'", dl.Url)) - req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil) + req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, dl.Url, nil) if err != nil { return fmt.Errorf("create request to '%s': %w", dl.Url, err) } @@ -235,7 +239,20 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst for key, value := range dl.ExtraHttpHeaders { req.Header.Set(key, value) } - req.Header.Set(traceHeader, trace) + req.Header.Set(echoCtx.Get("correlationIdHeader").(string), echoCtx.Get("correlationId").(string)) + + // OpenTelemetry. + meter := gotenberg.Meter() + semconvClient := semconvutil.NewHTTPClient(meter) + + tracer := gotenberg.Tracer() + traceCtx, span := tracer.Start(ctx, fmt.Sprintf("%s Download From", req.Method), + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...), + ) + defer span.End() + + otel.GetTextMapPropagator().Inject(traceCtx, propagation.HeaderCarrier(req.Header)) client := &retryablehttp.Client{ HTTPClient: &http.Client{ @@ -244,22 +261,27 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst RetryMax: downloadFromCfg.maxRetry, RetryWaitMin: time.Duration(1) * time.Second, RetryWaitMax: time.Until(deadline), - Logger: gotenberg.NewLeveledLogger(logger), + Logger: gotenberg.NewLeveledLogger(logger).WithContext(ctx), CheckRetry: retryablehttp.DefaultRetryPolicy, Backoff: retryablehttp.DefaultBackoff, } resp, err := client.Do(req) if err != nil { + span.RecordError(err) + span.SetStatus(semconvClient.Status(0)) return WrapError( fmt.Errorf("download file from to '%s': %w", dl.Url, err), NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)), ) } + + span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...) + span.SetStatus(semconvClient.Status(resp.StatusCode)) defer func() { err := resp.Body.Close() if err != nil { - logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err)) + logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", dl.Url, err)) } }() @@ -301,7 +323,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst // normalized. // See: https://github.com/gotenberg/gotenberg/issues/662. filename = norm.NFC.String(filepath.Base(filename)) - path := fmt.Sprintf("%s/%s", ctx.dirPath, filename) + path := fmt.Sprintf("%s/%s", dirPath, filename) out, err := os.Create(path) if err != nil { @@ -310,7 +332,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst defer func() { err := out.Close() if err != nil { - logger.Error(fmt.Sprintf("close local file: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err)) } }() @@ -323,8 +345,8 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst } ctx.files[filename] = path - if dl.Embedded { - ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path) + if dl.Attachment { + ctx.filesByField[AttachmentsFormField] = append(ctx.filesByField[AttachmentsFormField], path) } return nil @@ -346,7 +368,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst defer func() { err := in.Close() if err != nil { - logger.Error(fmt.Sprintf("close file header: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close file header: %s", err)) } }() @@ -366,7 +388,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst defer func() { err := out.Close() if err != nil { - logger.Error(fmt.Sprintf("close local file: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err)) } }() @@ -394,10 +416,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst } } - ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values)) - ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) - ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField)) - ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load())) + ctx.Log().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values)) + ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files)) + ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField)) + ctx.Log().DebugContext(ctx, fmt.Sprintf("total bytes: %d", totalBytesRead.Load())) return ctx, cancel, err } @@ -444,7 +466,7 @@ func (ctx *Context) CreateSubDirectory(dirName string) (string, error) { // Rename is just a wrapper around [os.Rename], as we need to mock this // behavior in our tests. func (ctx *Context) Rename(oldpath, newpath string) error { - ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath)) + ctx.Log().DebugContext(ctx, fmt.Sprintf("rename %s to %s", oldpath, newpath)) err := ctx.pathRename.Rename(oldpath, newpath) if err != nil { return fmt.Errorf("rename path: %w", err) @@ -470,8 +492,8 @@ func (ctx *Context) AddOutputPaths(paths ...string) error { return nil } -// Log returns the context [zap.Logger]. -func (ctx *Context) Log() *zap.Logger { +// Log returns the context [slog.Logger]. +func (ctx *Context) Log() *slog.Logger { return ctx.logger } @@ -487,7 +509,7 @@ func (ctx *Context) BuildOutputFile() (string, error) { } if len(ctx.outputPaths) == 1 { - ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0])) + ctx.logger.DebugContext(ctx, fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0])) return ctx.outputPaths[0], nil } @@ -510,7 +532,7 @@ func (ctx *Context) BuildOutputFile() (string, error) { defer func(out *os.File) { err := out.Close() if err != nil { - ctx.logger.Error(fmt.Sprintf("close zip file: %s", err)) + ctx.logger.ErrorContext(ctx, fmt.Sprintf("close zip file: %s", err)) } }(out) @@ -519,7 +541,7 @@ func (ctx *Context) BuildOutputFile() (string, error) { return "", fmt.Errorf("archive output files: %w", err) } - ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath)) + ctx.logger.DebugContext(ctx, fmt.Sprintf("archive '%s' created", archivePath)) return archivePath, nil } diff --git a/pkg/modules/api/context_test.go b/pkg/modules/api/context_test.go index c0d33049..734dd696 100644 --- a/pkg/modules/api/context_test.go +++ b/pkg/modules/api/context_test.go @@ -3,14 +3,15 @@ package api import ( "bytes" "context" + "log/slog" "mime/multipart" "net/http" "net/http/httptest" + "os" "testing" "time" "github.com/labstack/echo/v4" - "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -35,14 +36,14 @@ func TestNewContext_Cancellation(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - logger := zap.NewNop() + logger := slog.New(slog.NewJSONHandler(os.Stdout, nil)) fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) timeout := time.Duration(10) * time.Second downloadFromCfg := downloadFromConfig{ disable: true, } - ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg, "trace", "trace") + ctx, cancel, err := newContext(c, logger, fs, timeout, 0, downloadFromCfg) if err != nil { t.Fatalf("expected no error from newContext, got: %v", err) } diff --git a/pkg/modules/api/formdata.go b/pkg/modules/api/formdata.go index 632a67d9..118bf0fb 100644 --- a/pkg/modules/api/formdata.go +++ b/pkg/modules/api/formdata.go @@ -17,9 +17,9 @@ import ( "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) -// EmbedsFormField represents the form field name for embedding files. +// AttachmentsFormField represents the form field name for attaching files. const ( - EmbedsFormField string = "embeds" + AttachmentsFormField string = "attachments" ) // FormData is a helper for validating and hydrating values from a @@ -365,20 +365,20 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData { return form.paths(extensions, target) } -// Embeds binds the absolute paths of form data files that should be -// embedded in the PDF. Only files uploaded with the "embeds" field name +// Attachments binds the absolute paths of form data files that should be +// attached in the PDF. Only files uploaded with the "attachments" field name // will be included. // -// var embeds []string +// var attachments []string // -// ctx.FormData().Embeds(&embeds) -func (form *FormData) Embeds(target *[]string) *FormData { +// ctx.FormData().Attachments(&attachments) +func (form *FormData) Attachments(target *[]string) *FormData { if form.errors != nil { return form } - // Get files from the "embeds" field - if paths, ok := form.filesByField[EmbedsFormField]; ok { + // Get files from the "attachments" field + if paths, ok := form.filesByField[AttachmentsFormField]; ok { *target = append(*target, paths...) } @@ -408,12 +408,12 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For // paths bind the absolute paths of form data files, according to a list of // file extensions, to a string slice variable. -// embeds are excluded. +// attachments are excluded. func (form *FormData) paths(extensions []string, target *[]string) *FormData { - embeds, ok := form.filesByField[EmbedsFormField] + attachments, ok := form.filesByField[AttachmentsFormField] for filename, path := range form.files { - if ok && slices.Contains(embeds, path) { + if ok && slices.Contains(attachments, path) { continue } diff --git a/pkg/modules/api/formdata_test.go b/pkg/modules/api/formdata_test.go index ca575836..cd623192 100644 --- a/pkg/modules/api/formdata_test.go +++ b/pkg/modules/api/formdata_test.go @@ -1613,15 +1613,15 @@ func TestFormData_Paths(t *testing.T) { expectCount: 2, }, { - scenario: "files except embeds", + scenario: "files except attachments", form: &FormData{ files: map[string]string{ - "foo.pdf": "/foo.pdf", - "embed_1.pdf": "/embed_1.pdf", - "embed_2.xml": "/embed_2.xml", + "foo.pdf": "/foo.pdf", + "attachments_1.pdf": "/attachments_1.pdf", + "attachments_2.xml": "/attachments_2.xml", }, filesByField: map[string][]string{ - "embeds": {"/embed_1.pdf", "/embed_2.xml"}, + "attachments": {"/attachments_1.pdf", "/attachments_2.xml"}, }, }, extensions: []string{".pdf"}, @@ -1759,7 +1759,7 @@ func TestFormData_mustAssign(t *testing.T) { form.mustAssign("foo", "foo", &target) } -func TestFormData_Embeds(t *testing.T) { +func TestFormData_Attachments(t *testing.T) { expected := []string{"/bar.xml", "/baz.xml"} var actual []string @@ -1770,13 +1770,13 @@ func TestFormData_Embeds(t *testing.T) { "baz.xml": "/baz.xml", }, filesByField: map[string][]string{ - "embeds": {"/bar.xml", "/baz.xml"}, + "attachments": {"/bar.xml", "/baz.xml"}, }, } - form.Embeds(&actual) + form.Attachments(&actual) if len(actual) != len(expected) { - t.Errorf("expected %d embeds but got %d", len(expected), len(actual)) + t.Errorf("expected %d attachments but got %d", len(expected), len(actual)) } if !reflect.DeepEqual(actual, expected) { diff --git a/pkg/modules/api/middlewares.go b/pkg/modules/api/middlewares.go index a9149efe..669b0923 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -5,6 +5,8 @@ import ( "crypto/subtle" "errors" "fmt" + "io" + "log/slog" "net/http" "path/filepath" "strings" @@ -13,9 +15,13 @@ import ( "github.com/google/uuid" "github.com/labstack/echo/v4" "github.com/labstack/echo/v4/middleware" - "go.uber.org/zap" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" + semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv" ) var ( @@ -79,14 +85,14 @@ func ParseError(err error) (int, string) { // returns a response as "text/plain; charset=UTF-8". func httpErrorHandler() echo.HTTPErrorHandler { return func(err error, c echo.Context) { - logger := c.Get("logger").(*zap.Logger) + logger := c.Get("logger").(*slog.Logger) status, message := ParseError(err) c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8) err = c.String(status, message) if err != nil { - logger.Error(fmt.Sprintf("send error response: %s", err.Error())) + logger.ErrorContext(c.Request().Context(), fmt.Sprintf("send error response: %s", err.Error())) } } } @@ -130,32 +136,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc { } } -// traceMiddleware sets the request identifier in the [echo.Context] under -// "trace". Its value is either retrieved from the trace header or generated if -// the header is not present / its value is empty. -// -// trace := c.Get("trace").(string) -// traceHeader := c.Get("traceHeader").(string). -func traceMiddleware(header string) echo.MiddlewareFunc { - return func(next echo.HandlerFunc) echo.HandlerFunc { - return func(c echo.Context) error { - // Get or create the request identifier. - trace := c.Request().Header.Get(header) - - if trace == "" { - trace = uuid.New().String() - } - - c.Set("trace", trace) - c.Set("traceHeader", header) - c.Response().Header().Add(header, trace) - - // Call the next middleware in the chain. - return next(c) - } - } -} - // outputFilenameMiddleware sets the output filename in the [echo.Context] // under "outputFilename". // @@ -175,59 +155,28 @@ func outputFilenameMiddleware() echo.MiddlewareFunc { } } -// loggerMiddleware sets the logger in the [echo.Context] under "logger" and -// logs a synchronous request result. +// telemetryMiddleware manages telemetry. It sets the correlation ID in the +// [echo.Context] under "correlationId". // -// logger := c.Get("logger").(*zap.Logger) -func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc { +// correlationIdHeader := c.Get("correlationIdHeader").(string) +// correlationId := c.Get("correlationId").(string) +func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader string, disableTelemetryForPaths []string) echo.MiddlewareFunc { + meter := gotenberg.Meter() + semconvSrv := semconvutil.NewHTTPServer(meter) + return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { startTime := c.Get("startTime").(time.Time) - trace := c.Get("trace").(string) rootPath := c.Get("rootPath").(string) - // Create the application logger and add it to our locals. - appLogger := logger. - With(zap.String("log_type", "application")). - With(zap.String("trace", trace)) + request := c.Request() + savedCtx := request.Context() + defer func() { + request = request.WithContext(savedCtx) + c.SetRequest(request) + }() - c.Set("logger", appLogger.Named(func() string { - return strings.ReplaceAll( - strings.ReplaceAll(c.Request().URL.Path, rootPath, ""), - "/", - "", - ) - }())) - - // Call the next middleware in the chain. - err := next(c) - if err != nil { - c.Error(err) - } - - // Create the access logger. - accessLogger := logger. - With(zap.String("log_type", "access")). - With(zap.String("trace", trace)) - - for _, path := range disableLoggingForPaths { - URI := fmt.Sprintf("%s%s", rootPath, path) - - if c.Request().RequestURI == URI { - return nil - } - } - - // Last piece for calculating the latency. - finishTime := time.Now() - - // Now, let's log! - fields := make([]zap.Field, 12) - fields[0] = zap.String("remote_ip", c.RealIP()) - fields[1] = zap.String("host", c.Request().Host) - fields[2] = zap.String("uri", c.Request().RequestURI) - fields[3] = zap.String("method", c.Request().Method) - fields[4] = zap.String("path", func() string { + routePath := func() string { path := c.Request().URL.Path if path == "" { @@ -235,21 +184,127 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo. } return path - }()) - fields[5] = zap.String("referer", c.Request().Referer()) - fields[6] = zap.String("user_agent", c.Request().UserAgent()) - fields[7] = zap.Int("status", c.Response().Status) - fields[8] = zap.Int64("latency", int64(finishTime.Sub(startTime))) - fields[9] = zap.String("latency_human", finishTime.Sub(startTime).String()) - fields[10] = zap.Int64("bytes_in", c.Request().ContentLength) - fields[11] = zap.Int64("bytes_out", c.Response().Size) + }() + + // Evaluate if we should skip telemetry for this path. + skipTelemetry := false + for _, path := range disableTelemetryForPaths { + URI := fmt.Sprintf("%s%s", rootPath, path) + if c.Request().RequestURI == URI { + skipTelemetry = true + break + } + } + + if skipTelemetry { + c.Set("logger", slog.New(slog.NewJSONHandler(io.Discard, nil))) + + err := next(c) + if err != nil { + c.Error(err) + } + return nil + } + + correlationId := request.Header.Get(correlationIdHeader) + if correlationId == "" { + correlationId = uuid.NewString() + } + c.Set("correlationIdHeader", correlationIdHeader) + c.Set("correlationId", correlationId) + + ctx := otel.GetTextMapPropagator().Extract(savedCtx, propagation.HeaderCarrier(request.Header)) + + rAttr := semconvSrv.Route(routePath) + opts := []trace.SpanStartOption{ + trace.WithAttributes( + semconvSrv.RequestTraceAttrs(serverName, request, semconvutil.RequestTraceAttrsOpts{})..., + ), + trace.WithSpanKind(trace.SpanKindServer), + trace.WithAttributes(rAttr), + } + spanName := strings.ToUpper(c.Request().Method) + " " + routePath + + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, spanName, opts...) + defer span.End() + + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(c.Response().Header())) + + c.Response().Header().Set(correlationIdHeader, correlationId) + c.SetRequest(c.Request().WithContext(ctx)) + + appLogger := logger. + With(slog.String("log_type", "application")). + With(slog.String("correlation_id", correlationId)) + + loggerName := strings.ReplaceAll( + strings.ReplaceAll(c.Request().URL.Path, rootPath, ""), + "/", + "", + ) + + c.Set("logger", appLogger.With(slog.String("logger", loggerName))) + + // Call the next middleware in the chain. + err := next(c) + finishTime := time.Now() + + status := c.Response().Status + if err != nil { + parsedStatus, _ := ParseError(err) + status = parsedStatus + + span.SetAttributes(attribute.String("error", err.Error())) + c.Error(err) + } + + span.SetStatus(semconvSrv.Status(status)) + span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconvutil.ResponseTelemetry{ + StatusCode: status, + WriteBytes: c.Response().Size, + })...) + + accessLogger := logger. + With(slog.String("log_type", "access")). + With(slog.String("correlation_id", correlationId)). + With(slog.String("remote_ip", c.RealIP())). + With(slog.String("host", c.Request().Host)). + With(slog.String("uri", c.Request().RequestURI)). + With(slog.String("method", c.Request().Method)). + With(slog.String("path", routePath)). + With(slog.String("referer", c.Request().Referer())). + With(slog.String("user_agent", c.Request().UserAgent())). + With(slog.Int("status", c.Response().Status)). + With(slog.Int64("latency", int64(finishTime.Sub(startTime)))). + With(slog.String("latency_human", finishTime.Sub(startTime).String())). + With(slog.Int64("bytes_in", c.Request().ContentLength)). + With(slog.Int64("bytes_out", c.Response().Size)) if err != nil { - accessLogger.Error(err.Error(), fields...) + accessLogger.ErrorContext(ctx, err.Error()) } else { - accessLogger.Info("request handled", fields...) + accessLogger.InfoContext(ctx, "request handled") } + additionalAttributes := []attribute.KeyValue{ + semconvSrv.Route(routePath), + } + + semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{ + ServerName: serverName, + ResponseSize: c.Response().Size, + MetricAttributes: semconvutil.MetricAttributes{ + Req: request, + StatusCode: status, + AdditionalAttributes: additionalAttributes, + }, + MetricData: semconvutil.MetricData{ + RequestSize: request.ContentLength, + ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond), + }, + }) + return nil } } @@ -276,13 +331,11 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc { func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - logger := c.Get("logger").(*zap.Logger) - traceHeader := c.Get("traceHeader").(string) - trace := c.Get("trace").(string) + logger := c.Get("logger").(*slog.Logger) // We create a context with a timeout so that underlying processes are // able to stop early and correctly handle a timeout scenario. - ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace) + ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg) if err != nil { cancel() @@ -336,7 +389,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { return func(c echo.Context) error { - logger := c.Get("logger").(*zap.Logger) + logger := c.Get("logger").(*slog.Logger) // Define a hard timeout if the route handler fails to timeout as // expected. @@ -353,7 +406,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc { // This deferred function allows us to recover from such scenarios. defer func() { if r := recover(); r != nil { - logger.Debug(fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r)) + logger.DebugContext(hardTimeoutCtx, fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r)) } }() @@ -365,7 +418,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc { case err := <-errChan: return err case <-hardTimeoutCtx.Done(): - logger.Debug("hard timeout as the route handler did not timeout as expected") + logger.DebugContext(hardTimeoutCtx, "hard timeout as the route handler did not timeout as expected") return fmt.Errorf("hard timeout: %w", hardTimeoutCtx.Err()) } diff --git a/pkg/modules/api/mocks.go b/pkg/modules/api/mocks.go index 7de02d64..6c657e71 100644 --- a/pkg/modules/api/mocks.go +++ b/pkg/modules/api/mocks.go @@ -1,9 +1,10 @@ package api import ( + "log/slog" + "github.com/alexliesenfeld/health" "github.com/labstack/echo/v4" - "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -73,8 +74,8 @@ func (ctx *ContextMock) OutputPaths() []string { // SetLogger sets the logger. // // ctx := &api.ContextMock{Context: &api.Context{}} -// ctx.SetLogger(zap.NewNop()) -func (ctx *ContextMock) SetLogger(logger *zap.Logger) { +// ctx.SetLogger(slog.New(slog.NewJSONHandler(os.Stdout, nil))) +func (ctx *ContextMock) SetLogger(logger *slog.Logger) { ctx.logger = logger } diff --git a/pkg/modules/chromium/browser.go b/pkg/modules/chromium/browser.go index 1e8f100f..70fee4f2 100644 --- a/pkg/modules/chromium/browser.go +++ b/pkg/modules/chromium/browser.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "strings" "sync" @@ -18,15 +19,18 @@ import ( "github.com/chromedp/chromedp" "github.com/dlclark/regexp2" "github.com/shirou/gopsutil/v4/process" - "go.uber.org/zap" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) type browser interface { gotenberg.Process - pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error - screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error + pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error + screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error } type browserArguments struct { @@ -72,12 +76,15 @@ func newChromiumBrowser(arguments browserArguments) browser { return b } -func (b *chromiumBrowser) Start(logger *zap.Logger) error { +func (b *chromiumBrowser) Start(logger *slog.Logger) error { if b.isStarted.Load() { return errors.New("browser is already started") } - debug := &debugLogger{logger: logger} + debug := &debugLogger{ + ctx: b.initialCtx, + logger: logger, + } b.userProfileDirPath = b.fs.NewDirPath() // See https://github.com/gotenberg/gotenberg/issues/1293. @@ -164,7 +171,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error { return nil } -func (b *chromiumBrowser) Stop(logger *zap.Logger) error { +func (b *chromiumBrowser) Stop(logger *slog.Logger) error { if !b.isStarted.Load() { // No big deal? Like calling cancel twice. return nil @@ -181,7 +188,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error { // Clean up stuck processes. ps, err := process.Processes() if err != nil { - logger.Error(fmt.Sprintf("list processes: %v", err)) + logger.ErrorContext(context.Background(), fmt.Sprintf("list processes: %v", err)) } else { for _, p := range ps { func() { @@ -199,9 +206,9 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error { err = p.KillWithContext(killCtx) if err != nil { - logger.Error(fmt.Sprintf("kill process: %v", err)) + logger.ErrorContext(killCtx, fmt.Sprintf("kill process: %v", err)) } else { - logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid)) + logger.DebugContext(killCtx, fmt.Sprintf("Chromium process %d killed", p.Pid)) } }() } @@ -215,15 +222,15 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error { err = os.RemoveAll(userProfileDirPath) if err != nil { - logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err)) + logger.ErrorContext(context.Background(), fmt.Sprintf("remove Chromium's user profile directory: %s", err)) } else { - logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) + logger.DebugContext(context.Background(), fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) } // Also, remove Chromium-specific files in the temporary directory. - err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime) + err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime) if err != nil { - logger.Error(err.Error()) + logger.ErrorContext(context.Background(), err.Error()) } }() }(copyUserProfileDirPath, expirationTime) @@ -239,7 +246,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error { return nil } -func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { +func (b *chromiumBrowser) 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. @@ -266,14 +273,14 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { return err })) if err != nil { - logger.Error(fmt.Sprintf("browser health check failed: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("browser health check failed: %s", err)) return false } return true } -func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { +func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error { // Note: no error wrapping because it leaks on errors we want to display to // the end user. return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ @@ -299,7 +306,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp }) } -func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { +func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error { // Note: no error wrapping because it leaks on errors we want to display to // the end user. return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ @@ -326,7 +333,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur }) } -func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error { +func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error { if !b.isStarted.Load() { return errors.New("browser not started, cannot handle tasks") } @@ -412,7 +419,26 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string resourceLoadingFailedMu: &resourceLoadingFailedMu, }) - err = chromedp.Run(taskCtx, tasks...) + clientCtx, clientSpan := gotenberg.Tracer().Start(taskCtx, "cdp.execute", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + semconv.ServerAddress("127.0.0.1"), + semconv.ServicePeerName("chromium"), + semconv.RPCSystemNameKey.String("cdp"), + // Legacy attribute for older APMs (Datadog, Jaeger) to draw the + // dependency graph. + attribute.String("peer.service", "chromium"), + ), + ) + + err = chromedp.Run(clientCtx, tasks...) + if err != nil { + clientSpan.RecordError(err) + clientSpan.SetStatus(codes.Error, err.Error()) + } + + clientSpan.End() + if err != nil { errMessage := err.Error() diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index b952a60b..52ffb7d1 100644 --- a/pkg/modules/chromium/chromium.go +++ b/pkg/modules/chromium/chromium.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "strings" @@ -14,7 +15,8 @@ import ( "github.com/chromedp/cdproto/network" "github.com/dlclark/regexp2" flag "github.com/spf13/pflag" - "go.uber.org/zap" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/metric" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/api" @@ -91,7 +93,7 @@ type Chromium struct { maxConcurrency int64 args browserArguments - logger *zap.Logger + logger *slog.Logger browser browser supervisor gotenberg.ProcessSupervisor engine gotenberg.PdfEngine @@ -268,7 +270,7 @@ type PdfOptions struct { PreferCssPageSize bool // GenerateDocumentOutline defines whether the document outline should be - // embedded into the PDF. + // attached into the PDF. GenerateDocumentOutline bool // GenerateTaggedPdf defines whether to generate tagged (accessible) @@ -389,8 +391,8 @@ type ExtraHttpHeader struct { // Api helps to interact with Chromium for converting HTML documents to PDF. type Api interface { - Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error - Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error + Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error + Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error } // Provider is a module interface that exposes a method for creating an [Api] @@ -428,13 +430,6 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor { fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") fs.Bool("chromium-disable-routes", false, "Disable the routes") - // Deprecated flags. - fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode") - err := fs.MarkDeprecated("chromium-incognito", "this flag is ignored as it provides no benefits") - if err != nil { - panic(err) - } - return fs }(), New: func() gotenberg.Module { return new(Chromium) }, @@ -477,15 +472,7 @@ func (mod *Chromium) 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(mod) - if err != nil { - return fmt.Errorf("get logger: %w", err) - } - mod.logger = logger.Named("browser") + mod.logger = gotenberg.Logger(mod).With(slog.String("logger", "browser")) // Process. mod.browser = newChromiumBrowser(mod.args) @@ -502,6 +489,36 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { } mod.engine = engine + // OpenTelemetry. + meter := gotenberg.Meter() + _, err = meter.Int64ObservableCounter( + "chromium.process.restarts.total", + metric.WithDescription("Current number of Chromium restarts."), + metric.WithUnit("{restart}"), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + val := mod.supervisor.RestartsCount() + o.Observe(val) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create process restarts observable counter: %w", err) + } + + _, err = meter.Int64ObservableGauge( + "chromium.requests.queue_size", + metric.WithDescription("Current number of Chromium conversion requests waiting to be treated."), + metric.WithUnit("{request}"), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + val := mod.supervisor.ReqQueueSize() + o.Observe(val) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create requests queue size observable gauge: %w", err) + } + return nil } @@ -552,7 +569,7 @@ func (mod *Chromium) StartupMessage() string { func (mod *Chromium) Stop(ctx context.Context) error { // Block until the context is done so that another module may gracefully // stop before we do a shutdown. - mod.logger.Debug("wait for the end of grace duration") + mod.logger.DebugContext(ctx, "wait for the end of grace duration") <-ctx.Done() @@ -581,26 +598,6 @@ func (mod *Chromium) Debug() map[string]any { return debug } -// Metrics returns the metrics. -func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) { - return []gotenberg.Metric{ - { - Name: "chromium_requests_queue_size", - Description: "Current number of Chromium conversion requests waiting to be treated.", - Read: func() float64 { - return float64(mod.supervisor.ReqQueueSize()) - }, - }, - { - Name: "chromium_restarts_count", - Description: "Current number of Chromium restarts.", - Read: func() float64 { - return float64(mod.supervisor.RestartsCount()) - }, - }, - }, nil -} - // Checks adds a health check that verifies if Chromium is healthy. func (mod *Chromium) Checks() ([]health.CheckerOption, error) { return []health.CheckerOption{ @@ -668,32 +665,49 @@ func (mod *Chromium) Routes() ([]api.Route, error) { } // Pdf converts a URL to PDF. -func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { +func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error { // Note: no error wrapping because it leaks on errors we want to display to // the end user. - return mod.supervisor.Run(ctx, logger, func() error { + ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Pdf") + defer span.End() + + err := mod.supervisor.Run(ctx, logger, func() error { return mod.browser.pdf(ctx, logger, url, outputPath, options) }) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } -func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { +func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error { // Note: no error wrapping because it leaks on errors we want to display to // the end user. - return mod.supervisor.Run(ctx, logger, func() error { + ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Screenshot") + defer span.End() + + err := mod.supervisor.Run(ctx, logger, func() error { return mod.browser.screenshot(ctx, logger, url, outputPath, options) }) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Interface guards. var ( - _ gotenberg.Module = (*Chromium)(nil) - _ gotenberg.Provisioner = (*Chromium)(nil) - _ gotenberg.Validator = (*Chromium)(nil) - _ gotenberg.App = (*Chromium)(nil) - _ gotenberg.Debuggable = (*Chromium)(nil) - _ gotenberg.MetricsProvider = (*Chromium)(nil) - _ api.HealthChecker = (*Chromium)(nil) - _ api.Router = (*Chromium)(nil) - _ Api = (*Chromium)(nil) - _ Provider = (*Chromium)(nil) + _ gotenberg.Module = (*Chromium)(nil) + _ gotenberg.Provisioner = (*Chromium)(nil) + _ gotenberg.Validator = (*Chromium)(nil) + _ gotenberg.App = (*Chromium)(nil) + _ gotenberg.Debuggable = (*Chromium)(nil) + _ api.HealthChecker = (*Chromium)(nil) + _ api.Router = (*Chromium)(nil) + _ Api = (*Chromium)(nil) + _ Provider = (*Chromium)(nil) ) diff --git a/pkg/modules/chromium/debug.go b/pkg/modules/chromium/debug.go index 1aabd0fa..7828378e 100644 --- a/pkg/modules/chromium/debug.go +++ b/pkg/modules/chromium/debug.go @@ -1,28 +1,29 @@ package chromium import ( + "context" "fmt" "io" - - "go.uber.org/zap" + "log/slog" ) -// debugLogger is wrapper around a [zap.Logger] which is used for debugging +// debugLogger is wrapper around a [slog.Logger] which is used for debugging // Chromium. type debugLogger struct { - logger *zap.Logger + ctx context.Context + logger *slog.Logger } // Write logs the bytes in a debug message. func (debug *debugLogger) Write(p []byte) (n int, err error) { - debug.logger.Debug(string(p)) + debug.logger.DebugContext(debug.ctx, string(p)) return len(p), nil } // Printf logs a debug message. func (debug *debugLogger) Printf(format string, v ...any) { - debug.logger.Debug(fmt.Sprintf(format, v...)) + debug.logger.DebugContext(debug.ctx, fmt.Sprintf(format, v...)) } // Interface guards. diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index 7e95c6d8..96769abe 100644 --- a/pkg/modules/chromium/events.go +++ b/pkg/modules/chromium/events.go @@ -3,6 +3,7 @@ package chromium import ( "context" "fmt" + "log/slog" "net/http" "net/url" "slices" @@ -17,7 +18,6 @@ import ( "github.com/chromedp/chromedp" "github.com/dlclark/regexp2" "go.uber.org/multierr" - "go.uber.org/zap" "golang.org/x/sync/errgroup" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" @@ -32,29 +32,29 @@ type eventRequestPausedOptions struct { // allowed or not. It also set the extra HTTP headers, if any. // See https://github.com/gotenberg/gotenberg/issues/1011. // TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now). -func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) { +func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, options eventRequestPausedOptions) { if len(options.extraHttpHeaders) == 0 { - logger.Debug("no extra HTTP headers") + logger.DebugContext(ctx, "no extra HTTP headers") } else { - logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders)) + logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders)) } chromedp.ListenTarget(ctx, func(ev any) { switch e := ev.(type) { case *fetch.EventRequestPaused: go func() { - logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL)) + logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL)) allow := true deadline, ok := ctx.Deadline() if !ok { - logger.Error("context has no deadline, cannot filter URL") + logger.ErrorContext(ctx, "context has no deadline, cannot filter URL") return } err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline) if err != nil { - logger.Warn(err.Error()) + logger.WarnContext(ctx, err.Error()) allow = false } @@ -65,7 +65,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) err = req.Do(executorCtx) if err != nil { - logger.Error(fmt.Sprintf("fail request: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("fail request: %s", err)) } return } @@ -81,25 +81,25 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option for _, header := range options.extraHttpHeaders { if header.Scope == nil { // Non-scoped header. - logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL)) + logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL)) extraHttpHeadersToSet = append(extraHttpHeadersToSet, header) continue } ok, err := header.Scope.MatchString(e.Request.URL) if err != nil { - logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) + logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) } else if ok { - logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL)) + logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL)) extraHttpHeadersToSet = append(extraHttpHeadersToSet, header) } else { - logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL)) + logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL)) } } } if len(extraHttpHeadersToSet) > 0 { - logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet)) + logger.DebugContext(ctx, fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet)) originalHeaders := e.Request.Headers headers := make(map[string]string) @@ -109,7 +109,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option if ok { headers[key] = strValue } else { - logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL)) + logger.ErrorContext(ctx, fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL)) } } @@ -132,7 +132,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option err = req.Do(executorCtx) if err != nil { - logger.Error(fmt.Sprintf("continue request: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("continue request: %s", err)) } }() } @@ -157,7 +157,7 @@ type eventResponseReceivedOptions struct { // https://github.com/gotenberg/gotenberg/issues/1021. func listenForEventResponseReceived( ctx context.Context, - logger *zap.Logger, + logger *slog.Logger, options eventResponseReceivedOptions, ) { normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains) @@ -180,7 +180,7 @@ func listenForEventResponseReceived( switch ev := ev.(type) { case *network.EventResponseReceived: if ev.Response.URL == options.mainPageUrl { - logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response)) + logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response)) if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) { options.invalidHttpStatusCodeMu.Lock() @@ -192,11 +192,11 @@ func listenForEventResponseReceived( return } - logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response)) + logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response)) if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) { if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) { - logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL)) + logger.DebugContext(ctx, fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL)) return } @@ -298,11 +298,11 @@ type eventLoadingFailedOptions struct { // https://github.com/gotenberg/gotenberg/issues/913. // https://github.com/gotenberg/gotenberg/issues/959. // https://github.com/gotenberg/gotenberg/issues/1021. -func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) { +func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) { chromedp.ListenTarget(ctx, func(ev any) { switch ev := ev.(type) { case *network.EventLoadingFailed: - logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText)) + logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText)) // We are looking for common errors. // TODO: sufficient? @@ -321,14 +321,14 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option "net::ERR_HTTP2_PROTOCOL_ERROR", } if !slices.Contains(errors, ev.ErrorText) { - logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors)) + logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors)) return } if ev.Type == network.ResourceTypeDocument { // Supposition: except iframe, an event loading failed with a // resource type Document is about the main page. - logger.Debug("event EventLoadingFailed fired for main page") + logger.DebugContext(ctx, "event EventLoadingFailed fired for main page") options.loadingFailedMu.Lock() defer options.loadingFailedMu.Unlock() @@ -338,7 +338,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option return } - logger.Debug("event EventLoadingFailed fired for a resource") + logger.DebugContext(ctx, "event EventLoadingFailed fired for a resource") options.resourceLoadingFailedMu.Lock() defer options.resourceLoadingFailedMu.Unlock() @@ -354,11 +354,11 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option // listenForEventExceptionThrown listens for exceptions in the console and // appends those exceptions to the given error pointer. // See https://github.com/gotenberg/gotenberg/issues/262. -func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) { +func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) { chromedp.ListenTarget(ctx, func(ev any) { switch ev := ev.(type) { case *runtime.EventExceptionThrown: - logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails)) + logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails)) consoleExceptionsMu.Lock() defer consoleExceptionsMu.Unlock() @@ -370,7 +370,7 @@ func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, cons // waitForEventDomContentEventFired waits until the event DomContentEventFired // is fired or the context timeout. -func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error { +func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error { return func() error { ch := make(chan struct{}) cctx, cancel := context.WithCancel(ctx) @@ -384,7 +384,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f select { case <-ch: - logger.Debug("event DomContentEventFired fired") + logger.DebugContext(ctx, "event DomContentEventFired fired") return nil case <-ctx.Done(): return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err()) @@ -394,7 +394,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f // waitForEventLoadEventFired waits until the event LoadEventFired is fired or // the context timeout. -func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() error { +func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error { return func() error { ch := make(chan struct{}) cctx, cancel := context.WithCancel(ctx) @@ -408,7 +408,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() select { case <-ch: - logger.Debug("event LoadEventFired fired") + logger.DebugContext(ctx, "event LoadEventFired fired") return nil case <-ctx.Done(): return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err()) @@ -418,7 +418,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() // waitForEventNetworkIdle waits until the event networkIdle is fired or the // context timeout. -func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() error { +func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error { return func() error { ch := make(chan struct{}) cctx, cancel := context.WithCancel(ctx) @@ -434,7 +434,7 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() err select { case <-ch: - logger.Debug("event networkIdle fired") + logger.DebugContext(ctx, "event networkIdle fired") return nil case <-ctx.Done(): return fmt.Errorf("wait for event networkIdle: %w", ctx.Err()) @@ -444,7 +444,7 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() err // waitForEventLoadingFinished waits until the event LoadingFinished is fired // or the context timeout. -func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() error { +func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error { return func() error { ch := make(chan struct{}) cctx, cancel := context.WithCancel(ctx) @@ -458,7 +458,7 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() select { case <-ch: - logger.Debug("event LoadingFinished fired") + logger.DebugContext(ctx, "event LoadingFinished fired") return nil case <-ctx.Done(): return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err()) diff --git a/pkg/modules/chromium/mocks.go b/pkg/modules/chromium/mocks.go index cbd4884d..36a2f5f1 100644 --- a/pkg/modules/chromium/mocks.go +++ b/pkg/modules/chromium/mocks.go @@ -2,38 +2,37 @@ package chromium import ( "context" - - "go.uber.org/zap" + "log/slog" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) // ApiMock is a mock for the [Api] interface. type ApiMock struct { - PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error - ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error + PdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error + ScreenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error } -func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { +func (api *ApiMock) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error { return api.PdfMock(ctx, logger, url, outputPath, options) } -func (api *ApiMock) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { +func (api *ApiMock) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error { return api.ScreenshotMock(ctx, logger, url, outputPath, options) } // browserMock is a mock for the [browser] interface. type browserMock struct { gotenberg.ProcessMock - pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error - screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error + pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error + screenshotMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error } -func (b *browserMock) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { +func (b *browserMock) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error { return b.pdfMock(ctx, logger, url, outputPath, options) } -func (b *browserMock) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { +func (b *browserMock) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error { return b.screenshotMock(ctx, logger, url, outputPath, options) } diff --git a/pkg/modules/chromium/routes.go b/pkg/modules/chromium/routes.go index 426b97a6..942a38d3 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -414,7 +414,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) - embedPaths := pdfengines.FormDataPdfEmbeds(form) + attachmentsPaths := pdfengines.FormDataPdfAttachments(form) var url string err := form. @@ -424,7 +424,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths) if err != nil { return fmt.Errorf("convert URL to PDF: %w", err) } @@ -477,7 +477,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) - embedPaths := pdfengines.FormDataPdfEmbeds(form) + attachmentsPaths := pdfengines.FormDataPdfAttachments(form) var inputPath string err := form. @@ -488,7 +488,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { } url := fmt.Sprintf("file://%s", inputPath) - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths) if err != nil { return fmt.Errorf("convert HTML to PDF: %w", err) } @@ -542,7 +542,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) - embedPaths := pdfengines.FormDataPdfEmbeds(form) + attachmentsPaths := pdfengines.FormDataPdfAttachments(form) var ( inputPath string @@ -562,7 +562,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("transform markdown file(s) to HTML: %w", err) } - err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths) + err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths) if err != nil { return fmt.Errorf("convert markdown to PDF: %w", err) } @@ -686,7 +686,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string) return fmt.Sprintf("file://%s", inputPath), nil } -func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string) error { +func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, attachmentsPaths []string) error { outputPath := ctx.GeneratePath(".pdf") // See https://github.com/gotenberg/gotenberg/issues/1130. filename := ctx.OutputFilename(outputPath) @@ -758,9 +758,9 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url return fmt.Errorf("convert PDF(s): %w", err) } - err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) + err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths) if err != nil { - return fmt.Errorf("embed files into PDFs: %w", err) + return fmt.Errorf("add attachments into PDFs: %w", err) } err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index a6df949e..b72dc28c 100644 --- a/pkg/modules/chromium/tasks.go +++ b/pkg/modules/chromium/tasks.go @@ -5,6 +5,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "time" @@ -13,16 +14,15 @@ import ( "github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/page" "github.com/chromedp/chromedp" - "go.uber.org/zap" ) -func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc { +func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc { return func(ctx context.Context) error { paperHeight := options.PaperHeight pageRanges := options.PageRanges if options.SinglePage { - logger.Debug("single page PDF") + logger.DebugContext(ctx, "single page PDF") _, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx) if err != nil { @@ -56,11 +56,11 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti options.FooterTemplate != DefaultPdfOptions().FooterTemplate if !hasCustomHeaderFooter { - logger.Debug("no custom header nor footer") + logger.DebugContext(ctx, "no custom header nor footer") printToPdf = printToPdf.WithDisplayHeaderFooter(false) } else { - logger.Debug("with custom header and/or footer") + logger.DebugContext(ctx, "with custom header and/or footer") printToPdf = printToPdf. WithDisplayHeaderFooter(true). @@ -68,7 +68,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti WithFooterTemplate(options.FooterTemplate) } - logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf)) + logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf)) _, stream, err := printToPdf.Do(ctx) if err != nil { @@ -86,7 +86,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti defer func() { err = reader.Close() if err != nil { - logger.Error(fmt.Sprintf("close reader: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err)) } }() @@ -98,7 +98,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti defer func() { err = file.Close() if err != nil { - logger.Error(fmt.Sprintf("close output path: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err)) } }() @@ -113,7 +113,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti } } -func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc { +func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc { return func(ctx context.Context) error { captureScreenshot := page.CaptureScreenshot(). WithCaptureBeyondViewport(true). @@ -134,7 +134,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options WithQuality(int64(options.Quality)) } - logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot)) + logger.DebugContext(ctx, fmt.Sprintf("capture screenshot with: %+v", captureScreenshot)) buffer, err := captureScreenshot.Do(ctx) if err != nil { @@ -149,7 +149,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options defer func() { err = file.Close() if err != nil { - logger.Error(fmt.Sprintf("close output path: %s", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err)) } }() @@ -162,9 +162,9 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options } } -func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc { +func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc { return func(ctx context.Context) error { - logger.Debug("set device metrics override") + logger.DebugContext(ctx, "set device metrics override") err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx) if err == nil { @@ -175,15 +175,15 @@ func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.Ac } } -func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc { +func clearCacheActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc { return func(ctx context.Context) error { // See https://github.com/gotenberg/gotenberg/issues/753. if !clear { - logger.Debug("cache not cleared") + logger.DebugContext(ctx, "cache not cleared") return nil } - logger.Debug("clear cache") + logger.DebugContext(ctx, "clear cache") err := network.ClearBrowserCache().Do(ctx) if err == nil { @@ -194,15 +194,15 @@ func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc { } } -func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc { +func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc { return func(ctx context.Context) error { // See https://github.com/gotenberg/gotenberg/issues/753. if !clear { - logger.Debug("cookies not cleared") + logger.DebugContext(ctx, "cookies not cleared") return nil } - logger.Debug("clear cookies") + logger.DebugContext(ctx, "clear cookies") err := network.ClearBrowserCookies().Do(ctx) if err == nil { @@ -213,15 +213,15 @@ func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc } } -func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc { +func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc { return func(ctx context.Context) error { // See https://github.com/gotenberg/gotenberg/issues/175. if !disable { - logger.Debug("JavaScript not disabled") + logger.DebugContext(ctx, "JavaScript not disabled") return nil } - logger.Debug("disable JavaScript") + logger.DebugContext(ctx, "disable JavaScript") err := emulation.SetScriptExecutionDisabled(true).Do(ctx) if err == nil { @@ -232,10 +232,10 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti } } -func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc { +func setCookiesActionFunc(logger *slog.Logger, cookies []Cookie) chromedp.ActionFunc { return func(ctx context.Context) error { if len(cookies) == 0 { - logger.Debug("no cookies to set") + logger.DebugContext(ctx, "no cookies to set") return nil } @@ -274,21 +274,21 @@ func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionF return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err) } - logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams))) + logger.DebugContext(ctx, fmt.Sprintf("set cookie %s", cookiePretty(cookieParams))) } return nil } } -func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc { +func userAgentOverride(logger *slog.Logger, userAgent string) chromedp.ActionFunc { return func(ctx context.Context) error { if len(userAgent) == 0 { - logger.Debug("no user agent override") + logger.DebugContext(ctx, "no user agent override") return nil } - logger.Debug(fmt.Sprintf("user agent override: %s", userAgent)) + logger.DebugContext(ctx, fmt.Sprintf("user agent override: %s", userAgent)) err := emulation.SetUserAgentOverride(userAgent).Do(ctx) if err == nil { return nil @@ -303,14 +303,14 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc // network.SetExtraHTTPHeaders set the headers for ALL requests from the page. // See https://github.com/gotenberg/gotenberg/issues/1011. // -//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { +//func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { // return func(ctx context.Context) error { // if len(extraHttpHeaders) == 0 { -// logger.Debug("no extra HTTP headers") +// logger.DebugContext(ctx, "no extra HTTP headers") // return nil // } // -// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders)) +// logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders)) // // headers := make(network.Headers, len(extraHttpHeaders)) // for key, value := range extraHttpHeaders { @@ -326,9 +326,9 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc // } //} -func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc { +func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc { return func(ctx context.Context) error { - logger.Debug(fmt.Sprintf("navigate to '%s'", url)) + logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url)) _, _, _, _, err := page.Navigate(url).Do(ctx) if err != nil { @@ -344,7 +344,7 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo if !skipNetworkIdleEvent { waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger)) } else { - logger.Debug("skipping network idle event") + logger.DebugContext(ctx, "skipping network idle event") } err = runBatch( @@ -360,11 +360,11 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo } } -func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, printBackground bool) chromedp.ActionFunc { +func hideDefaultWhiteBackgroundActionFunc(logger *slog.Logger, omitBackground, printBackground bool) chromedp.ActionFunc { return func(ctx context.Context) error { // See https://github.com/gotenberg/gotenberg/issues/226. if !omitBackground { - logger.Debug("default white background not hidden") + logger.DebugContext(ctx, "default white background not hidden") return nil } @@ -373,7 +373,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground) } - logger.Debug("hide default white background") + logger.DebugContext(ctx, "hide default white background") err := emulation.SetDefaultBackgroundColorOverride().WithColor( &cdp.RGBA{ @@ -391,7 +391,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr } } -func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chromedp.ActionFunc { +func forceExactColorsActionFunc(logger *slog.Logger, printBackground bool) chromedp.ActionFunc { return func(ctx context.Context) error { css := "html { -webkit-print-color-adjust: exact !important; }" if !printBackground { @@ -399,7 +399,7 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome // print of the background, whatever the printToPDF args. // See https://github.com/gotenberg/gotenberg/issues/1154. additionalCss := "html, body { background: none !important; }" - logger.Debug(fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground)) + logger.DebugContext(ctx, fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground)) css += additionalCss } @@ -423,10 +423,10 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome } } -func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc { +func emulateMediaTypeActionFunc(logger *slog.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc { return func(ctx context.Context) error { if mediaType == "" && len(mediaFeatures) == 0 { - logger.Debug("no emulated media type or features") + logger.DebugContext(ctx, "no emulated media type or features") return nil } @@ -437,12 +437,12 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu emulatedMedia := emulation.SetEmulatedMedia() if mediaType != "" { - logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType)) + logger.DebugContext(ctx, fmt.Sprintf("emulate media type '%s'", mediaType)) emulatedMedia = emulatedMedia.WithMedia(mediaType) } if len(mediaFeatures) > 0 { - logger.Debug(fmt.Sprintf("emulate media features %+v", mediaFeatures)) + logger.DebugContext(ctx, fmt.Sprintf("emulate media features %+v", mediaFeatures)) features := make([]*emulation.MediaFeature, len(mediaFeatures)) for i, f := range mediaFeatures { @@ -464,21 +464,21 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu } } -func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc { +func waitDelayBeforePrintActionFunc(logger *slog.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc { return func(ctx context.Context) error { if disableJavaScript { - logger.Debug("JavaScript disabled, skipping wait delay") + logger.DebugContext(ctx, "JavaScript disabled, skipping wait delay") return nil } if delay <= 0 { - logger.Debug("no wait delay") + logger.DebugContext(ctx, "no wait delay") return nil } // We wait for a given amount of time so that JavaScript // scripts have a chance to finish before printing the page. - logger.Debug(fmt.Sprintf("wait '%s' before print", delay)) + logger.DebugContext(ctx, fmt.Sprintf("wait '%s' before print", delay)) select { case <-ctx.Done(): @@ -489,21 +489,21 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, } } -func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc { +func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc { return func(ctx context.Context) error { if disableJavaScript { - logger.Debug("JavaScript disabled, skipping wait expression") + logger.DebugContext(ctx, "JavaScript disabled, skipping wait expression") return nil } if expression == "" { - logger.Debug("no wait expression") + logger.DebugContext(ctx, "no wait expression") return nil } // We wait until the evaluation of the expression is true or // until the context is done. - logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression)) + logger.DebugContext(ctx, fmt.Sprintf("wait until '%s' is true before print", expression)) ticker := time.NewTicker(time.Duration(100) * time.Millisecond) for { @@ -531,14 +531,14 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip } } -func waitForSelectorVisibleBeforePrintActionFunc(logger *zap.Logger, selector string) chromedp.ActionFunc { +func waitForSelectorVisibleBeforePrintActionFunc(logger *slog.Logger, selector string) chromedp.ActionFunc { return func(ctx context.Context) error { if selector == "" { - logger.Debug("no wait selector") + logger.DebugContext(ctx, "no wait selector") return nil } - logger.Debug(fmt.Sprintf("wait until '%s' is visible before print", selector)) + logger.DebugContext(ctx, fmt.Sprintf("wait until '%s' is visible before print", selector)) err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx) if err != nil { return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery) diff --git a/pkg/modules/exiftool/exiftool.go b/pkg/modules/exiftool/exiftool.go index bbaede3e..a497248f 100644 --- a/pkg/modules/exiftool/exiftool.go +++ b/pkg/modules/exiftool/exiftool.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "reflect" @@ -11,7 +12,8 @@ import ( "syscall" "github.com/barasher/go-exiftool" - "go.uber.org/zap" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -74,64 +76,118 @@ func (engine *ExifTool) Debug() map[string]any { } // Merge is not available in this implementation. -func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Split is not available in this implementation. -func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return nil, err } // Flatten is not available in this implementation. -func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { - return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Convert is not available in this implementation. -func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // ReadMetadata extracts the metadata of a given PDF file. -func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +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.SpanKindInternal)) + defer span.End() + exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) if err != nil { - return nil, fmt.Errorf("new ExifTool: %w", err) + err = fmt.Errorf("new ExifTool: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } defer func(exifTool *exiftool.Exiftool) { err := exifTool.Close() if err != nil { - logger.Error(fmt.Sprintf("close ExifTool: %v", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err)) } }(exifTool) fileMetadata := exifTool.ExtractMetadata(inputPath) if fileMetadata[0].Err != nil { - return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err) + err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } return fileMetadata[0].Fields, nil } // WriteMetadata writes the metadata into a given PDF file. -func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +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.SpanKindInternal)) + defer span.End() + exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) if err != nil { - return fmt.Errorf("new ExifTool: %w", err) + err = fmt.Errorf("new ExifTool: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } defer func(exifTool *exiftool.Exiftool) { err := exifTool.Close() if err != nil { - logger.Error(fmt.Sprintf("close ExifTool: %v", err)) + logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err)) } }(exifTool) fileMetadata := exifTool.ExtractMetadata(inputPath) if fileMetadata[0].Err != nil { - return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err) + err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } // Define a list of derived, system, or computed tags that ExifTool @@ -175,7 +231,10 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m strs[i] = str continue } - return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported) + err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } fileMetadata[0].SetStrings(key, strs) case bool: @@ -191,36 +250,50 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m // TODO: support more complex cases, e.g., arrays and nested objects // (limitations in underlying library). default: - return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported) + err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } } exifTool.WriteMetadata(fileMetadata) if fileMetadata[0].Err != nil { - return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err) + err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } return nil } -// WriteBookmarks is not available in this implementation. -func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} - -// ReadBookmarks is not available in this implementation. -func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { - return nil, fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} - // Encrypt is not available in this implementation. -func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) +func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } -// EmbedFiles is not available in this implementation. -func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { - return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) +// AddAttachments is not available in this implementation. +func (engine *ExifTool) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "ExifTool.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("add attachments with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Interface guards. diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 90d26892..00f2f08a 100644 --- a/pkg/modules/libreoffice/api/api.go +++ b/pkg/modules/libreoffice/api/api.go @@ -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/codes" + "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,7 +47,7 @@ type Api struct { autoStart bool args libreOfficeArguments - logger *zap.Logger + logger *slog.Logger libreOffice libreOffice supervisor gotenberg.ProcessSupervisor } @@ -187,7 +189,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 } @@ -241,20 +243,42 @@ 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) + // OpenTelemetry. + meter := gotenberg.Meter() + _, 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 { + val := a.supervisor.RestartsCount() + o.Observe(val) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create process restarts observable counter: %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 { + val := a.supervisor.ReqQueueSize() + o.Observe(val) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create requests queue size observable gauge: %w", err) + } + return nil } @@ -303,7 +327,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() @@ -332,26 +356,6 @@ func (a *Api) Debug() map[string]any { return debug } -// Metrics returns the metrics. -func (a *Api) Metrics() ([]gotenberg.Metric, error) { - return []gotenberg.Metric{ - { - Name: "libreoffice_requests_queue_size", - Description: "Current number of LibreOffice conversion requests waiting to be treated.", - Read: func() float64 { - return float64(a.supervisor.ReqQueueSize()) - }, - }, - { - Name: "libreoffice_restarts_count", - Description: "Current number of LibreOffice restarts.", - Read: func() float64 { - return float64(a.supervisor.RestartsCount()) - }, - }, - }, nil -} - // Checks adds a health check that verifies if LibreOffice is healthy. func (a *Api) Checks() ([]health.CheckerOption, error) { return []health.CheckerOption{ @@ -402,7 +406,10 @@ 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 { + ctx, span := gotenberg.Tracer().Start(ctx, "LibreOffice.Pdf") + defer span.End() + err := a.supervisor.Run(ctx, logger, func() error { return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options) }) @@ -413,11 +420,20 @@ func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath // See https://github.com/gotenberg/gotenberg/issues/639. if errors.Is(err, ErrCoreDumped) { - logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err)) - return a.Pdf(ctx, logger, inputPath, outputPath, options) + logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err)) + err = a.Pdf(ctx, logger, inputPath, outputPath, options) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + return err } - return fmt.Errorf("supervisor run task: %w", err) + err = fmt.Errorf("supervisor run task: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err } // Extensions returns the file extensions available for conversions. @@ -559,13 +575,12 @@ func (a *Api) Extensions() []string { // Interface guards. var ( - _ gotenberg.Module = (*Api)(nil) - _ gotenberg.Provisioner = (*Api)(nil) - _ gotenberg.Validator = (*Api)(nil) - _ gotenberg.App = (*Api)(nil) - _ gotenberg.Debuggable = (*Api)(nil) - _ gotenberg.MetricsProvider = (*Api)(nil) - _ api.HealthChecker = (*Api)(nil) - _ Uno = (*Api)(nil) - _ Provider = (*Api)(nil) + _ gotenberg.Module = (*Api)(nil) + _ gotenberg.Provisioner = (*Api)(nil) + _ gotenberg.Validator = (*Api)(nil) + _ gotenberg.App = (*Api)(nil) + _ gotenberg.Debuggable = (*Api)(nil) + _ api.HealthChecker = (*Api)(nil) + _ Uno = (*Api)(nil) + _ Provider = (*Api)(nil) ) diff --git a/pkg/modules/libreoffice/api/freeport.go b/pkg/modules/libreoffice/api/freeport.go index 0a3e71d8..7409a5dc 100644 --- a/pkg/modules/libreoffice/api/freeport.go +++ b/pkg/modules/libreoffice/api/freeport.go @@ -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())) } }() diff --git a/pkg/modules/libreoffice/api/libreoffice.go b/pkg/modules/libreoffice/api/libreoffice.go index 9ee9e353..83e4f416 100644 --- a/pkg/modules/libreoffice/api/libreoffice.go +++ b/pkg/modules/libreoffice/api/libreoffice.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net" "os" "strings" @@ -11,14 +12,17 @@ import ( "sync/atomic" "time" - "go.uber.org/zap" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/codes" + semconv "go.opentelemetry.io/otel/semconv/v1.39.0" + "go.opentelemetry.io/otel/trace" "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 +52,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 +90,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(ctx, "got exit code 81, e.g., LibreOffice first start") // Second start (daemon). cmd = gotenberg.Command(logger, p.arguments.binPath, args...) @@ -123,7 +127,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(ctx, fmt.Sprintf("close connection after health checking the LibreOffice: %v", err)) } break @@ -148,19 +152,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(ctx, "waiting for the LibreOffice socket to be available...") for { select { @@ -169,7 +173,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(ctx, "LibreOffice socket available") success = true return nil @@ -179,7 +183,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 +196,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 +225,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 +241,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 +250,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 +263,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") } @@ -332,14 +335,35 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP args = append(args, "--output", outputPath, inputPath) - cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...) + p.cfgMu.RLock() + clientCtx, clientSpan := gotenberg.Tracer().Start(ctx, "uno.execute", + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes( + semconv.ServerAddress("127.0.0.1"), + semconv.ServerPort(p.socketPort), + semconv.ServicePeerName("libreoffice"), + // Legacy attribute for older APMs (Datadog, Jaeger) to draw the + // dependency graph. + attribute.String("peer.service", "libreoffice"), + ), + ) + p.cfgMu.RUnlock() + + cmd, err := gotenberg.CommandContext(clientCtx, logger, p.arguments.unoBinPath, args...) if err != nil { return fmt.Errorf("create uno command: %w", err) } - logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) + logger.DebugContext(clientCtx, fmt.Sprintf("print to PDF with: %+v", options)) exitCode, err := cmd.Exec() + if err != nil { + clientSpan.RecordError(err) + clientSpan.SetStatus(codes.Error, err.Error()) + } + + clientSpan.End() + if err == nil { return nil } diff --git a/pkg/modules/libreoffice/api/mocks.go b/pkg/modules/libreoffice/api/mocks.go index 9c8c5c1a..7ba3283e 100644 --- a/pkg/modules/libreoffice/api/mocks.go +++ b/pkg/modules/libreoffice/api/mocks.go @@ -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 diff --git a/pkg/modules/libreoffice/pdfengine/pdfengine.go b/pkg/modules/libreoffice/pdfengine/pdfengine.go index 52aecb5e..8462c578 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -4,8 +4,10 @@ import ( "context" "errors" "fmt" + "log/slog" - "go.uber.org/zap" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" @@ -47,25 +49,55 @@ func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error { } // Merge is not available in this implementation. -func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { - return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Merge", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Split is not available in this implementation. -func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { - return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Split", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return nil, err } // Flatten is not available in this implementation. -func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { - return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Convert converts the given PDF to a specific PDF format. Currently, only the // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // error. -func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { +func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Convert", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + opts := api.DefaultOptions() opts.PdfFormats = formats err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts) @@ -75,40 +107,73 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log } if errors.Is(err, api.ErrInvalidPdfFormats) { - return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported) + err = fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported) + } else { + err = fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err) } - return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // ReadMetadata is not available in this implementation. -func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return nil, err } // WriteMetadata is not available in this implementation. -func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} +func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() -// WriteBookmarks is not available in this implementation. -func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} + err := fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } -// ReadBookmarks is not available in this implementation. -func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { - return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + return err } // Encrypt is not available in this implementation. -func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { - return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } -// EmbedFiles is not available in this implementation. -func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { - return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) +// AddAttachments is not available in this implementation. +func (engine *LibreOfficePdfEngine) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("add attachments with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Interface guards. diff --git a/pkg/modules/libreoffice/routes.go b/pkg/modules/libreoffice/routes.go index 854f347a..08353270 100644 --- a/pkg/modules/libreoffice/routes.go +++ b/pkg/modules/libreoffice/routes.go @@ -31,7 +31,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap pdfFormats := pdfengines.FormDataPdfFormats(form) metadata := pdfengines.FormDataPdfMetadata(form, false) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) - embedPaths := pdfengines.FormDataPdfEmbeds(form) + attachmentsPaths := pdfengines.FormDataPdfAttachments(form) zeroValuedSplitMode := gotenberg.SplitMode{} @@ -253,9 +253,9 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap } } - err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths) + err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, outputPaths) if err != nil { - return fmt.Errorf("embed files into PDFs: %w", err) + return fmt.Errorf("add attachments into PDFs: %w", err) } err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) diff --git a/pkg/modules/logging/doc.go b/pkg/modules/logging/doc.go deleted file mode 100644 index 1d04f60b..00000000 --- a/pkg/modules/logging/doc.go +++ /dev/null @@ -1,3 +0,0 @@ -// Package logging provides a module which creates a zap.Logger instance for -// other modules. -package logging diff --git a/pkg/modules/logging/gcp.go b/pkg/modules/logging/gcp.go deleted file mode 100644 index e9ddc9f6..00000000 --- a/pkg/modules/logging/gcp.go +++ /dev/null @@ -1,36 +0,0 @@ -package logging - -import "go.uber.org/zap/zapcore" - -func gcpSeverity(l zapcore.Level) string { - switch l { - case zapcore.DebugLevel: - return "DEBUG" - case zapcore.InfoLevel: - return "INFO" - case zapcore.WarnLevel: - return "WARNING" - case zapcore.ErrorLevel: - return "ERROR" - case zapcore.DPanicLevel: - return "CRITICAL" - case zapcore.PanicLevel: - return "ALERT" - case zapcore.FatalLevel: - return "EMERGENCY" - case zapcore.InvalidLevel: - return "DEFAULT" - default: - return "DEFAULT" - } -} - -func gcpSeverityEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { - enc.AppendString(gcpSeverity(l)) -} - -func gcpSeverityColorEncoder(l zapcore.Level, enc zapcore.PrimitiveArrayEncoder) { - severity := gcpSeverity(l) - c := levelToColor(l) - enc.AppendString(c.Add(severity)) -} diff --git a/pkg/modules/logging/logging.go b/pkg/modules/logging/logging.go deleted file mode 100644 index 9d4220d6..00000000 --- a/pkg/modules/logging/logging.go +++ /dev/null @@ -1,239 +0,0 @@ -package logging - -import ( - "fmt" - "os" - "time" - - flag "github.com/spf13/pflag" - "go.uber.org/multierr" - "go.uber.org/zap" - "go.uber.org/zap/zapcore" - "golang.org/x/term" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" -) - -func init() { - gotenberg.MustRegisterModule(new(Logging)) -} - -const ( - errorLoggingLevel = "error" - warnLoggingLevel = "warn" - infoLoggingLevel = "info" - debugLoggingLevel = "debug" -) - -const ( - autoLoggingFormat = "auto" - jsonLoggingFormat = "json" - textLoggingFormat = "text" -) - -// Logging is a module that implements the [gotenberg.LoggerProvider] -// interface. -type Logging struct { - level string - format string - fieldsPrefix string - enableGcpFields bool -} - -// Descriptor returns a [Logging]'s module descriptor. -func (log *Logging) Descriptor() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ - ID: "logging", - FlagSet: func() *flag.FlagSet { - fs := flag.NewFlagSet("logging", flag.ExitOnError) - fs.String("log-level", infoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel)) - fs.String("log-format", autoLoggingFormat, fmt.Sprintf("Specify the format of logging. Options include %s, %s, or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat)) - fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs") - fs.Bool("log-enable-gcp-fields", false, "Enable Google Cloud Platform fields - namely: time, message, severity") - - // Deprecated flags. - fs.Bool("log-enable-gcp-severity", false, "Enable Google Cloud Platform severity mapping") - err := fs.MarkDeprecated("log-enable-gcp-severity", "use log-enable-gcp-fields instead") - if err != nil { - panic(err) - } - - return fs - }(), - New: func() gotenberg.Module { return new(Logging) }, - } -} - -// Provision sets the log level and format. -func (log *Logging) Provision(ctx *gotenberg.Context) error { - flags := ctx.ParsedFlags() - - log.level = flags.MustString("log-level") - log.format = flags.MustString("log-format") - log.fieldsPrefix = flags.MustString("log-fields-prefix") - log.enableGcpFields = flags.MustDeprecatedBool("log-enable-gcp-severity", "log-enable-gcp-fields") - - return nil -} - -// Validate validates the log level and format. -func (log *Logging) Validate() error { - var err error - - switch log.level { - case errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel: - break - default: - err = multierr.Append( - err, - fmt.Errorf("log level must be either %s, %s, %s or %s", errorLoggingLevel, warnLoggingLevel, infoLoggingLevel, debugLoggingLevel), - ) - } - - switch log.format { - case autoLoggingFormat, jsonLoggingFormat, textLoggingFormat: - break - default: - err = multierr.Append( - err, - fmt.Errorf("log format must be either %s, %s or %s", autoLoggingFormat, jsonLoggingFormat, textLoggingFormat), - ) - } - - return err -} - -// Logger returns a [zap.Logger]. -func (log *Logging) Logger(mod gotenberg.Module) (*zap.Logger, error) { - if logger == nil { - lvl, err := newLogLevel(log.level) - if err != nil { - return nil, fmt.Errorf("get log level: %w", err) - } - - encoder, err := newLogEncoder(log.format, log.enableGcpFields) - if err != nil { - return nil, fmt.Errorf("get log encoder: %w", err) - } - - logger = zap.New(customCore{ - Core: zapcore.NewCore(encoder, os.Stderr, lvl), - fieldsPrefix: log.fieldsPrefix, - }) - } - - return logger.Named(mod.Descriptor().ID), nil -} - -// See https://github.com/gotenberg/gotenberg/issues/659. -type customCore struct { - zapcore.Core - fieldsPrefix string -} - -func (c customCore) With(fields []zapcore.Field) zapcore.Core { - if c.fieldsPrefix != "" { - for i := range fields { - fields[i].Key = c.fieldsPrefix + "_" + fields[i].Key - } - } - - return customCore{ - Core: c.Core.With(fields), - fieldsPrefix: c.fieldsPrefix, - } -} - -func (c customCore) Check(ent zapcore.Entry, ce *zapcore.CheckedEntry) *zapcore.CheckedEntry { - // This is a copy from the zapcore.ioCore implementation. Indeed, by doing - // so, we are able to prefix the fields given to the logger methods like - // Debug, Info, Warn, Error, etc. - if c.Enabled(ent.Level) { - return ce.AddCore(ent, c) - } - - return ce -} - -func (c customCore) Write(entry zapcore.Entry, fields []zapcore.Field) error { - if c.fieldsPrefix != "" { - for i := range fields { - fields[i].Key = c.fieldsPrefix + "_" + fields[i].Key - } - } - - return c.Core.Write(entry, fields) -} - -func newLogLevel(level string) (zapcore.Level, error) { - lvl := zapcore.InvalidLevel - - err := lvl.UnmarshalText([]byte(level)) - if err != nil { - return lvl, fmt.Errorf("%q is not a recognized log level: %w", level, err) - } - - return lvl, nil -} - -func newLogEncoder(format string, gcpFields bool) (zapcore.Encoder, error) { - isTerminal := term.IsTerminal(int(os.Stdout.Fd())) // #nosec - encCfg := zap.NewProductionEncoderConfig() - - // Normalize the log format based on the output device. - if format == autoLoggingFormat { - if isTerminal { - format = textLoggingFormat - } else { - format = jsonLoggingFormat - } - } - - // Use a human-readable time format if running in a terminal. - if isTerminal { - encCfg.EncodeTime = func(ts time.Time, encoder zapcore.PrimitiveArrayEncoder) { - encoder.AppendString(ts.Local().Format("2006/01/02 15:04:05.000")) - } - } - - // Configure level encoding based on format and GCP settings. - if format == textLoggingFormat && isTerminal { - if gcpFields { - encCfg.EncodeLevel = gcpSeverityColorEncoder - } else { - encCfg.EncodeLevel = zapcore.CapitalColorLevelEncoder - } - } - - // For non-text (JSON) or when GCP fields are requested outside a terminal text output, - // adjust the configuration to use GCP-specific field names and encoders. - if gcpFields && format != textLoggingFormat { - encCfg.EncodeLevel = gcpSeverityEncoder - encCfg.TimeKey = "time" - encCfg.LevelKey = "severity" - encCfg.MessageKey = "message" - encCfg.EncodeTime = zapcore.ISO8601TimeEncoder - encCfg.EncodeDuration = zapcore.MillisDurationEncoder - } - - switch format { - case textLoggingFormat: - return zapcore.NewConsoleEncoder(encCfg), nil - case jsonLoggingFormat: - return zapcore.NewJSONEncoder(encCfg), nil - default: - return nil, fmt.Errorf("%s is not a recognized log format", format) - } -} - -// Singleton so that we instantiate our logger only once. -var logger *zap.Logger = nil - -// Interface guards. -var ( - _ gotenberg.Module = (*Logging)(nil) - _ gotenberg.Provisioner = (*Logging)(nil) - _ gotenberg.Validator = (*Logging)(nil) - _ gotenberg.LoggerProvider = (*Logging)(nil) - _ zapcore.Core = (*customCore)(nil) -) diff --git a/pkg/modules/pdfcpu/pdfcpu.go b/pkg/modules/pdfcpu/pdfcpu.go index 8b4a02d0..df90efa6 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -1,11 +1,10 @@ package pdfcpu import ( - "bytes" "context" - "encoding/json" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -13,7 +12,8 @@ import ( "strings" "syscall" - "go.uber.org/zap" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -28,16 +28,6 @@ type PdfCpu struct { binPath string } -type pdfcpuBookmark struct { - Title string `json:"title"` - Page int `json:"page"` - Children []pdfcpuBookmark `json:"kids,omitempty"` -} - -type pdfcpuBookmarks struct { - Bookmarks []pdfcpuBookmark `json:"bookmarks"` -} - // Descriptor returns a [PdfCpu]'s module descriptor. func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor { return gotenberg.ModuleDescriptor{ @@ -95,14 +85,20 @@ func (engine *PdfCpu) Debug() map[string]any { } // Merge combines multiple PDFs into a single PDF. -func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { +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.SpanKindInternal)) + defer span.End() + args := make([]string, 0, 2+len(inputPaths)) args = append(args, "merge", outputPath) args = append(args, inputPaths...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() @@ -110,11 +106,19 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths return nil } - return fmt.Errorf("merge PDFs with pdfcpu: %w", err) + err = fmt.Errorf("merge PDFs with pdfcpu: %w", err) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + return err } // Split splits a given PDF file. -func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { +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.SpanKindInternal)) + defer span.End() + var args []string switch mode.Mode { @@ -128,17 +132,26 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb } args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath) default: - return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + err := fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return nil, fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } _, err = cmd.Exec() if err != nil { - return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err) + err = fmt.Errorf("split PDFs with pdfcpu: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } var outputPaths []string @@ -155,7 +168,10 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb return nil }) if err != nil { - return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err) + err = fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } sort.Sort(digitSuffixSort(outputPaths)) @@ -164,167 +180,72 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb } // Flatten is not available in this implementation. -func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { - return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Convert is not available in this implementation. -func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // ReadMetadata is not available in this implementation. -func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return nil, err } // WriteMetadata is not available in this implementation. -func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } -// ReadBookmarks reads the document outline (bookmarks) of a PDF file using pdfcpu. -func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { - tmpPath := fmt.Sprintf("%s.read.json", inputPath) - args := []string{"bookmarks", "export", inputPath, tmpPath} - cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) - if err != nil { - return nil, fmt.Errorf("create command: %w", err) - } +// AddAttachments adds attachments into a PDF. All files are attached as +// file attachments without modifying the main PDF content. +func (engine *PdfCpu) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "PdfCpu.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() - defer func() { - err := os.Remove(tmpPath) - if err != nil && !os.IsNotExist(err) { - logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err)) - } - }() - - _, cmdErr := cmd.Exec() - - // Check file existence and size. - info, statErr := os.Stat(tmpPath) - - if cmdErr != nil { - // If the file wasn't created, or it was created but is 0 bytes, - // it means pdfcpu had no bookmarks to write. - if os.IsNotExist(statErr) || (statErr == nil && info.Size() == 0) { - return make([]gotenberg.Bookmark, 0), nil - } - - // Fallback: Check the error string just in case pdfcpu failed without - // touching the file. - if strings.Contains(strings.ToLower(cmdErr.Error()), "no bookmarks") { - return make([]gotenberg.Bookmark, 0), nil - } - return nil, fmt.Errorf("read bookmarks with pdfcpu: %w", cmdErr) - } - - // If cmd succeeded, but output a 0-byte file anyway. - if info != nil && info.Size() == 0 { - return make([]gotenberg.Bookmark, 0), nil - } - - // Read the file content. - jsonBytes, err := os.ReadFile(tmpPath) - if err != nil { - return nil, fmt.Errorf("read temporary bookmarks JSON file: %w", err) - } - - // Check if the content is just empty whitespace. - if len(bytes.TrimSpace(jsonBytes)) == 0 { - return make([]gotenberg.Bookmark, 0), nil - } - - var data pdfcpuBookmarks - err = json.Unmarshal(jsonBytes, &data) - if err != nil { - return nil, fmt.Errorf("unmarshal bookmarks: %w", err) - } - - // Safety check: Does the parsed JSON actually contain bookmarks? - if len(data.Bookmarks) == 0 { - return make([]gotenberg.Bookmark, 0), nil - } - - var mapBookmarks func(bookmarks []pdfcpuBookmark) []gotenberg.Bookmark - mapBookmarks = func(bookmarks []pdfcpuBookmark) []gotenberg.Bookmark { - res := make([]gotenberg.Bookmark, len(bookmarks)) - for i, b := range bookmarks { - res[i] = gotenberg.Bookmark{ - Title: b.Title, - Page: b.Page, - Children: mapBookmarks(b.Children), - } - } - return res - } - - return mapBookmarks(data.Bookmarks), nil -} - -// WriteBookmarks adds a document outline (bookmarks) to a PDF file using pdfcpu. -func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - if len(bookmarks) == 0 { - return nil - } - - var mapBookmarks func(bookmarks []gotenberg.Bookmark) []pdfcpuBookmark - mapBookmarks = func(bookmarks []gotenberg.Bookmark) []pdfcpuBookmark { - res := make([]pdfcpuBookmark, len(bookmarks)) - for i, b := range bookmarks { - res[i] = pdfcpuBookmark{ - Title: b.Title, - Page: b.Page, - Children: mapBookmarks(b.Children), - } - } - return res - } - - data := pdfcpuBookmarks{ - Bookmarks: mapBookmarks(bookmarks), - } - - jsonBytes, err := json.Marshal(data) - if err != nil { - return fmt.Errorf("marshal bookmarks: %w", err) - } - - tmpPath := fmt.Sprintf("%s.json", inputPath) - err = os.WriteFile(tmpPath, jsonBytes, 0o600) - if err != nil { - return fmt.Errorf("write temporary bookmarks JSON file: %w", err) - } - - defer func() { - err := os.Remove(tmpPath) - if err != nil { - logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err)) - } - }() - - args := []string{"bookmarks", "import", "-replace", inputPath, tmpPath, inputPath} - cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) - if err != nil { - return fmt.Errorf("create command: %w", err) - } - - _, err = cmd.Exec() - if err != nil { - return fmt.Errorf("write bookmarks with pdfcpu: %w", err) - } - - return nil -} - -// EmbedFiles embeds files into a PDF. All files are embedded as file attachments -// without modifying the main PDF content. -func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { if len(filePaths) == 0 { return nil } - logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths)) + logger.DebugContext(ctx, fmt.Sprintf("attaching %d file(s) to %s: %v", len(filePaths), inputPath, filePaths)) args := make([]string, 0, 3+len(filePaths)) args = append(args, "attachments", "add", inputPath) @@ -332,21 +253,33 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePa cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command for attaching files: %w", err) + err = fmt.Errorf("create command for attaching files: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() if err != nil { - return fmt.Errorf("attach files with pdfcpu: %w", err) + err = fmt.Errorf("attach files with pdfcpu: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } return nil } // Encrypt adds password protection to a PDF file using pdfcpu. -func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + if userPassword == "" { - return errors.New("user password cannot be empty") + err := errors.New("user password cannot be empty") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } if ownerPassword == "" { @@ -363,12 +296,18 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() if err != nil { - return fmt.Errorf("encrypt PDF with pdfcpu: %w", err) + err = fmt.Errorf("encrypt PDF with pdfcpu: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } return nil diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index 6076ff5b..e9735823 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -1,27 +1,28 @@ +//nolint:dupl package pdfengines import ( "context" "fmt" + "log/slog" "sync" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "go.uber.org/multierr" - "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) type multiPdfEngines struct { - mergeEngines []gotenberg.PdfEngine - splitEngines []gotenberg.PdfEngine - flattenEngines []gotenberg.PdfEngine - convertEngines []gotenberg.PdfEngine - readMetadataEngines []gotenberg.PdfEngine - writeMetadataEngines []gotenberg.PdfEngine - passwordEngines []gotenberg.PdfEngine - embedEngines []gotenberg.PdfEngine - readBookmarksEngines []gotenberg.PdfEngine - writeBookmarksEngines []gotenberg.PdfEngine + mergeEngines []gotenberg.PdfEngine + splitEngines []gotenberg.PdfEngine + flattenEngines []gotenberg.PdfEngine + convertEngines []gotenberg.PdfEngine + readMetadataEngines []gotenberg.PdfEngine + writeMetadataEngines []gotenberg.PdfEngine + passwordEngines []gotenberg.PdfEngine + attachmentsEngines []gotenberg.PdfEngine } func newMultiPdfEngines( @@ -32,27 +33,26 @@ func newMultiPdfEngines( readMetadataEngines, writeMetadataEngines, passwordEngines, - embedEngines, - readBookmarksEngines, - writeBookmarksEngines []gotenberg.PdfEngine, + attachmentsEngines []gotenberg.PdfEngine, ) *multiPdfEngines { return &multiPdfEngines{ - mergeEngines: mergeEngines, - splitEngines: splitEngines, - flattenEngines: flattenEngines, - convertEngines: convertEngines, - readMetadataEngines: readMetadataEngines, - writeMetadataEngines: writeMetadataEngines, - passwordEngines: passwordEngines, - embedEngines: embedEngines, - readBookmarksEngines: readBookmarksEngines, - writeBookmarksEngines: writeBookmarksEngines, + mergeEngines: mergeEngines, + splitEngines: splitEngines, + flattenEngines: flattenEngines, + convertEngines: convertEngines, + readMetadataEngines: readMetadataEngines, + writeMetadataEngines: writeMetadataEngines, + passwordEngines: passwordEngines, + attachmentsEngines: attachmentsEngines, } } // Merge combines multiple PDF files into a single document using the first // available engine that supports PDF merging. -func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { +func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Merge", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -72,7 +72,10 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp } } - return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) + err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } type splitResult struct { @@ -82,7 +85,10 @@ type splitResult struct { // Split divides the PDF into separate pages using the first available engine // that supports PDF splitting. -func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { +func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Split", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error var mu sync.Mutex // to safely append errors. @@ -108,12 +114,18 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod } } - return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err) + err = fmt.Errorf("split PDF with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } // Flatten merges existing annotation appearances with page content using the // first available engine that supports flattening. -func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { +func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -133,12 +145,18 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i } } - return fmt.Errorf("flatten PDF with multi PDF engines: %w", err) + err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } // Convert transforms the given PDF to a specific PDF format using the first // available engine that supports PDF conversion. -func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { +func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Convert", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -158,7 +176,10 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f } } - return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err) + err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } type readMetadataResult struct { @@ -168,7 +189,10 @@ type readMetadataResult struct { // ReadMetadata extracts metadata from a PDF file using the first available // engine that supports metadata reading. -func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error var mu sync.Mutex // to safely append errors. @@ -194,12 +218,18 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg } } - return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) + err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } -// WriteMetadata embeds metadata into a PDF file using the first available +// WriteMetadata attachments metadata into a PDF file using the first available // engine that supports metadata writing. -func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -219,73 +249,18 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log } } - return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) -} - -type readBookmarksResult struct { - bookmarks []gotenberg.Bookmark - err error -} - -// ReadBookmarks reads bookmarks from a PDF file using the first available -// engine that supports bookmarks reading. -func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { - var err error - var mu sync.Mutex // to safely append errors. - - for _, engine := range multi.readBookmarksEngines { - resultChan := make(chan readBookmarksResult, 1) - - go func(engine gotenberg.PdfEngine) { - bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath) - resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err} - }(engine) - - select { - case result := <-resultChan: - if result.err != nil { - mu.Lock() - err = multierr.Append(err, result.err) - mu.Unlock() - } else { - return result.bookmarks, nil - } - case <-ctx.Done(): - return nil, ctx.Err() - } - } - - return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) -} - -// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the -// first available engine that supports bookmarks writing. -func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - var err error - errChan := make(chan error, 1) - - for _, engine := range multi.writeBookmarksEngines { - go func(engine gotenberg.PdfEngine) { - errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks) - }(engine) - - select { - case writeBookmarksErr := <-errChan: - errored := multierr.AppendInto(&err, writeBookmarksErr) - if !errored { - return nil - } - case <-ctx.Done(): - return ctx.Err() - } - } - - return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) + err = fmt.Errorf("write PDF metadata with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } // Encrypt adds password protection to a PDF file using the first available // engine that supports password protection. -func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { +func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -305,23 +280,29 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i } } - return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) + err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } -// EmbedFiles embeds files into a PDF using the first available -// engine that supports file embedding. -func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { +// AddAttachments adds attachments into a PDF using the first available +// engine that supports file attaching. +func (multi *multiPdfEngines) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) - for _, engine := range multi.embedEngines { + for _, engine := range multi.attachmentsEngines { go func(engine gotenberg.PdfEngine) { - errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath) + errChan <- engine.AddAttachments(ctx, logger, filePaths, inputPath) }(engine) select { - case embedErr := <-errChan: - errored := multierr.AppendInto(&err, embedErr) + case attachmentsErr := <-errChan: + errored := multierr.AppendInto(&err, attachmentsErr) if !errored { return nil } @@ -330,7 +311,10 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger } } - return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) + err = fmt.Errorf("add attachments into PDF using multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } // Interface guards. diff --git a/pkg/modules/pdfengines/multi_test.go b/pkg/modules/pdfengines/multi_test.go new file mode 100644 index 00000000..403bc24e --- /dev/null +++ b/pkg/modules/pdfengines/multi_test.go @@ -0,0 +1,650 @@ +package pdfengines + +import ( + "context" + "errors" + "log/slog" + "os" + "testing" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" +) + +func TestMultiPdfEngines_Merge(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + expectError: false, + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + expectError: false, + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + mergeEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + return nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.Merge(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), nil, "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_Encrypt(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + passwordEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + return nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.Encrypt(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "", "", "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_Split(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + splitEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + splitEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + splitEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + splitEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { + return nil, nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + _, err := tc.engine.Split(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), gotenberg.SplitMode{}, "", "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_Flatten(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + flattenEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + flattenEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + flattenEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + flattenEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error { + return nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.Flatten(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_Convert(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + convertEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { + return nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.Convert(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), gotenberg.PdfFormats{}, "", "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_ReadMetadata(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + return make(map[string]any), nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + return nil, errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + return make(map[string]any), nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + return nil, errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + return nil, errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + readMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + return make(map[string]any), nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + _, err := tc.engine.ReadMetadata(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} + +func TestMultiPdfEngines_WriteMetadata(t *testing.T) { + for _, tc := range []struct { + scenario string + engine *multiPdfEngines + ctx context.Context + expectError bool + }{ + { + scenario: "nominal behavior", + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "at least one engine does not return an error", + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + return nil + }, + }, + }, + }, + ctx: context.Background(), + }, + { + scenario: "all engines return an error", + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + return errors.New("foo") + }, + }, + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + return errors.New("foo") + }, + }, + }, + }, + ctx: context.Background(), + expectError: true, + }, + { + scenario: "context expired", + engine: &multiPdfEngines{ + writeMetadataEngines: []gotenberg.PdfEngine{ + &gotenberg.PdfEngineMock{ + WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { + return nil + }, + }, + }, + }, + ctx: func() context.Context { + ctx, cancel := context.WithCancel(context.Background()) + cancel() + + return ctx + }(), + expectError: true, + }, + } { + t.Run(tc.scenario, func(t *testing.T) { + err := tc.engine.WriteMetadata(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), nil, "") + + if !tc.expectError && err != nil { + t.Fatalf("expected no error but got: %v", err) + } + + if tc.expectError && err == nil { + t.Fatal("expected error but got none") + } + }) + } +} diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index 08f29d36..b50fe462 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -28,18 +28,16 @@ func init() { // the [api.Router] interface to expose relevant PDF processing routes if // enabled. type PdfEngines struct { - mergeNames []string - splitNames []string - flattenNames []string - convertNames []string - readMetadataNames []string - writeMetadataNames []string - encryptNames []string - embedNames []string - readBookmarksNames []string - writeBookmarksNames []string - engines []gotenberg.PdfEngine - disableRoutes bool + mergeNames []string + splitNames []string + flattenNames []string + convertNames []string + readMetadataNames []string + writeMetadataNames []string + encryptNames []string + attachmentsNames []string + engines []gotenberg.PdfEngine + disableRoutes bool } // Descriptor returns a PdfEngines' module descriptor. @@ -54,19 +52,10 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor { fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all") fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all") fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") - fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") - fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all") - fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all") - fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all") + fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the password protection feature - empty means all") + fs.StringSlice("pdfengines-add-attachments-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file attaching feature - empty means all") fs.Bool("pdfengines-disable-routes", false, "Disable the routes") - // Deprecated flags. - fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default") - err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method") - if err != nil { - panic(err) - } - return fs }(), New: func() gotenberg.Module { return new(PdfEngines) }, @@ -84,9 +73,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") - embedNames := flags.MustStringSlice("pdfengines-embed-engines") - readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines") - writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines") + attachmentsNames := flags.MustStringSlice("pdfengines-add-attachments-engines") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") engines, err := ctx.Modules(new(gotenberg.PdfEngine)) @@ -108,7 +95,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { // Example in the case of deprecated module name. //for i, name := range defaultNames { // if name == "unoconv-pdfengine" || name == "uno-pdfengine" { - // logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name)) + // logger.WarnContext(ctx, fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name)) // mod.defaultNames[i] = "libreoffice-pdfengine" // } //} @@ -148,19 +135,9 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error { mod.encryptNames = encryptNames } - mod.embedNames = defaultNames - if len(embedNames) > 0 { - mod.embedNames = embedNames - } - - mod.readBookmarksNames = defaultNames - if len(readBookmarksNames) > 0 { - mod.readBookmarksNames = readBookmarksNames - } - - mod.writeBookmarksNames = defaultNames - if len(writeBookmarksNames) > 0 { - mod.writeBookmarksNames = writeBookmarksNames + mod.attachmentsNames = defaultNames + if len(attachmentsNames) > 0 { + mod.attachmentsNames = attachmentsNames } return nil @@ -211,9 +188,7 @@ func (mod *PdfEngines) Validate() error { findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.writeMetadataNames) findNonExistingEngines(mod.encryptNames) - findNonExistingEngines(mod.embedNames) - findNonExistingEngines(mod.readBookmarksNames) - findNonExistingEngines(mod.writeBookmarksNames) + findNonExistingEngines(mod.attachmentsNames) if len(nonExistingEngines) == 0 { return nil @@ -233,9 +208,6 @@ func (mod *PdfEngines) SystemMessages() []string { fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")), fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")), - fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")), - fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames[:], " ")), - fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")), } } @@ -263,9 +235,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) { engines(mod.readMetadataNames), engines(mod.writeMetadataNames), engines(mod.encryptNames), - engines(mod.embedNames), - engines(mod.readBookmarksNames), - engines(mod.writeBookmarksNames), + engines(mod.attachmentsNames), ), nil } @@ -289,10 +259,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) { convertRoute(engine), readMetadataRoute(engine), writeMetadataRoute(engine), - readBookmarksRoute(engine), - writeBookmarksRoute(engine), encryptRoute(engine), - embedRoute(engine), + attachmentsRoute(engine), }, nil } diff --git a/pkg/modules/pdfengines/routes.go b/pkg/modules/pdfengines/routes.go index c902f5a2..bea0a19b 100644 --- a/pkg/modules/pdfengines/routes.go +++ b/pkg/modules/pdfengines/routes.go @@ -84,7 +84,7 @@ func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMod } // FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data. -// Fallback to the default value if the considered key is not present. +// Fallback to default value if the considered key is not present. func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats { var ( pdfa string @@ -128,44 +128,6 @@ func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any { return metadata } -// FormDataPdfBookmarks creates bookmarks from the form data. -func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any { - var bookmarks any - - bookmarksFunc := func(value string) error { - if len(value) > 0 { - var list []gotenberg.Bookmark - err := json.Unmarshal([]byte(value), &list) - if err == nil { - bookmarks = list - return nil - } - - var m map[string][]gotenberg.Bookmark - err = json.Unmarshal([]byte(value), &m) - if err == nil { - bookmarks = m - return nil - } - - return fmt.Errorf("unmarshal bookmarks: %w", err) - } - return nil - } - - if mandatory { - form.MandatoryCustom("bookmarks", func(value string) error { - return bookmarksFunc(value) - }) - } else { - form.Custom("bookmarks", func(value string) error { - return bookmarksFunc(value) - }) - } - - return bookmarks -} - // MergeStub merges given PDFs. If only one input PDF, it does nothing and // returns the corresponding input path. func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) { @@ -292,49 +254,12 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma return nil } -// WriteBookmarksStub writes the bookmarks into PDF files. If no bookmarks, it -// does nothing. -func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks any, inputPaths []string) error { - if bookmarks == nil { - return nil - } - - switch b := bookmarks.(type) { - case []gotenberg.Bookmark: - if len(b) == 0 { - return nil - } - - for _, inputPath := range inputPaths { - err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, b) - if err != nil { - return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err) - } - } - case map[string][]gotenberg.Bookmark: - for _, inputPath := range inputPaths { - filename := filepath.Base(inputPath) - if specificBookmarks, ok := b[filename]; ok { - err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks) - if err != nil { - return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err) - } - } - } - default: - // Should not happen. - return fmt.Errorf("bookmarks type '%T' not supported", bookmarks) - } - - return nil -} - -// FormDataPdfEmbeds extracts embedded file paths from form data. -// Only files uploaded with the "embeds" field name are included. -func FormDataPdfEmbeds(form *api.FormData) []string { - var embedPaths []string - form.Embeds(&embedPaths) - return embedPaths +// FormDataPdfAttachments extracts attached file paths from form data. +// Only files uploaded with the "attachments" field name are included. +func FormDataPdfAttachments(form *api.FormData) []string { + var attachmentsPaths []string + form.Attachments(&attachmentsPaths) + return attachmentsPaths } // FormDataPdfEncrypt extracts encryption parameters from form data. @@ -360,16 +285,16 @@ func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword, return nil } -// EmbedFilesStub embeds files into PDF files. -func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error { - if len(embedPaths) == 0 { +// AddAttachmentsStub adds attachments into PDF files. +func AddAttachmentsStub(ctx *api.Context, engine gotenberg.PdfEngine, attachmentsPaths []string, inputPaths []string) error { + if len(attachmentsPaths) == 0 { return nil } for _, inputPath := range inputPaths { - err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath) + err := engine.AddAttachments(ctx, ctx.Log(), attachmentsPaths, inputPath) if err != nil { - return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err) + return fmt.Errorf("add attachments into PDF '%s': %w", inputPath, err) } } @@ -388,9 +313,8 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { form := ctx.FormData() pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) - bookmarks := FormDataPdfBookmarks(form, false) userPassword, ownerPassword := FormDataPdfEncrypt(form) - embedPaths := FormDataPdfEmbeds(form) + attachmentsPaths := FormDataPdfAttachments(form) var inputPaths []string var flatten bool @@ -402,13 +326,6 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("validate form data: %w", err) } - if b, ok := bookmarks.(map[string][]gotenberg.Bookmark); ok { - err = WriteBookmarksStub(ctx, engine, b, inputPaths) - if err != nil { - return fmt.Errorf("write bookmarks: %w", err) - } - } - outputPath := ctx.GeneratePath(".pdf") err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath) if err != nil { @@ -420,16 +337,9 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("convert PDF: %w", err) } - err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths) + err = AddAttachmentsStub(ctx, engine, attachmentsPaths, outputPaths) if err != nil { - return fmt.Errorf("embed files into PDFs: %w", err) - } - - if b, ok := bookmarks.([]gotenberg.Bookmark); ok { - err = WriteBookmarksStub(ctx, engine, b, outputPaths) - if err != nil { - return fmt.Errorf("write bookmarks: %w", err) - } + return fmt.Errorf("add attachments into PDFs: %w", err) } err = WriteMetadataStub(ctx, engine, metadata, outputPaths) @@ -473,7 +383,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { pdfFormats := FormDataPdfFormats(form) metadata := FormDataPdfMetadata(form, false) userPassword, ownerPassword := FormDataPdfEncrypt(form) - embedPaths := FormDataPdfEmbeds(form) + attachmentsPaths := FormDataPdfAttachments(form) var inputPaths []string var flatten bool @@ -495,9 +405,9 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route { return fmt.Errorf("convert PDFs: %w", err) } - err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) + err = AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths) if err != nil { - return fmt.Errorf("embed files into PDFs: %w", err) + return fmt.Errorf("add attachments into PDFs: %w", err) } err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) @@ -709,83 +619,6 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route { } } -// readBookmarksRoute returns an [api.Route] which returns the bookmarks of PDFs. -func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route { - return api.Route{ - Method: http.MethodPost, - Path: "/forms/pdfengines/bookmarks/read", - IsMultipart: true, - Handler: func(c echo.Context) error { - ctx := c.Get("context").(*api.Context) - - var inputPaths []string - err := ctx.FormData(). - MandatoryPaths([]string{".pdf"}, &inputPaths). - Validate() - if err != nil { - return fmt.Errorf("validate form data: %w", err) - } - - res := make(map[string][]gotenberg.Bookmark, len(inputPaths)) - for _, inputPath := range inputPaths { - bookmarks, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath) - if err != nil { - return fmt.Errorf("read bookmarks: %w", err) - } - - res[filepath.Base(inputPath)] = bookmarks - } - - err = c.JSON(http.StatusOK, res) - if err != nil { - if strings.Contains(err.Error(), "request method or response status code does not allow body") { - // High probability that the user is using the webhook - // feature. It does not make sense for this route. - return api.ErrNoOutputFile - } - return fmt.Errorf("return JSON response: %w", err) - } - - return api.ErrNoOutputFile - }, - } -} - -// writeBookmarksRoute returns an [api.Route] which can write bookmarks into PDFs. -func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route { - return api.Route{ - Method: http.MethodPost, - Path: "/forms/pdfengines/bookmarks/write", - IsMultipart: true, - Handler: func(c echo.Context) error { - ctx := c.Get("context").(*api.Context) - - form := ctx.FormData() - bookmarks := FormDataPdfBookmarks(form, true) - - var inputPaths []string - err := form. - MandatoryPaths([]string{".pdf"}, &inputPaths). - Validate() - if err != nil { - return fmt.Errorf("validate form data: %w", err) - } - - err = WriteBookmarksStub(ctx, engine, bookmarks, inputPaths) - if err != nil { - return fmt.Errorf("write bookmarks: %w", err) - } - - err = ctx.AddOutputPaths(inputPaths...) - if err != nil { - return fmt.Errorf("add output paths: %w", err) - } - - return nil - }, - } -} - // encryptRoute returns an [api.Route] which can add password protection to PDFs. func encryptRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ @@ -824,18 +657,17 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route { } } -// embedRoute returns an [api.Route] which can add embedded files to PDFs. -// TODO: attachments instead? -func embedRoute(engine gotenberg.PdfEngine) api.Route { +// attachmentsRoute returns an [api.Route] which can add attached files to PDFs. +func attachmentsRoute(engine gotenberg.PdfEngine) api.Route { return api.Route{ Method: http.MethodPost, - Path: "/forms/pdfengines/embed", + Path: "/forms/pdfengines/attachments/add", IsMultipart: true, Handler: func(c echo.Context) error { ctx := c.Get("context").(*api.Context) form := ctx.FormData() - embedPaths := FormDataPdfEmbeds(form) + attachmentsPaths := FormDataPdfAttachments(form) var inputPaths []string err := form. @@ -844,9 +676,10 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route { if err != nil { return fmt.Errorf("validate form data: %w", err) } - err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths) + + err = AddAttachmentsStub(ctx, engine, attachmentsPaths, inputPaths) if err != nil { - return fmt.Errorf("embed files into PDFs: %w", err) + return fmt.Errorf("add attachments into PDFs: %w", err) } err = ctx.AddOutputPaths(inputPaths...) diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 65298385..335b71ba 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -5,12 +5,14 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" "syscall" - "go.uber.org/zap" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -79,42 +81,63 @@ func (engine *PdfTk) Debug() map[string]any { } // Split splits a given PDF file. -func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { +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.SpanKindInternal)) + defer span.End() + var args []string outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) switch mode.Mode { case gotenberg.SplitModePages: if !mode.Unify { - return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + err := fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } args = append(args, inputPath, "cat", mode.Span, "output", outputPath) default: - return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + err := fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return nil, fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } _, err = cmd.Exec() if err != nil { - return nil, fmt.Errorf("split PDFs with PDFtk: %w", err) + err = fmt.Errorf("split PDFs with PDFtk: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } return []string{outputPath}, nil } // Merge combines multiple PDFs into a single PDF. -func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { +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.SpanKindInternal)) + defer span.End() + args := make([]string, 0, 3+len(inputPaths)) args = append(args, inputPaths...) args = append(args, "cat", "output", outputPath) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() @@ -122,47 +145,87 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [ return nil } - return fmt.Errorf("merge PDFs with PDFtk: %w", err) + err = fmt.Errorf("merge PDFs with PDFtk: %w", err) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + return err } // Flatten is not available in this implementation. -func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { - return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "PdfTk.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Convert is not available in this implementation. -func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // ReadMetadata is not available in this implementation. -func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return nil, err } // WriteMetadata is not available in this implementation. -func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} +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.SpanKindInternal)) + defer span.End() -// WriteBookmarks is not available in this implementation. -func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} + err := fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } -// ReadBookmarks is not available in this implementation. -func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { - return nil, fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) + return err } // Encrypt adds password protection to a PDF file using PDFtk. -func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "PdfTk.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + if userPassword == "" { - return errors.New("user password cannot be empty") + err := errors.New("user password cannot be empty") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } if ownerPassword == userPassword || ownerPassword == "" { - return gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow") + err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } // Create a temp output file in the same directory. @@ -177,25 +240,43 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() if err != nil { - return fmt.Errorf("encrypt PDF with PDFtk: %w", err) + err = fmt.Errorf("encrypt PDF with PDFtk: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } err = os.Rename(tmpPath, inputPath) if err != nil { - return fmt.Errorf("rename temporary output file with input file: %w", err) + err = fmt.Errorf("rename temporary output file with input file: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } return nil } -// EmbedFiles is not available in this implementation. -func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { - return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) +// AddAttachments is not available in this implementation. +func (engine *PdfTk) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "PdfTk.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("add attachments with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // Interface guards. diff --git a/pkg/modules/prometheus/doc.go b/pkg/modules/prometheus/doc.go deleted file mode 100644 index f5b629c8..00000000 --- a/pkg/modules/prometheus/doc.go +++ /dev/null @@ -1,5 +0,0 @@ -// Package prometheus provides a module which collects metrics and exposes them -// via an HTTP route. -// -// See: https://prometheus.io/. -package prometheus diff --git a/pkg/modules/prometheus/prometheus.go b/pkg/modules/prometheus/prometheus.go deleted file mode 100644 index 51a72833..00000000 --- a/pkg/modules/prometheus/prometheus.go +++ /dev/null @@ -1,197 +0,0 @@ -package prometheus - -import ( - "context" - "errors" - "fmt" - "net/http" - "time" - - "github.com/labstack/echo/v4" - "github.com/prometheus/client_golang/prometheus" - "github.com/prometheus/client_golang/prometheus/promhttp" - flag "github.com/spf13/pflag" - - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" - "github.com/gotenberg/gotenberg/v8/pkg/modules/api" -) - -func init() { - gotenberg.MustRegisterModule(new(Prometheus)) -} - -// Prometheus is a module that collects metrics and exposes them via an HTTP -// route. -type Prometheus struct { - namespace string - interval time.Duration - disableRouteLogging bool - disableCollect bool - metricsPath string - - metrics []gotenberg.Metric - registry *prometheus.Registry -} - -// Descriptor returns a [Prometheus]'s module descriptor. -func (mod *Prometheus) Descriptor() gotenberg.ModuleDescriptor { - return gotenberg.ModuleDescriptor{ - ID: "prometheus", - FlagSet: func() *flag.FlagSet { - fs := flag.NewFlagSet("prometheus", flag.ExitOnError) - fs.String("prometheus-namespace", "gotenberg", "Set the namespace of modules' metrics") - fs.Duration("prometheus-collect-interval", time.Duration(1)*time.Second, "Set the interval for collecting modules' metrics") - fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging") - fs.Bool("prometheus-disable-collect", false, "Disable the collect of metrics") - fs.String("prometheus-metrics-path", "/prometheus/metrics", "Path for Prometheus metrics endpoint") - - return fs - }(), - New: func() gotenberg.Module { return new(Prometheus) }, - } -} - -// Provision sets the module properties. -func (mod *Prometheus) Provision(ctx *gotenberg.Context) error { - flags := ctx.ParsedFlags() - mod.namespace = flags.MustString("prometheus-namespace") - mod.interval = flags.MustDuration("prometheus-collect-interval") - mod.disableRouteLogging = flags.MustBool("prometheus-disable-route-logging") - mod.disableCollect = flags.MustBool("prometheus-disable-collect") - mod.metricsPath = flags.MustString("prometheus-metrics-path") - - if mod.disableCollect { - // Exit early. - return nil - } - - // Get metrics from modules. - mods, err := ctx.Modules(new(gotenberg.MetricsProvider)) - if err != nil { - return fmt.Errorf("get metrics providers: %w", err) - } - - metricsProviders := make([]gotenberg.MetricsProvider, len(mods)) - for i, metricsProvider := range mods { - metricsProviders[i] = metricsProvider.(gotenberg.MetricsProvider) - } - - for _, metricsProvider := range metricsProviders { - metrics, err := metricsProvider.Metrics() - if err != nil { - return fmt.Errorf("get metrics: %w", err) - } - - mod.metrics = append(mod.metrics, metrics...) - } - - mod.registry = prometheus.NewRegistry() - - return nil -} - -// Validate validates the module properties. -func (mod *Prometheus) Validate() error { - if mod.disableCollect { - // Exit early. - return nil - } - - if mod.namespace == "" { - return errors.New("namespace must not be empty") - } - - if mod.metricsPath == "" { - return errors.New("metrics path cannot be empty") - } - - metricsMap := make(map[string]string, len(mod.metrics)) - - for _, metric := range mod.metrics { - if metric.Name == "" { - return errors.New("metric name cannot be empty") - } - - if metric.Read == nil { - return fmt.Errorf("metric '%s' has nil read method", metric.Name) - } - - if _, ok := metricsMap[metric.Name]; ok { - return fmt.Errorf("metric '%s' is already registered", metric.Name) - } - - metricsMap[metric.Name] = metric.Name - } - - return nil -} - -// Start starts the collect. -func (mod *Prometheus) Start() error { - if mod.disableCollect { - // Exit early. - return nil - } - - for _, metric := range mod.metrics { - gauge := prometheus.NewGauge( - prometheus.GaugeOpts{ - Namespace: mod.namespace, - Name: metric.Name, - Help: metric.Description, - }, - ) - - mod.registry.MustRegister(gauge) - - go func(gauge prometheus.Gauge, metric gotenberg.Metric) { - for { - gauge.Set(metric.Read()) - time.Sleep(mod.interval) - } - }(gauge, metric) - } - - return nil -} - -// StartupMessage returns a custom startup message. -func (mod *Prometheus) StartupMessage() string { - if mod.disableCollect { - return "collect disabled" - } - - return "collecting metrics" -} - -// Stop does nothing. -func (mod *Prometheus) Stop(ctx context.Context) error { - return nil -} - -// Routes returns the HTTP route. -func (mod *Prometheus) Routes() ([]api.Route, error) { - if mod.disableCollect { - return nil, nil - } - - return []api.Route{ - { - Method: http.MethodGet, - Path: mod.metricsPath, - DisableLogging: mod.disableRouteLogging, - Handler: echo.WrapHandler( - promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}), - ), - }, - }, nil -} - -// Interface guards. -var ( - _ gotenberg.Module = (*Prometheus)(nil) - _ gotenberg.Provisioner = (*Prometheus)(nil) - _ gotenberg.Validator = (*Prometheus)(nil) - _ gotenberg.App = (*Prometheus)(nil) - _ api.Router = (*Prometheus)(nil) -) diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index 70b79be3..aa9ac707 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -5,12 +5,14 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" "syscall" - "go.uber.org/zap" + "go.opentelemetry.io/otel/codes" + "go.opentelemetry.io/otel/trace" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -82,38 +84,56 @@ func (engine *QPdf) Debug() map[string]any { } // Split splits a given PDF file. -func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { +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.SpanKindInternal)) + defer span.End() + var args []string outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) switch mode.Mode { case gotenberg.SplitModePages: if !mode.Unify { - return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + err := fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } args = append(args, inputPath) args = append(args, engine.globalArgs...) args = append(args, "--pages", ".", mode.Span) args = append(args, "--", outputPath) default: - return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + err := fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return nil, fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } _, err = cmd.Exec() if err != nil { - return nil, fmt.Errorf("split PDFs with QPDF: %w", err) + err = fmt.Errorf("split PDFs with QPDF: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return nil, err } return []string{outputPath}, nil } // Merge combines multiple PDFs into a single PDF. -func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { +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.SpanKindInternal)) + defer span.End() + args := make([]string, 0, 4+len(engine.globalArgs)+len(inputPaths)) args = append(args, "--empty") args = append(args, engine.globalArgs...) @@ -123,7 +143,10 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths [] cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() @@ -131,12 +154,20 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths [] return nil } - return fmt.Errorf("merge PDFs with QPDF: %w", err) + err = fmt.Errorf("merge PDFs with QPDF: %w", err) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + return err } // Flatten merges annotation appearances with page content, deleting the // original annotations. -func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { +func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "QPdf.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + args := make([]string, 0, 4+len(engine.globalArgs)) args = append(args, inputPath) args = append(args, "--generate-appearances") @@ -146,7 +177,10 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath s cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() @@ -154,38 +188,66 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath s return nil } - return fmt.Errorf("flatten PDFs with QPDF: %w", err) + err = fmt.Errorf("flatten PDFs with QPDF: %w", err) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + return err } // Convert is not available in this implementation. -func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { - return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } // ReadMetadata is not available in this implementation. -func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { - return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +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.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return nil, err } // WriteMetadata is not available in this implementation. -func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { - return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} +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.SpanKindInternal)) + defer span.End() -// WriteBookmarks is not available in this implementation. -func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { - return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) -} + err := fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } -// ReadBookmarks is not available in this implementation. -func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { - return nil, fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) + return err } // Encrypt adds password protection to a PDF file using QPDF. -func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { + ctx, span := gotenberg.Tracer().Start(ctx, "QPdf.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + if userPassword == "" { - return errors.New("user password cannot be empty") + err := errors.New("user password cannot be empty") + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } if ownerPassword == "" { @@ -200,20 +262,35 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) if err != nil { - return fmt.Errorf("create command: %w", err) + err = fmt.Errorf("create command: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } _, err = cmd.Exec() if err != nil { - return fmt.Errorf("encrypt PDF with QPDF: %w", err) + err = fmt.Errorf("encrypt PDF with QPDF: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } return nil } -// EmbedFiles is not available in this implementation. -func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { - return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) +// AddAttachments is not available in this implementation. +func (engine *QPdf) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + _, span := gotenberg.Tracer().Start(ctx, "QPdf.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + + err := fmt.Errorf("add attachments with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) + if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + } + + return err } var ( diff --git a/pkg/modules/webhook/client.go b/pkg/modules/webhook/client.go index 39d91bd6..4cdc9c37 100644 --- a/pkg/modules/webhook/client.go +++ b/pkg/modules/webhook/client.go @@ -1,15 +1,22 @@ package webhook import ( + "context" "fmt" "io" + "log/slog" "net/http" "strconv" "time" "github.com/hashicorp/go-retryablehttp" "github.com/labstack/echo/v4" - "go.uber.org/zap" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" + semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv" ) // client gathers all the data required to send a request to a webhook. @@ -22,11 +29,11 @@ type client struct { startTime time.Time client *retryablehttp.Client - logger *zap.Logger + logger *slog.Logger } // send call the webhook either to send the success response or the error response. -func (c client) send(body io.Reader, headers map[string]string, errored bool) error { +func (c client) send(ctx context.Context, body io.Reader, headers http.Header, errored bool) error { url := c.url if errored { url = c.errorUrl @@ -37,7 +44,7 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er method = c.errorMethod } - req, err := retryablehttp.NewRequest(method, url, body) + req, err := retryablehttp.NewRequestWithContext(ctx, method, url, body) if err != nil { return fmt.Errorf("create '%s' request to '%s': %w", method, url, err) } @@ -51,33 +58,53 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er // Middleware caller's headers > extra HTTP headers from the user. - contentLength, ok := headers[echo.HeaderContentLength] - if ok { - // Golang "http" package should automatically calculate the size of the - // body. But when using a buffered file reader, it does not work. - // Worse, the "Content-Length" header is also removed. Therefore, - // to keep this valuable information, we have to trust the caller - // by reading the value of the "Content-Length" entry and set it as the - // content length of the request. It's kinda suboptimal, but hey, at - // least it works. + contentLength := headers.Get(echo.HeaderContentLength) + // Golang "http" package should automatically calculate the size of the + // body. But when using a buffered file reader, it does not work. + // Worse, the "Content-Length" header is also removed. Therefore, + // to keep this valuable information, we have to trust the caller + // by reading the value of the "Content-Length" entry and set it as the + // content length of the request. It's kinda suboptimal, but hey, at + // least it works. - bodySize, err := strconv.ParseInt(contentLength, 10, 64) - if err != nil { - return fmt.Errorf("parse content length entry: %w", err) - } - - req.ContentLength = bodySize + bodySize, err := strconv.ParseInt(contentLength, 10, 64) + if err != nil { + return fmt.Errorf("parse content length entry: %w", err) } - for key, value := range headers { - req.Header.Set(key, value) + req.ContentLength = bodySize + + for key := range headers { + req.Header.Set(key, headers.Get(key)) } + // OpenTelemetry. + meter := gotenberg.Meter() + semconvClient := semconvutil.NewHTTPClient(meter) + + tracer := gotenberg.Tracer() + spanName := fmt.Sprintf("%s Webhook", req.Method) + if errored { + spanName = fmt.Sprintf("%s Webhook Error", req.Method) + } + ctx, span := tracer.Start(ctx, spanName, + trace.WithSpanKind(trace.SpanKindClient), + trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...), + ) + defer span.End() + + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + resp, err := c.client.Do(req) if err != nil { + span.RecordError(err) + span.SetStatus(semconvClient.Status(0)) return fmt.Errorf("send '%s' request to '%s': %w", method, url, err) } + span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...) + span.SetStatus(semconvClient.Status(resp.StatusCode)) + if resp.StatusCode >= http.StatusBadRequest { return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status) } @@ -85,7 +112,7 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er defer func() { err := resp.Body.Close() if err != nil { - c.logger.Error(fmt.Sprintf("close response body from '%s': %s", url, err)) + c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", url, err)) } }() @@ -93,20 +120,18 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er finishTime := time.Now() // Now let's log! - fields := make([]zap.Field, 5) - fields[0] = zap.String("webhook_url", url) - fields[1] = zap.String("method", method) - fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime))) - fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String()) - fields[4] = zap.Int64("bytes_out", req.ContentLength) + fields := make([]any, 5) + fields[0] = slog.String("webhook_url", url) + fields[1] = slog.String("method", method) + fields[2] = slog.Int64("latency", int64(finishTime.Sub(c.startTime))) + fields[3] = slog.String("latency_human", finishTime.Sub(c.startTime).String()) + fields[4] = slog.Int64("bytes_out", req.ContentLength) if errored { - c.logger.Warn("request to webhook with error details handled", fields...) - + c.logger.WarnContext(ctx, "request to webhook with error details handled", fields...) return nil } - c.logger.Info("request to webhook handled", fields...) - + c.logger.InfoContext(ctx, "request to webhook handled", fields...) return nil } diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index 8771f042..154bec12 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -15,336 +15,317 @@ import ( "github.com/hashicorp/go-retryablehttp" "github.com/labstack/echo/v4" + "go.opentelemetry.io/otel" + "go.opentelemetry.io/otel/propagation" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/api" ) -type sendOutputFileParams struct { - ctx *api.Context - outputPath string - extraHttpHeaders map[string]string - traceHeader string - trace string - client *client - handleError func(error) -} - func webhookMiddleware(w *Webhook) api.Middleware { return api.Middleware{ Stack: api.MultipartStack, Handler: func() echo.MiddlewareFunc { return func(next echo.HandlerFunc) echo.HandlerFunc { - sendOutputFile := func(params sendOutputFileParams) { - outputFile, err := os.Open(params.outputPath) - if err != nil { - params.ctx.Log().Error(fmt.Sprintf("open output file: %s", err)) - params.handleError(err) - return - } - defer func() { - err := outputFile.Close() - if err != nil { - params.ctx.Log().Error(fmt.Sprintf("close output file: %s", err)) - } - }() - - fileHeader := make([]byte, 512) - _, err = outputFile.Read(fileHeader) - if err != nil { - params.ctx.Log().Error(fmt.Sprintf("read header of output file: %s", err)) - params.handleError(err) - return - } - - fileStat, err := outputFile.Stat() - if err != nil { - params.ctx.Log().Error(fmt.Sprintf("get stat from output file: %s", err)) - params.handleError(err) - return - } - - _, err = outputFile.Seek(0, 0) - if err != nil { - params.ctx.Log().Error(fmt.Sprintf("reset output file reader: %s", err)) - params.handleError(err) - return - } - - headers := map[string]string{ - echo.HeaderContentType: http.DetectContentType(fileHeader), - echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), - params.traceHeader: params.trace, - } - _, ok := params.extraHttpHeaders[echo.HeaderContentDisposition] - if !ok { - headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath)) - } - - err = params.client.send(bufio.NewReader(outputFile), headers, false) - if err != nil { - params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err)) - params.handleError(err) - } - } - return func(c echo.Context) error { webhookUrl := c.Request().Header.Get("Gotenberg-Webhook-Url") if webhookUrl == "" { - // No webhook URL, call the next middleware in the chain. return next(c) } ctx := c.Get("context").(*api.Context) cancel := c.Get("cancel").(context.CancelFunc) - // Do we have a webhook error URL in case of... error? - webhookErrorUrl := c.Request().Header.Get("Gotenberg-Webhook-Error-Url") - if webhookErrorUrl == "" { - return api.WrapError( - errors.New("empty webhook error URL"), - api.NewSentinelHttpError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"), - ) - } - deadline, ok := ctx.Deadline() if !ok { return errors.New("context has no deadline") } - // Let's check if the webhook URLs are acceptable according to our - // allowed/denied lists. - err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline) + // Parse all configurations. + cfg, err := parseWebhookConfig(c, w, deadline, webhookUrl) if err != nil { - return fmt.Errorf("filter webhook URL: %w", err) + return err } - err = gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, webhookErrorUrl, deadline) - if err != nil { - return fmt.Errorf("filter webhook error URL: %w", err) - } - - // Let's check the HTTP methods for calling the webhook URLs. - methodFromHeader := func(header string) (string, error) { - method := c.Request().Header.Get(header) - - if method == "" { - return http.MethodPost, nil - } - - method = strings.ToUpper(method) - - switch method { - case http.MethodPost: - return method, nil - case http.MethodPatch: - return method, nil - case http.MethodPut: - return method, nil - } - - return "", api.WrapError( - fmt.Errorf("webhook method '%s' is not '%s', '%s' or '%s'", method, http.MethodPost, http.MethodPatch, http.MethodPut), - api.NewSentinelHttpError( - http.StatusBadRequest, - fmt.Sprintf("Invalid '%s' header value: expected '%s', '%s' or '%s', but got '%s'", header, http.MethodPost, http.MethodPatch, http.MethodPut, method), - ), - ) - } - - webhookMethod, err := methodFromHeader("Gotenberg-Webhook-Method") - if err != nil { - return fmt.Errorf("get method to use for webhook: %w", err) - } - - webhookErrorMethod, err := methodFromHeader("Gotenberg-Webhook-Error-Method") - if err != nil { - return fmt.Errorf("get method to use for webhook error: %w", err) - } - - // What about extra HTTP headers? - var extraHttpHeaders map[string]string - - extraHttpHeadersJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers") - if extraHttpHeadersJson != "" { - err = json.Unmarshal([]byte(extraHttpHeadersJson), &extraHttpHeaders) - if err != nil { - return api.WrapError( - fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err), - api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Gotenberg-Webhook-Extra-Http-Headers' header value: %s", err.Error())), - ) - } - } - - // Retrieve values from echo.Context before it gets recycled. - // See https://github.com/gotenberg/gotenberg/issues/1000. + // Set up Headers and Tracing. startTime := c.Get("startTime").(time.Time) - traceHeader := c.Get("traceHeader").(string) - trace := c.Get("trace").(string) + correlationIdHeader := c.Get("correlationIdHeader").(string) + correlationId := c.Get("correlationId").(string) - client := &client{ - url: webhookUrl, - method: webhookMethod, - errorUrl: webhookErrorUrl, - errorMethod: webhookErrorMethod, - extraHttpHeaders: extraHttpHeaders, + headers := make(http.Header) + headers.Set(echo.HeaderContentType, echo.MIMEApplicationJSON) + headers.Set(correlationIdHeader, correlationId) + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(headers)) + + // Initialize the Webhook Client. + webhookClient := &client{ + url: cfg.URL, + method: cfg.Method, + errorUrl: cfg.ErrorURL, + errorMethod: cfg.ErrorMethod, + extraHttpHeaders: cfg.ExtraHTTPHeaders, startTime: startTime, - client: &retryablehttp.Client{ - HTTPClient: &http.Client{ - Timeout: w.clientTimeout, - }, + HTTPClient: &http.Client{Timeout: w.clientTimeout}, RetryMax: w.maxRetry, RetryWaitMin: w.retryMinWait, RetryWaitMax: w.retryMaxWait, - Logger: gotenberg.NewLeveledLogger(ctx.Log()), + Logger: gotenberg.NewLeveledLogger(ctx.Log()).WithContext(ctx), CheckRetry: retryablehttp.DefaultRetryPolicy, Backoff: retryablehttp.DefaultBackoff, }, logger: ctx.Log(), } - // This method parses an "asynchronous" error and sends a - // request to the webhook error URL with a JSON body - // containing the status and the error message. - handleError := func(err error) { - status, message := api.ParseError(err) - - body := struct { - Status int `json:"status"` - Message string `json:"message"` - }{ - Status: status, - Message: message, - } - - b, err := json.Marshal(body) - if err != nil { - ctx.Log().Error(fmt.Sprintf("marshal JSON: %s", err.Error())) - return - } - - headers := map[string]string{ - echo.HeaderContentType: echo.MIMEApplicationJSON, - traceHeader: trace, - } - - err = client.send(bytes.NewReader(b), headers, true) - if err != nil { - ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error())) - } + handleErrFunc := func(err error) { + sendWebhookError(ctx, webhookClient, headers, err) } + // Execute Sync Flow. if w.enableSyncMode { - err := next(c) - if err != nil { - if errors.Is(err, api.ErrNoOutputFile) { - errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err) - handleError(api.WrapError( - errNoOutputFile, - api.NewSentinelHttpError( - http.StatusBadRequest, - "The webhook middleware can only work with multipart/form-data routes that results in output files", - ), - )) - return nil - } - ctx.Log().Error(err.Error()) - handleError(err) - return nil - } - - outputPath, err := ctx.BuildOutputFile() - if err != nil { - ctx.Log().Error(fmt.Sprintf("build output file: %s", err)) - handleError(err) - return nil - } - // No error, let's send the output file to the webhook URL. - sendOutputFile(sendOutputFileParams{ - ctx: ctx, - outputPath: outputPath, - extraHttpHeaders: extraHttpHeaders, - traceHeader: traceHeader, - trace: trace, - client: client, - handleError: handleError, - }) - return c.NoContent(http.StatusNoContent) + return handleSyncWebhook(c, next, ctx, webhookClient, headers, cfg.ExtraHTTPHeaders, handleErrFunc) } - if deadline, ok := ctx.Deadline(); ok { - // Create a new context derived from Background (detached from Request) - // but with the same deadline as the original context. - detachedCtx, detachedCancel := context.WithDeadline(context.Background(), deadline) - - // Replace the embedded context in the api.Context struct. - // The modules downstream will now use this detached context. - ctx.Context = detachedCtx - - // We must wrap the cancel function. - // 1. detachedCancel() cleans up our new detached context. - // 2. originalCancel() (captured from c.Get("cancel")) cleans up the working directory. - originalCancel := cancel - cancel = func() { - detachedCancel() - originalCancel() - } - } else { - // Fallback if no deadline was set (rare, as newContext enforces it). - ctx.Context = context.Background() - } - - // As a webhook URL has been given, we handle the request in a - // goroutine and return immediately. - w.asyncCount.Add(1) - go func() { - defer cancel() - defer w.asyncCount.Add(-1) - - // Call the next middleware in the chain. - err := next(c) - if err != nil { - if errors.Is(err, api.ErrNoOutputFile) { - errNoOutputFile := fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err) - handleError(api.WrapError( - errNoOutputFile, - api.NewSentinelHttpError( - http.StatusBadRequest, - "The webhook middleware can only work with multipart/form-data routes that results in output files", - ), - )) - return - } - // The process failed for whatever reason. Let's send the - // details to the webhook. - ctx.Log().Error(err.Error()) - handleError(err) - return - } - - // No error, let's get to build the output file. - outputPath, err := ctx.BuildOutputFile() - if err != nil { - ctx.Log().Error(fmt.Sprintf("build output file: %s", err)) - handleError(err) - return - } - - sendOutputFile(sendOutputFileParams{ - ctx: ctx, - outputPath: outputPath, - extraHttpHeaders: extraHttpHeaders, - traceHeader: traceHeader, - trace: trace, - client: client, - handleError: handleError, - }) - }() - - return api.ErrAsyncProcess + // Execute Async Flow. + return handleAsyncWebhook(c, next, w, ctx, cancel, deadline, webhookClient, headers, cfg.ExtraHTTPHeaders, handleErrFunc) } } }(), } } + +func handleSyncWebhook(c echo.Context, next echo.HandlerFunc, ctx *api.Context, client *client, headers http.Header, extraHeaders map[string]string, handleErr func(error)) error { + if err := next(c); err != nil { + if errors.Is(err, api.ErrNoOutputFile) { + handleErr(api.WrapError( + fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err), + api.NewSentinelHttpError(http.StatusBadRequest, "The webhook middleware can only work with multipart/form-data routes that results in output files"), + )) + return nil + } + ctx.Log().ErrorContext(ctx, err.Error()) + handleErr(err) + return nil + } + + outputPath, err := ctx.BuildOutputFile() + if err != nil { + ctx.Log().ErrorContext(ctx, fmt.Sprintf("build output file: %s", err)) + handleErr(err) + return nil + } + + sendOutputFile(sendOutputFileParams{ + ctx: ctx, + outputPath: outputPath, + extraHttpHeaders: extraHeaders, + headers: headers, + client: client, + handleError: handleErr, + }) + return c.NoContent(http.StatusNoContent) +} + +func handleAsyncWebhook(c echo.Context, next echo.HandlerFunc, w *Webhook, ctx *api.Context, cancel context.CancelFunc, deadline time.Time, client *client, headers http.Header, extraHeaders map[string]string, handleErr func(error)) error { + // Detach context for async processing. + detachedCtx := context.WithoutCancel(ctx.Context) + detachedCtx, detachedCancel := context.WithDeadline(detachedCtx, deadline) + ctx.Context = detachedCtx + + originalCancel := cancel + cancel = func() { + detachedCancel() + originalCancel() + } + + w.asyncCount.Add(1) + go func() { + defer cancel() + defer w.asyncCount.Add(-1) + + if err := next(c); err != nil { + if errors.Is(err, api.ErrNoOutputFile) { + handleErr(api.WrapError( + fmt.Errorf("%w - the webhook middleware cannot handle the result of this route", err), + api.NewSentinelHttpError(http.StatusBadRequest, "The webhook middleware can only work with multipart/form-data routes that results in output files"), + )) + return + } + ctx.Log().ErrorContext(ctx, err.Error()) + handleErr(err) + return + } + + outputPath, err := ctx.BuildOutputFile() + if err != nil { + ctx.Log().ErrorContext(ctx, fmt.Sprintf("build output file: %s", err)) + handleErr(err) + return + } + + sendOutputFile(sendOutputFileParams{ + ctx: ctx, + outputPath: outputPath, + extraHttpHeaders: extraHeaders, + headers: headers, + client: client, + handleError: handleErr, + }) + }() + + return api.ErrAsyncProcess +} + +type webhookConfig struct { + URL string + ErrorURL string + Method string + ErrorMethod string + ExtraHTTPHeaders map[string]string +} + +func parseWebhookConfig(c echo.Context, w *Webhook, deadline time.Time, webhookUrl string) (*webhookConfig, error) { + errorUrl := c.Request().Header.Get("Gotenberg-Webhook-Error-Url") + if errorUrl == "" { + return nil, api.WrapError( + errors.New("empty webhook error URL"), + api.NewSentinelHttpError(http.StatusBadRequest, "Invalid 'Gotenberg-Webhook-Error-Url' header: empty value or header not provided"), + ) + } + + if err := gotenberg.FilterDeadline(w.allowList, w.denyList, webhookUrl, deadline); err != nil { + return nil, fmt.Errorf("filter webhook URL: %w", err) + } + if err := gotenberg.FilterDeadline(w.errorAllowList, w.errorDenyList, errorUrl, deadline); err != nil { + return nil, fmt.Errorf("filter webhook error URL: %w", err) + } + + method, err := methodFromHeader(c, "Gotenberg-Webhook-Method") + if err != nil { + return nil, fmt.Errorf("get method to use for webhook: %w", err) + } + + errorMethod, err := methodFromHeader(c, "Gotenberg-Webhook-Error-Method") + if err != nil { + return nil, fmt.Errorf("get method to use for webhook error: %w", err) + } + + var extraHeaders map[string]string + if extraJson := c.Request().Header.Get("Gotenberg-Webhook-Extra-Http-Headers"); extraJson != "" { + if err := json.Unmarshal([]byte(extraJson), &extraHeaders); err != nil { + return nil, api.WrapError( + fmt.Errorf("unmarshal webhook extra HTTP headers: %w", err), + api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Gotenberg-Webhook-Extra-Http-Headers' header value: %s", err.Error())), + ) + } + } + + return &webhookConfig{ + URL: webhookUrl, + ErrorURL: errorUrl, + Method: method, + ErrorMethod: errorMethod, + ExtraHTTPHeaders: extraHeaders, + }, nil +} + +func methodFromHeader(c echo.Context, header string) (string, error) { + method := c.Request().Header.Get(header) + if method == "" { + return http.MethodPost, nil + } + + method = strings.ToUpper(method) + switch method { + case http.MethodPost, http.MethodPatch, http.MethodPut: + return method, nil + } + + return "", api.WrapError( + fmt.Errorf("webhook method '%s' is not '%s', '%s' or '%s'", method, http.MethodPost, http.MethodPatch, http.MethodPut), + api.NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid '%s' header value: expected '%s', '%s' or '%s', but got '%s'", header, http.MethodPost, http.MethodPatch, http.MethodPut, method)), + ) +} + +func sendWebhookError(ctx *api.Context, c *client, headers http.Header, processErr error) { + status, message := api.ParseError(processErr) + + body := struct { + Status int `json:"status"` + Message string `json:"message"` + }{ + Status: status, + Message: message, + } + + b, err := json.Marshal(body) + if err != nil { + ctx.Log().ErrorContext(ctx, fmt.Sprintf("marshal JSON: %s", err.Error())) + return + } + + headers.Set(echo.HeaderContentLength, strconv.Itoa(len(b))) + + if err := c.send(ctx, bytes.NewReader(b), headers, true); err != nil { + ctx.Log().ErrorContext(ctx, fmt.Sprintf("send error response to webhook: %s", err.Error())) + } +} + +type sendOutputFileParams struct { + ctx *api.Context + outputPath string + extraHttpHeaders map[string]string + headers http.Header + client *client + handleError func(error) +} + +func sendOutputFile(params sendOutputFileParams) { + outputFile, err := os.Open(params.outputPath) + if err != nil { + params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("open output file: %s", err)) + params.handleError(err) + return + } + defer func() { + if err := outputFile.Close(); err != nil { + params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("close output file: %s", err)) + } + }() + + fileHeader := make([]byte, 512) + _, err = outputFile.Read(fileHeader) + if err != nil { + params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("read header of output file: %s", err)) + params.handleError(err) + return + } + + fileStat, err := outputFile.Stat() + if err != nil { + params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("get stat from output file: %s", err)) + params.handleError(err) + return + } + + _, err = outputFile.Seek(0, 0) + if err != nil { + params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("reset output file reader: %s", err)) + params.handleError(err) + return + } + + params.headers.Set(echo.HeaderContentType, http.DetectContentType(fileHeader)) + params.headers.Set(echo.HeaderContentLength, strconv.FormatInt(fileStat.Size(), 10)) + + _, ok := params.extraHttpHeaders[echo.HeaderContentDisposition] + if !ok { + params.headers.Set(echo.HeaderContentDisposition, fmt.Sprintf("attachments; filename=%q", params.ctx.OutputFilename(params.outputPath))) + } + + err = params.client.send(params.ctx, bufio.NewReader(outputFile), params.headers, false) + if err != nil { + params.ctx.Log().ErrorContext(params.ctx, fmt.Sprintf("send output file to webhook: %s", err)) + params.handleError(err) + } +} diff --git a/pkg/standard/imports.go b/pkg/standard/imports.go index de45e9d8..a6433800 100644 --- a/pkg/standard/imports.go +++ b/pkg/standard/imports.go @@ -8,11 +8,9 @@ import ( _ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/pdfengine" - _ "github.com/gotenberg/gotenberg/v8/pkg/modules/logging" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfcpu" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk" - _ "github.com/gotenberg/gotenberg/v8/pkg/modules/prometheus" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/qpdf" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/webhook" ) diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index 899df667..e2705f09 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -988,28 +988,28 @@ Feature: /forms/chromium/convert/html Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted - @embed - Scenario: POST /forms/chromium/convert/html (Embeds) + @attachments + Scenario: POST /forms/chromium/convert/html (Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): | files | testdata/page-1-html/index.html | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | foo.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata @flatten - @embed - Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + @attachments + Scenario: POST /forms/chromium/convert/html (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): | files | testdata/page-1-html/index.html | file | @@ -1017,8 +1017,8 @@ Feature: /forms/chromium/convert/html | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" @@ -1028,8 +1028,8 @@ Feature: /forms/chromium/convert/html Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): | files | teststore/foo.pdf | file | Then the response status code should be 200 @@ -1061,16 +1061,19 @@ Feature: /forms/chromium/convert/html | files | testdata/page-1-html/index.html | file | Then the response status code should be 404 - Scenario: POST /forms/chromium/convert/html (Gotenberg Trace) + @telemetry + Scenario: POST /forms/chromium/convert/html (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/html" endpoint with the following form data and header(s): - | files | testdata/page-1-html/index.html | file | - | Gotenberg-Trace | forms_chromium_convert_html | header | + | files | testdata/page-1-html/index.html | file | + | X-Correlation-ID | forms_chromium_convert_html | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html" + Then the response header "X-Correlation-ID" should be "forms_chromium_convert_html" Then the Gotenberg container should log the following entries: - | "trace":"forms_chromium_convert_html" | + | "correlation_id":"forms_chromium_convert_html" | + | "trace_id":"12345678901234567890123456789012" | @download-from Scenario: POST /forms/chromium/convert/html (Download From) diff --git a/test/integration/features/chromium_convert_markdown.feature b/test/integration/features/chromium_convert_markdown.feature index 8afa1c79..a0bbe628 100644 --- a/test/integration/features/chromium_convert_markdown.feature +++ b/test/integration/features/chromium_convert_markdown.feature @@ -990,29 +990,29 @@ Feature: /forms/chromium/convert/markdown Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted - @embed - Scenario: POST /forms/chromium/convert/markdown (Embeds) + @attachments + Scenario: POST /forms/chromium/convert/markdown (Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): | files | testdata/page-1-markdown/index.html | file | | files | testdata/page-1-markdown/page_1.md | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | foo.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata @flatten - @embed - Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + @attachments + Scenario: POST /forms/chromium/convert/markdown (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): | files | testdata/page-1-markdown/index.html | file | @@ -1021,8 +1021,8 @@ Feature: /forms/chromium/convert/markdown | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" @@ -1032,8 +1032,8 @@ Feature: /forms/chromium/convert/markdown Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): | files | teststore/foo.pdf | file | Then the response status code should be 200 @@ -1066,17 +1066,20 @@ Feature: /forms/chromium/convert/markdown | files | testdata/page-1-markdown/page_1.md | file | Then the response status code should be 404 - Scenario: POST /forms/chromium/convert/markdown (Gotenberg Trace) + @telemetry + Scenario: POST /forms/chromium/convert/markdown (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/markdown" endpoint with the following form data and header(s): - | files | testdata/page-1-markdown/index.html | file | - | files | testdata/page-1-markdown/page_1.md | file | - | Gotenberg-Trace | forms_chromium_convert_html | header | + | files | testdata/page-1-markdown/index.html | file | + | files | testdata/page-1-markdown/page_1.md | file | + | X-Correlation-ID | forms_chromium_convert_markdown | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html" + Then the response header "X-Correlation-ID" should be "forms_chromium_convert_markdown" Then the Gotenberg container should log the following entries: - | "trace":"forms_chromium_convert_html" | + | "correlation_id":"forms_chromium_convert_markdown" | + | "trace_id":"12345678901234567890123456789012" | @download-from Scenario: POST /forms/chromium/convert/markdown (Download From) diff --git a/test/integration/features/chromium_convert_url.feature b/test/integration/features/chromium_convert_url.feature index 7907d108..a367e733 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -1085,28 +1085,28 @@ Feature: /forms/chromium/convert/url Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted - @embed - Scenario: POST /foo/forms/chromium/convert/url (Embeds) + @attachments + Scenario: POST /foo/forms/chromium/convert/url (Attachments) Given I have a default Gotenberg container Given I have a static server When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the webhook request: | foo.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata @flatten - @embed + @attachments Scenario: POST /forms/chromium/convert/url (PDF/A-1b & PDF/UA-1 & Metadata & Flatten) Given I have a default Gotenberg container Given I have a static server @@ -1116,8 +1116,8 @@ Feature: /forms/chromium/convert/url | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" @@ -1127,8 +1127,8 @@ Feature: /forms/chromium/convert/url Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 9 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): | files | teststore/foo.pdf | file | Then the response status code should be 200 @@ -1161,17 +1161,20 @@ Feature: /forms/chromium/convert/url | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | Then the response status code should be 404 - Scenario: POST /forms/chromium/convert/url (Gotenberg Trace) + @telemetry + Scenario: POST /forms/chromium/convert/url (Telemetry) Given I have a default Gotenberg container Given I have a static server When I make a "POST" request to Gotenberg at the "/forms/chromium/convert/url" endpoint with the following form data and header(s): - | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | - | Gotenberg-Trace | forms_chromium_convert_url | header | + | url | http://host.docker.internal:%d/html/testdata/page-1-html/index.html | field | + | X-Correlation-ID | forms_chromium_convert_url | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_url" + Then the response header "X-Correlation-ID" should be "forms_chromium_convert_url" Then the Gotenberg container should log the following entries: - | "trace":"forms_chromium_convert_url" | + | "correlation_id":"forms_chromium_convert_url" | + | "trace_id":"12345678901234567890123456789012" | @webhook Scenario: POST /forms/chromium/convert/url (Webhook) diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature index 14eeb077..2c6742e8 100644 --- a/test/integration/features/debug.feature +++ b/test/integration/features/debug.feature @@ -25,11 +25,9 @@ Feature: /debug "libreoffice", "libreoffice-api", "libreoffice-pdfengine", - "logging", "pdfcpu", "pdfengines", "pdftk", - "prometheus", "qpdf", "webhook" ], @@ -56,8 +54,12 @@ Feature: /debug "flags": { "api-bind-ip": "", "api-body-limit": "", + "api-correlation-id-header": "X-Correlation-ID", + "api-disable-debug-route-telemetry": "false", "api-disable-download-from": "false", - "api-disable-health-check-logging": "false", + "api-disable-health-check-route-telemetry": "false", + "api-disable-root-route-telemetry": "false", + "api-disable-version-route-telemetry": "false", "api-download-from-allow-list": "", "api-download-from-deny-list": "", "api-download-from-max-retry": "4", @@ -70,7 +72,6 @@ Feature: /debug "api-timeout": "30s", "api-tls-cert-file": "", "api-tls-key-file": "", - "api-trace-header": "Gotenberg-Trace", "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", "chromium-allow-list": "", @@ -83,39 +84,37 @@ Feature: /debug "chromium-disable-web-security": "false", "chromium-host-resolver-rules": "", "chromium-ignore-certificate-errors": "false", - "chromium-incognito": "false", - "chromium-max-concurrency": "6", "chromium-max-queue-size": "0", + "chromium-max-concurrency": "6", "chromium-proxy-server": "", "chromium-restart-after": "100", "chromium-start-timeout": "20s", "gotenberg-build-debug-data": "true", "gotenberg-graceful-shutdown-duration": "30s", + "gotenberg-hide-banner": "false", "libreoffice-auto-start": "false", "libreoffice-disable-routes": "false", "libreoffice-max-queue-size": "0", "libreoffice-restart-after": "10", "libreoffice-start-timeout": "20s", "log-fields-prefix": "", - "log-format": "auto", "log-level": "info", + "log-std-enable-gcp-fields": "false", + "log-std-format": "auto", "pdfengines-convert-engines": "[libreoffice-pdfengine]", "pdfengines-disable-routes": "false", - "pdfengines-engines": "[]", + "pdfengines-add-attachments-engines": "[pdfcpu]", + "pdfengines-encrypt-engines": "[qpdf,pdfcpu,pdftk]", "pdfengines-flatten-engines": "[qpdf]", "pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]", "pdfengines-read-metadata-engines": "[exiftool]", "pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]", "pdfengines-write-metadata-engines": "[exiftool]", - "prometheus-collect-interval": "1s", - "prometheus-disable-collect": "false", - "prometheus-disable-route-logging": "false", - "prometheus-namespace": "gotenberg", - "prometheus-metrics-path": "/prometheus/metrics", "webhook-allow-list": "", "webhook-client-timeout": "30s", "webhook-deny-list": "", "webhook-disable": "false", + "webhook-enable-sync-mode": "false", "webhook-error-allow-list": "", "webhook-error-deny-list": "", "webhook-max-retry": "4", @@ -125,7 +124,7 @@ Feature: /debug } """ - Scenario: GET /debug (Environment based timezone) + Scenario: GET /debug (Environment Based Timezone) Given I have a Gotenberg container with the following environment variable(s): | API_ENABLE_DEBUG_ROUTE | true | | TZ | America/New_York | @@ -145,11 +144,9 @@ Feature: /debug "libreoffice", "libreoffice-api", "libreoffice-pdfengine", - "logging", "pdfcpu", "pdfengines", "pdftk", - "prometheus", "qpdf", "webhook" ], @@ -176,8 +173,12 @@ Feature: /debug "flags": { "api-bind-ip": "", "api-body-limit": "", + "api-correlation-id-header": "X-Correlation-ID", + "api-disable-debug-route-telemetry": "false", "api-disable-download-from": "false", - "api-disable-health-check-logging": "false", + "api-disable-health-check-route-telemetry": "false", + "api-disable-root-route-telemetry": "false", + "api-disable-version-route-telemetry": "false", "api-download-from-allow-list": "", "api-download-from-deny-list": "", "api-download-from-max-retry": "4", @@ -190,7 +191,6 @@ Feature: /debug "api-timeout": "30s", "api-tls-cert-file": "", "api-tls-key-file": "", - "api-trace-header": "Gotenberg-Trace", "chromium-allow-file-access-from-files": "false", "chromium-allow-insecure-localhost": "false", "chromium-allow-list": "", @@ -203,7 +203,6 @@ Feature: /debug "chromium-disable-web-security": "false", "chromium-host-resolver-rules": "", "chromium-ignore-certificate-errors": "false", - "chromium-incognito": "false", "chromium-max-queue-size": "0", "chromium-max-concurrency": "6", "chromium-proxy-server": "", @@ -211,31 +210,30 @@ Feature: /debug "chromium-start-timeout": "20s", "gotenberg-build-debug-data": "true", "gotenberg-graceful-shutdown-duration": "30s", + "gotenberg-hide-banner": "false", "libreoffice-auto-start": "false", "libreoffice-disable-routes": "false", "libreoffice-max-queue-size": "0", "libreoffice-restart-after": "10", "libreoffice-start-timeout": "20s", "log-fields-prefix": "", - "log-format": "auto", "log-level": "info", + "log-std-enable-gcp-fields": "false", + "log-std-format": "auto", "pdfengines-convert-engines": "[libreoffice-pdfengine]", "pdfengines-disable-routes": "false", - "pdfengines-engines": "[]", + "pdfengines-add-attachments-engines": "[pdfcpu]", + "pdfengines-encrypt-engines": "[qpdf,pdfcpu,pdftk]", "pdfengines-flatten-engines": "[qpdf]", "pdfengines-merge-engines": "[qpdf,pdfcpu,pdftk]", "pdfengines-read-metadata-engines": "[exiftool]", "pdfengines-split-engines": "[pdfcpu,qpdf,pdftk]", "pdfengines-write-metadata-engines": "[exiftool]", - "prometheus-collect-interval": "1s", - "prometheus-disable-collect": "false", - "prometheus-disable-route-logging": "false", - "prometheus-namespace": "gotenberg", - "prometheus-metrics-path": "/prometheus/metrics", "webhook-allow-list": "", "webhook-client-timeout": "30s", "webhook-deny-list": "", "webhook-disable": "false", + "webhook-enable-sync-mode": "false", "webhook-error-allow-list": "", "webhook-error-deny-list": "", "webhook-max-retry": "4", @@ -264,15 +262,31 @@ Feature: /debug } """ - Scenario: GET /debug (Gotenberg Trace) + @telemetry + Scenario: GET /debug (Telemetry) Given I have a Gotenberg container with the following environment variable(s): | API_ENABLE_DEBUG_ROUTE | true | When I make a "GET" request to Gotenberg at the "/debug" endpoint with the following header(s): - | Gotenberg-Trace | debug | + | X-Correlation-ID | debug | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 200 - Then the response header "Gotenberg-Trace" should be "debug" + Then the response header "X-Correlation-ID" should be "debug" Then the Gotenberg container should log the following entries: - | "trace":"debug" | + | "correlation_id":"debug" | + | "trace_id":"12345678901234567890123456789012" | + + @telemetry + Scenario: GET /debug (No Telemetry) + Given I have a Gotenberg container with the following environment variable(s): + | API_ENABLE_DEBUG_ROUTE | true | + | API_DISABLE_DEBUG_ROUTE_TELEMETRY | true | + When I make a "GET" request to Gotenberg at the "/debug" endpoint with the following header(s): + | X-Correlation-ID | debug_no_telemetry | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "correlation_id":"debug_no_telemetry" | + | "trace_id":"12345678901234567890123456789012" | Scenario: GET /debug (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): diff --git a/test/integration/features/health.feature b/test/integration/features/health.feature index 81ae4b75..019252e4 100644 --- a/test/integration/features/health.feature +++ b/test/integration/features/health.feature @@ -29,22 +29,29 @@ Feature: /health Then the Gotenberg container should log the following entries: | "path":"/health" | - Scenario: GET /health (No Logging) - Given I have a Gotenberg container with the following environment variable(s): - | API_DISABLE_HEALTH_CHECK_LOGGING | true | - When I make a "GET" request to Gotenberg at the "/health" endpoint - Then the response status code should be 200 - Then the Gotenberg container should NOT log the following entries: - | "path":"/health" | - - Scenario: GET /health (Gotenberg Trace) + @telemetry + Scenario: GET /health (Telemetry) Given I have a default Gotenberg container When I make a "GET" request to Gotenberg at the "/health" endpoint with the following header(s): - | Gotenberg-Trace | get_health | + | X-Correlation-ID | get_health | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 200 - Then the response header "Gotenberg-Trace" should be "get_health" + Then the response header "X-Correlation-ID" should be "get_health" Then the Gotenberg container should log the following entries: - | "trace":"get_health" | + | "correlation_id":"get_health" | + | "trace_id":"12345678901234567890123456789012" | + + @telemetry + Scenario: GET /health (No Telemetry) + Given I have a Gotenberg container with the following environment variable(s): + | API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY | true | + When I make a "GET" request to Gotenberg at the "/health" endpoint with the following header(s): + | X-Correlation-ID | get_health_no_telemetry | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "correlation_id":"get_health_no_telemetry" | + | "trace_id":"12345678901234567890123456789012" | Scenario: GET /health (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): @@ -71,22 +78,29 @@ Feature: /health Then the Gotenberg container should log the following entries: | "path":"/health" | - Scenario: HEAD /health (Gotenberg Trace) + @telemetry + Scenario: HEAD /health (Telemetry) Given I have a default Gotenberg container When I make a "HEAD" request to Gotenberg at the "/health" endpoint with the following header(s): - | Gotenberg-Trace | head_health | + | X-Correlation-ID | head_health | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 200 - Then the response header "Gotenberg-Trace" should be "head_health" + Then the response header "X-Correlation-ID" should be "head_health" Then the Gotenberg container should log the following entries: - | "trace":"head_health" | + | "correlation_id":"head_health" | + | "trace_id":"12345678901234567890123456789012" | - Scenario: HEAD /health (No Logging) + @telemetry + Scenario: HEAD /health (No Telemetry) Given I have a Gotenberg container with the following environment variable(s): - | API_DISABLE_HEALTH_CHECK_LOGGING | true | - When I make a "HEAD" request to Gotenberg at the "/health" endpoint + | API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY | true | + When I make a "HEAD" request to Gotenberg at the "/health" endpoint with the following header(s): + | X-Correlation-ID | head_health_no_telemetry | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 200 Then the Gotenberg container should NOT log the following entries: - | "path":"/health" | + | "correlation_id":"head_health_no_telemetry" | + | "trace_id":"12345678901234567890123456789012" | Scenario: HEAD /health (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 13279a71..643e9c29 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -563,28 +563,28 @@ Feature: /forms/libreoffice/convert Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted - @embed - Scenario: POST /forms/libreoffice/convert (Embeds) + @attachments + Scenario: POST /forms/libreoffice/convert (Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): - | files | testdata/page_1.docx | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | - | Gotenberg-Output-Filename | foo | header | + | files | testdata/page_1.docx | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | + | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | foo.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata @flatten - @embed - Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + @attachments + Scenario: POST /forms/libreoffice/convert (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): | files | testdata/page_1.docx | file | @@ -592,8 +592,8 @@ Feature: /forms/libreoffice/convert | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" @@ -603,8 +603,8 @@ Feature: /forms/libreoffice/convert Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): | files | teststore/foo.pdf | file | Then the response status code should be 200 @@ -636,16 +636,19 @@ Feature: /forms/libreoffice/convert | files | testdata/page_1.docx | file | Then the response status code should be 404 - Scenario: POST /forms/libreoffice/convert (Gotenberg Trace) + @telemetry + Scenario: POST /forms/libreoffice/convert (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/libreoffice/convert" endpoint with the following form data and header(s): - | files | testdata/page_1.docx | file | - | Gotenberg-Trace | forms_libreoffice_convert | header | + | files | testdata/page_1.docx | file | + | X-Correlation-ID | forms_libreoffice_convert | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert" + Then the response header "X-Correlation-ID" should be "forms_libreoffice_convert" Then the Gotenberg container should log the following entries: - | "trace":"forms_libreoffice_convert" | + | "correlation_id":"forms_libreoffice_convert" | + | "trace_id":"12345678901234567890123456789012" | @download-from Scenario: POST /forms/libreoffice/convert (Download From) diff --git a/test/integration/features/pdfengines_embed.feature b/test/integration/features/pdfengines_attachments.feature similarity index 54% rename from test/integration/features/pdfengines_embed.feature rename to test/integration/features/pdfengines_attachments.feature index 4bf9ca7b..aabaa629 100644 --- a/test/integration/features/pdfengines_embed.feature +++ b/test/integration/features/pdfengines_attachments.feature @@ -1,72 +1,72 @@ @pdfengines -@pdfengines-embed -@embed -Feature: /forms/pdfengines/embed +@pdfengines-attachments +@attachments +Feature: /forms/pdfengines/attachments/add - Scenario: POST /forms/pdfengines/embed + Scenario: POST /forms/pdfengines/attachments/add Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/attachments/add" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | page_1.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached @download-from - Scenario: POST /forms/pdfengines/embed with (Download From) + Scenario: POST /forms/pdfengines/attachments/add (Download From) Given I have a default Gotenberg container Given I have a static server - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/embed_1.xml","embedded": true},{"url":"http://host.docker.internal:%d/static/testdata/embed_2.xml","embedded": false}] | field | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/attachments/add" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/attachment_1.xml","attachment": true},{"url":"http://host.docker.internal:%d/static/testdata/attachment_2.xml","attachment": false}] | field | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the response Then there should be the following file(s) in the response: | page_1.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should NOT have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should NOT have the "attachment_2.xml" file attached @webhook - Scenario: POST /forms/pdfengines/embed (Webhook) + Scenario: POST /forms/pdfengines/attachments/add (Webhook) Given I have a default Gotenberg container Given I have a webhook server - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/attachments/add" endpoint with the following form data and header(s): | files | testdata/page_1.pdf | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | Then the response status code should be 204 When I wait for the asynchronous request to the webhook Then the webhook request header "Content-Type" should be "application/pdf" Then there should be 1 PDF(s) in the webhook request - Then the webhook request PDF(s) should have the "embed_1.xml" file embedded - Then the webhook request PDF(s) should have the "embed_2.xml" file embedded + Then the webhook request PDF(s) should have the "attachment_1.xml" file attached + Then the webhook request PDF(s) should have the "attachment_2.xml" file attached - Scenario: POST /forms/pdfengines/embed (Basic Auth) + Scenario: POST /forms/pdfengines/attachments/add (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): | API_ENABLE_BASIC_AUTH | true | | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/embed" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + When I make a "POST" request to Gotenberg at the "/forms/pdfengines/attachments/add" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | Then the response status code should be 401 - Scenario: POST /foo/forms/pdfengines/embed (Root Path) + Scenario: POST /foo/forms/pdfengines/attachments/add (Root Path) Given I have a Gotenberg container with the following environment variable(s): | API_ENABLE_DEBUG_ROUTE | true | | API_ROOT_PATH | /foo/ | - When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/embed" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/attachments/add" endpoint with the following form data and header(s): + | files | testdata/page_1.pdf | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" diff --git a/test/integration/features/pdfengines_bookmarks.feature b/test/integration/features/pdfengines_bookmarks.feature deleted file mode 100644 index 2bc34afd..00000000 --- a/test/integration/features/pdfengines_bookmarks.feature +++ /dev/null @@ -1,339 +0,0 @@ -@pdfengines -@pdfengines-bookmarks -@bookmarks -Feature: /forms/pdfengines/bookmarks/{write|read} - - Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Single PDF & Bookmarks list) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1,"children":[{"title":"Sub-index","page":1}]}] | field | - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/pdf" - Then there should be 1 PDF(s) in the response - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | teststore/foo.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response body should match JSON: - """ - { - "foo.pdf": [ - { - "title": "Index", - "page": 1, - "children": [ - { - "title": "Sub-index", - "page": 1 - } - ] - } - ] - } - """ - - Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Single PDF & Bookmarks Map) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | {"page_1.pdf":[{"title":"Index","page":1}]} | field | - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/pdf" - Then there should be 1 PDF(s) in the response - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | teststore/foo.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response body should match JSON: - """ - { - "foo.pdf": [ - { - "title": "Index", - "page": 1 - } - ] - } - """ - - Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Many PDFs & Bookmarks List) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files. | testdata/page_1.pdf | file | - | files | testdata/page_2.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/zip" - Then there should be 2 PDF(s) in the response - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | teststore/page_1.pdf | file | - | files | teststore/page_2.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response body should match JSON: - """ - { - "page_1.pdf": [ - { - "title": "Index", - "page": 1 - } - ], - "page_2.pdf": [ - { - "title": "Index", - "page": 1 - } - ] - } - """ - - Scenario: POST /forms/pdfengines/bookmarks/{write|read} (Many PDFs & Bookmarks Map) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files. | testdata/page_1.pdf | file | - | files | testdata/page_2.pdf | file | - | bookmarks | {"page_1.pdf":[{"title":"Index","page":1}],"page_2.pdf":[{"title":"Index","page":1}]} | field | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/zip" - Then there should be 2 PDF(s) in the response - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | teststore/page_1.pdf | file | - | files | teststore/page_2.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response body should match JSON: - """ - { - "page_1.pdf": [ - { - "title": "Index", - "page": 1 - } - ], - "page_2.pdf": [ - { - "title": "Index", - "page": 1 - } - ] - } - """ - - Scenario: POST /forms/pdfengines/bookmarks/read (Empty List) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response body should match JSON: - """ - { - "page_1.pdf": [] - } - """ - - Scenario: POST /forms/pdfengines/bookmarks/write (Bad Request) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 400 - Then the response header "Content-Type" should be "text/plain; charset=UTF-8" - Then the response body should match string: - """ - Invalid form data: form field 'bookmarks' is required; no form file found for extensions: [.pdf] - """ - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | foo | field | - Then the response status code should be 400 - Then the response header "Content-Type" should be "text/plain; charset=UTF-8" - Then the response body should match string: - """ - Invalid form data: form field 'bookmarks' is invalid (got 'foo', resulting to unmarshal bookmarks: invalid character 'o' in literal false (expecting 'a')) - """ - - Scenario: POST /forms/pdfengines/bookmarks/read (Bad Request) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 400 - Then the response header "Content-Type" should be "text/plain; charset=UTF-8" - Then the response body should match string: - """ - Invalid form data: no form file found for extensions: [.pdf] - """ - - Scenario: POST /forms/pdfengines/bookmarks/write (Routes Disabled) - Given I have a Gotenberg container with the following environment variable(s): - | PDFENGINES_DISABLE_ROUTES | true | - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - Then the response status code should be 404 - - Scenario: POST /forms/pdfengines/bookmarks/read (Routes Disabled) - Given I have a Gotenberg container with the following environment variable(s): - | PDFENGINES_DISABLE_ROUTES | true | - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - Then the response status code should be 404 - - Scenario: POST /forms/pdfengines/bookmarks/write (Gotenberg Trace) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - | Gotenberg-Trace | forms_pdfengines_bookmarks_write | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_write" - Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_bookmarks_write" | - - Scenario: POST /forms/pdfengines/bookmarks/read (Gotenberg Trace) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | Gotenberg-Trace | forms_pdfengines_bookmarks_read | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_read" - Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_bookmarks_read" | - - @output-filename - Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Single PDF) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/pdf" - Then there should be the following file(s) in the response: - | foo.pdf | - - @output-filename - Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Many PDFs) - Given I have a default Gotenberg container - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | files | testdata/page_2.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/zip" - Then there should be the following file(s) in the response: - | foo.zip | - | page_1.pdf | - | page_2.pdf | - - @download-from - Scenario: POST /forms/pdfengines/bookmarks/write (Download From) - Given I have a default Gotenberg container - Given I have a static server - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | bookmarks | [{"title":"Index","page":1}] | field | - | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | - | Gotenberg-Output-Filename | foo | header | - Then the file request header "X-Foo" should be "bar" - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/pdf" - - @download-from - Scenario: POST /forms/pdfengines/bookmarks/read (Download From) - Given I have a default Gotenberg container - Given I have a static server - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | downloadFrom | [{"url":"http://host.docker.internal:%d/static/testdata/page_1.pdf","extraHttpHeaders":{"X-Foo":"bar"}}] | field | - Then the file request header "X-Foo" should be "bar" - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - - @webhook - Scenario: POST /forms/pdfengines/bookmarks/write (Webhook) - Given I have a default Gotenberg container - Given I have a webhook server - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | - | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 204 - When I wait for the asynchronous request to the webhook - Then the webhook request header "Content-Type" should be "application/pdf" - Then there should be 1 PDF(s) in the webhook request - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | teststore/foo.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" - Then the response body should match JSON: - """ - { - "foo.pdf": [ - { - "title": "Index", - "page": 1 - } - ] - } - """ - - @webhook - Scenario: POST /forms/pdfengines/bookmarks/read (Webhook) - Given I have a default Gotenberg container - Given I have a webhook server - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | Gotenberg-Webhook-Url | http://host.docker.internal:%d/webhook | header | - | Gotenberg-Webhook-Error-Url | http://host.docker.internal:%d/webhook/error | header | - Then the response status code should be 204 - When I wait for the asynchronous request to the webhook - Then the webhook request header "Content-Type" should be "application/json" - Then the webhook request body should match JSON: - """ - { - "status": 400, - "message": "The webhook middleware can only work with multipart/form-data routes that results in output files" - } - """ - - Scenario: POST /forms/pdfengines/bookmarks/write (Basic Auth) - Given I have a Gotenberg container with the following environment variable(s): - | API_ENABLE_BASIC_AUTH | true | - | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | - | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - Then the response status code should be 401 - - Scenario: POST /forms/pdfengines/bookmarks/read (Basic Auth) - Given I have a Gotenberg container with the following environment variable(s): - | API_ENABLE_BASIC_AUTH | true | - | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | - | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | - When I make a "POST" request to Gotenberg at the "/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - Then the response status code should be 401 - - Scenario: POST /foo/forms/pdfengines/bookmarks/{write|read} (Root Path) - Given I have a Gotenberg container with the following environment variable(s): - | API_ENABLE_DEBUG_ROUTE | true | - | API_ROOT_PATH | /foo/ | - When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/bookmarks/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | bookmarks | [{"title":"Index","page":1}] | field | - | Gotenberg-Output-Filename | foo | header | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/pdf" - When I make a "POST" request to Gotenberg at the "/foo/forms/pdfengines/bookmarks/read" endpoint with the following form data and header(s): - | files | teststore/foo.pdf | file | - Then the response status code should be 200 - Then the response header "Content-Type" should be "application/json" diff --git a/test/integration/features/pdfengines_convert.feature b/test/integration/features/pdfengines_convert.feature index e2157eb5..dcb5ee84 100644 --- a/test/integration/features/pdfengines_convert.feature +++ b/test/integration/features/pdfengines_convert.feature @@ -105,17 +105,20 @@ Feature: /forms/pdfengines/convert Invalid form data: form field 'pdfua' is invalid (got 'foo', resulting to strconv.ParseBool: parsing "foo": invalid syntax) """ - Scenario: POST /forms/pdfengines/convert (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/convert (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/convert" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | pdfa | PDF/A-1b | field | - | Gotenberg-Trace | forms_pdfengines_convert | header | + | files | testdata/page_1.pdf | file | + | pdfa | PDF/A-1b | field | + | X-Correlation-ID | forms_pdfengines_convert | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_convert" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_convert" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_convert" | + | "correlation_id":"forms_pdfengines_convert" | + | "trace_id":"12345678901234567890123456789012" | @output-filename Scenario: POST /forms/pdfengines/convert (Output Filename - Single PDF) diff --git a/test/integration/features/pdfengines_encrypt.feature b/test/integration/features/pdfengines_encrypt.feature index 0a072c7e..f81d004a 100644 --- a/test/integration/features/pdfengines_encrypt.feature +++ b/test/integration/features/pdfengines_encrypt.feature @@ -123,17 +123,20 @@ Feature: /forms/pdfengines/encrypt | files | testdata/page_1.pdf | file | Then the response status code should be 404 - Scenario: POST /forms/pdfengines/encrypt (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/encrypt (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/encrypt" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | userPassword | foo | field | - | Gotenberg-Trace | forms_pdfengines_encrypt | header | + | files | testdata/page_1.pdf | file | + | userPassword | foo | field | + | X-Correlation-ID | forms_pdfengines_encrypt | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_encrypt" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_encrypt" | + | "correlation_id":"forms_pdfengines_encrypt" | + | "trace_id":"12345678901234567890123456789012" | @download-from Scenario: POST /forms/pdfengines/encrypt (Download From) diff --git a/test/integration/features/pdfengines_flatten.feature b/test/integration/features/pdfengines_flatten.feature index 11529a73..24f7902f 100644 --- a/test/integration/features/pdfengines_flatten.feature +++ b/test/integration/features/pdfengines_flatten.feature @@ -40,16 +40,19 @@ Feature: /forms/pdfengines/flatten | files | testdata/page_1.pdf | file | Then the response status code should be 404 - Scenario: POST /forms/pdfengines/flatten (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/flatten (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/flatten" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | Gotenberg-Trace | forms_pdfengines_flatten | header | + | files | testdata/page_1.pdf | file | + | X-Correlation-ID | forms_pdfengines_flatten | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_flatten" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_flatten" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_flatten" | + | "correlation_id":"forms_pdfengines_flatten" | + | "trace_id":"12345678901234567890123456789012" | @output-filename Scenario: POST /forms/pdfengines/flatten (Output Filename - Single PDF) diff --git a/test/integration/features/pdfengines_merge.feature b/test/integration/features/pdfengines_merge.feature index bb520c78..1061f550 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -252,25 +252,25 @@ Feature: /forms/pdfengines/merge Then there should be 1 PDF(s) in the response Then the response PDF(s) should be encrypted - @embed - Scenario: POST /foo/forms/pdfengines/merge (Embeds) + @attachments + Scenario: POST /foo/forms/pdfengines/merge (Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | files | testdata/page_2.pdf | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | - | Gotenberg-Output-Filename | foo | header | + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | + | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached # FIXME: once decrypt is done, add encrypt and check after the content of the PDF. @convert @metadata @flatten - @embed - Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + @attachments + Scenario: POST /forms/pdfengines/merge (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): | files | testdata/page_1.pdf | file | @@ -279,8 +279,8 @@ Feature: /forms/pdfengines/merge | pdfua | true | field | | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | | Gotenberg-Output-Filename | foo | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" @@ -299,8 +299,8 @@ Feature: /forms/pdfengines/merge Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): | files | teststore/foo.pdf | file | Then the response status code should be 200 @@ -333,17 +333,20 @@ Feature: /forms/pdfengines/merge | files | testdata/page_2.pdf | file | Then the response status code should be 404 - Scenario: POST /forms/pdfengines/merge (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/merge (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/merge" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | files | testdata/page_2.pdf | file | - | Gotenberg-Trace | forms_pdfengines_merge | header | + | files | testdata/page_1.pdf | file | + | files | testdata/page_2.pdf | file | + | X-Correlation-ID | forms_pdfengines_merge | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_merge" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_merge" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_merge" | + | "correlation_id":"forms_pdfengines_merge" | + | "trace_id":"12345678901234567890123456789012" | @download-from Scenario: POST /forms/pdfengines/merge (Download From) diff --git a/test/integration/features/pdfengines_metadata.feature b/test/integration/features/pdfengines_metadata.feature index 8a5b9148..1276cc9f 100644 --- a/test/integration/features/pdfengines_metadata.feature +++ b/test/integration/features/pdfengines_metadata.feature @@ -1,7 +1,7 @@ @pdfengines @pdfengines-metadata @metadata -Feature: /forms/pdfengines/metadata/{write|read} +Feature: /forms/pdfengines/{write|read} Scenario: POST /forms/pdfengines/metadata/{write|read} (Single PDF) Given I have a default Gotenberg container @@ -131,28 +131,34 @@ Feature: /forms/pdfengines/metadata/{write|read} | files | testdata/page_1.pdf | file | Then the response status code should be 404 - Scenario: POST /forms/pdfengines/metadata/write (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/metadata/write (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/write" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | - | Gotenberg-Trace | forms_pdfengines_metadata_write | header | + | files | testdata/page_1.pdf | file | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | X-Correlation-ID | forms_pdfengines_metadata_write | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/pdf" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_write" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_metadata_write" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_metadata_write" | + | "correlation_id":"forms_pdfengines_metadata_write" | + | "trace_id":"12345678901234567890123456789012" | - Scenario: POST /forms/pdfengines/metadata/read (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/metadata/read (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): - | files | testdata/page_1.pdf | file | - | Gotenberg-Trace | forms_pdfengines_metadata_read | header | + | files | testdata/page_1.pdf | file | + | X-Correlation-ID | forms_pdfengines_metadata_read | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/json" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_read" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_metadata_read" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_metadata_read" | + | "correlation_id":"forms_pdfengines_metadata_read" | + | "trace_id":"12345678901234567890123456789012" | @output-filename Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Single PDF) diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index ae4dac4f..031ca281 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -473,41 +473,41 @@ Feature: /forms/pdfengines/split Then there should be 2 PDF(s) in the response Then the response PDF(s) should be encrypted - @embed - Scenario: POST /foo/forms/pdfengines/split (Embeds) + @attachments + Scenario: POST /foo/forms/pdfengines/split (Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): - | files | testdata/pages_3.pdf | file | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | - | splitMode | intervals | field | - | splitSpan | 2 | field | + | files | testdata/pages_3.pdf | file | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | Then the response status code should be 200 And the response header "Content-Type" should be "application/zip" And there should be 2 PDF(s) in the response And there should be the following file(s) in the response: | pages_3_0.pdf | | pages_3_1.pdf | - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached # FIXME: once decrypt is done, add encrypt and check after the content of the PDFs. @convert @metadata @flatten - @embed - Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Embeds) + @attachments + Scenario: POST /forms/pdfengines/split (PDF/A-1b & PDF/UA-1 & Metadata & Flatten & Attachments) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): - | files | testdata/pages_3.pdf | file | - | splitMode | intervals | field | - | splitSpan | 2 | field | - | pdfa | PDF/A-1b | field | - | pdfua | true | field | - | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | - | flatten | true | field | - | embeds | testdata/embed_1.xml | file | - | embeds | testdata/embed_2.xml | file | + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | pdfa | PDF/A-1b | field | + | pdfua | true | field | + | metadata | {"Author":"Julien Neuhart","Copyright":"Julien Neuhart","CreateDate":"2006-09-18T16:27:50-04:00","Creator":"Gotenberg","Keywords":["first","second"],"Marked":true,"ModDate":"2006-09-18T16:27:50-04:00","PDFVersion":1.7,"Producer":"Gotenberg","Subject":"Sample","Title":"Sample","Trapped":"Unknown"} | field | + | flatten | true | field | + | attachments | testdata/attachment_1.xml | file | + | attachments | testdata/attachment_2.xml | file | Then the response status code should be 200 Then the response header "Content-Type" should be "application/zip" Then there should be 2 PDF(s) in the response @@ -531,8 +531,8 @@ Feature: /forms/pdfengines/split Then the response PDF(s) should be valid "PDF/A-1b" with a tolerance of 10 failed rule(s) Then the response PDF(s) should be valid "PDF/UA-1" with a tolerance of 2 failed rule(s) Then the response PDF(s) should be flatten - Then the response PDF(s) should have the "embed_1.xml" file embedded - Then the response PDF(s) should have the "embed_2.xml" file embedded + Then the response PDF(s) should have the "attachment_1.xml" file attached + Then the response PDF(s) should have the "attachment_2.xml" file attached When I make a "POST" request to Gotenberg at the "/forms/pdfengines/metadata/read" endpoint with the following form data and header(s): | files | teststore/pages_3_0.pdf | file | | files | teststore/pages_3_1.pdf | file | @@ -581,18 +581,21 @@ Feature: /forms/pdfengines/split | splitSpan | 2 | field | Then the response status code should be 404 - Scenario: POST /forms/pdfengines/split (Gotenberg Trace) + @telemetry + Scenario: POST /forms/pdfengines/split (Telemetry) Given I have a default Gotenberg container When I make a "POST" request to Gotenberg at the "/forms/pdfengines/split" endpoint with the following form data and header(s): - | files | testdata/pages_3.pdf | file | - | splitMode | intervals | field | - | splitSpan | 2 | field | - | Gotenberg-Trace | forms_pdfengines_split | header | + | files | testdata/pages_3.pdf | file | + | splitMode | intervals | field | + | splitSpan | 2 | field | + | X-Correlation-ID | forms_pdfengines_split | header | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | header | Then the response status code should be 200 Then the response header "Content-Type" should be "application/zip" - Then the response header "Gotenberg-Trace" should be "forms_pdfengines_split" + Then the response header "X-Correlation-ID" should be "forms_pdfengines_split" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_split" | + | "correlation_id":"forms_pdfengines_split" | + | "trace_id":"12345678901234567890123456789012" | @output-filename Scenario: POST /forms/pdfengines/split (Output Filename - Single PDF) diff --git a/test/integration/features/prometheus_metrics.feature b/test/integration/features/prometheus_metrics.feature deleted file mode 100644 index 19a1e40e..00000000 --- a/test/integration/features/prometheus_metrics.feature +++ /dev/null @@ -1,116 +0,0 @@ -# TODO: -# 1. Count restarts. -# 2. Count queue size. - -@prometheus-metrics -Feature: /prometheus/metrics - - Scenario: GET /prometheus/metrics (Enabled) - Given I have a default Gotenberg container - When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint - Then the response status code should be 200 - Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores" - Then the response body should match string: - """ - # HELP gotenberg_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated. - # TYPE gotenberg_chromium_requests_queue_size gauge - gotenberg_chromium_requests_queue_size 0 - # HELP gotenberg_chromium_restarts_count Current number of Chromium restarts. - # TYPE gotenberg_chromium_restarts_count gauge - gotenberg_chromium_restarts_count 0 - # HELP gotenberg_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated. - # TYPE gotenberg_libreoffice_requests_queue_size gauge - gotenberg_libreoffice_requests_queue_size 0 - # HELP gotenberg_libreoffice_restarts_count Current number of LibreOffice restarts. - # TYPE gotenberg_libreoffice_restarts_count gauge - gotenberg_libreoffice_restarts_count 0 - - """ - Then the Gotenberg container should log the following entries: - | "path":"/prometheus/metrics" | - - Scenario: GET /custom/metrics (Custom Metrics Path) - Given I have a Gotenberg container with the following environment variable(s): - | PROMETHEUS_METRICS_PATH | /custom/metrics | - When I make a "GET" request to Gotenberg at the "/custom/metrics" endpoint - Then the response status code should be 200 - Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores" - Then the response body should match string: - """ - # HELP gotenberg_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated. - # TYPE gotenberg_chromium_requests_queue_size gauge - gotenberg_chromium_requests_queue_size 0 - # HELP gotenberg_chromium_restarts_count Current number of Chromium restarts. - # TYPE gotenberg_chromium_restarts_count gauge - gotenberg_chromium_restarts_count 0 - # HELP gotenberg_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated. - # TYPE gotenberg_libreoffice_requests_queue_size gauge - gotenberg_libreoffice_requests_queue_size 0 - # HELP gotenberg_libreoffice_restarts_count Current number of LibreOffice restarts. - # TYPE gotenberg_libreoffice_restarts_count gauge - gotenberg_libreoffice_restarts_count 0 - - """ - Then the Gotenberg container should log the following entries: - | "path":"/custom/metrics" | - - Scenario: GET /prometheus/metrics (Custom Namespace) - Given I have a Gotenberg container with the following environment variable(s): - | PROMETHEUS_NAMESPACE | foo | - When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint - Then the response status code should be 200 - Then the response header "Content-Type" should be "text/plain; version=0.0.4; charset=utf-8; escaping=underscores" - Then the response body should match string: - """ - # HELP foo_chromium_requests_queue_size Current number of Chromium conversion requests waiting to be treated. - # TYPE foo_chromium_requests_queue_size gauge - foo_chromium_requests_queue_size 0 - # HELP foo_chromium_restarts_count Current number of Chromium restarts. - # TYPE foo_chromium_restarts_count gauge - foo_chromium_restarts_count 0 - # HELP foo_libreoffice_requests_queue_size Current number of LibreOffice conversion requests waiting to be treated. - # TYPE foo_libreoffice_requests_queue_size gauge - foo_libreoffice_requests_queue_size 0 - # HELP foo_libreoffice_restarts_count Current number of LibreOffice restarts. - # TYPE foo_libreoffice_restarts_count gauge - foo_libreoffice_restarts_count 0 - - """ - - Scenario: GET /prometheus/metrics (Disabled) - Given I have a Gotenberg container with the following environment variable(s): - | PROMETHEUS_DISABLE_COLLECT | true | - When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint - Then the response status code should be 404 - - Scenario: GET /prometheus/metrics (No Logging) - Given I have a Gotenberg container with the following environment variable(s): - | PROMETHEUS_DISABLE_ROUTE_LOGGING | true | - When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint - Then the response status code should be 200 - Then the Gotenberg container should NOT log the following entries: - | "path":"/prometheus/metrics" | - - Scenario: GET /prometheus/metrics (Gotenberg Trace) - Given I have a default Gotenberg container - When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint with the following header(s): - | Gotenberg-Trace | prometheus_metrics | - Then the response status code should be 200 - Then the response header "Gotenberg-Trace" should be "prometheus_metrics" - Then the Gotenberg container should log the following entries: - | "trace":"prometheus_metrics" | - - Scenario: GET /prometheus/metrics (Basic Auth) - Given I have a Gotenberg container with the following environment variable(s): - | API_ENABLE_BASIC_AUTH | true | - | GOTENBERG_API_BASIC_AUTH_USERNAME | foo | - | GOTENBERG_API_BASIC_AUTH_PASSWORD | bar | - When I make a "GET" request to Gotenberg at the "/prometheus/metrics" endpoint - Then the response status code should be 401 - - Scenario: GET /foo/prometheus/metrics (Root Path) - Given I have a Gotenberg container with the following environment variable(s): - | API_ENABLE_DEBUG_ROUTE | true | - | API_ROOT_PATH | /foo/ | - When I make a "GET" request to Gotenberg at the "/foo/prometheus/metrics" endpoint - Then the response status code should be 200 diff --git a/test/integration/features/root.feature b/test/integration/features/root.feature index a1c6f506..dd26fdb9 100644 --- a/test/integration/features/root.feature +++ b/test/integration/features/root.feature @@ -11,14 +11,29 @@ Feature: / Hey, Gotenberg has no UI, it's an API. Head to the documentation to learn how to interact with it 🚀 """ - Scenario: GET / (Gotenberg Trace) + @telemetry + Scenario: GET / (Telemetry) Given I have a default Gotenberg container When I make a "GET" request to Gotenberg at the "/" endpoint with the following header(s): - | Gotenberg-Trace | root | + | X-Correlation-ID | root | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 200 - Then the response header "Gotenberg-Trace" should be "root" + Then the response header "X-Correlation-ID" should be "root" Then the Gotenberg container should log the following entries: - | "trace":"root" | + | "correlation_id":"root" | + | "trace_id":"12345678901234567890123456789012" | + + @telemetry + Scenario: GET / (No Telemetry) + Given I have a Gotenberg container with the following environment variable(s): + | API_DISABLE_ROOT_ROUTE_TELEMETRY | true | + When I make a "GET" request to Gotenberg at the "/" endpoint with the following header(s): + | X-Correlation-ID | root_no_telemetry | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "correlation_id":"root_no_telemetry" | + | "trace_id":"12345678901234567890123456789012" | Scenario: GET / (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): @@ -39,14 +54,16 @@ Feature: / When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint Then the response status code should be 204 - Scenario: GET /favicon.ico (Gotenberg Trace) + @telemetry + Scenario: GET /favicon.ico (No Telemetry) Given I have a default Gotenberg container When I make a "GET" request to Gotenberg at the "/favicon.ico" endpoint with the following header(s): - | Gotenberg-Trace | favicon | + | X-Correlation-ID | favicon_no_telemetry | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 204 - Then the response header "Gotenberg-Trace" should be "favicon" - Then the Gotenberg container should log the following entries: - | "trace":"favicon" | + Then the Gotenberg container should NOT log the following entries: + | "correlation_id":"favicon_no_telemetry" | + | "trace_id":"12345678901234567890123456789012" | Scenario: GET /favicon.ico (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): diff --git a/test/integration/features/version.feature b/test/integration/features/version.feature index 02975505..84ea9205 100644 --- a/test/integration/features/version.feature +++ b/test/integration/features/version.feature @@ -11,14 +11,29 @@ Feature: /version {version} """ - Scenario: GET /version (Gotenberg Trace) + @telemetry + Scenario: GET /version (Telemetry) Given I have a default Gotenberg container When I make a "GET" request to Gotenberg at the "/version" endpoint with the following header(s): - | Gotenberg-Trace | version | + | X-Correlation-ID | version | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | Then the response status code should be 200 - Then the response header "Gotenberg-Trace" should be "version" + Then the response header "X-Correlation-ID" should be "version" Then the Gotenberg container should log the following entries: - | "trace":"version" | + | "correlation_id":"version" | + | "trace_id":"12345678901234567890123456789012" | + + @telemetry + Scenario: GET /version (No Telemetry) + Given I have a Gotenberg container with the following environment variable(s): + | API_DISABLE_VERSION_ROUTE_TELEMETRY | true | + When I make a "GET" request to Gotenberg at the "/version" endpoint with the following header(s): + | X-Correlation-ID | version_no_telemetry | + | traceparent | 00-12345678901234567890123456789012-1234567890123456-01 | + Then the response status code should be 200 + Then the Gotenberg container should NOT log the following entries: + | "correlation_id":"version_no_telemetry" | + | "trace_id":"12345678901234567890123456789012" | Scenario: GET /version (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): @@ -30,7 +45,6 @@ Feature: /version Scenario: GET /foo/version (Root Path) Given I have a Gotenberg container with the following environment variable(s): - | API_ENABLE_DEBUG_ROUTE | true | - | API_ROOT_PATH | /foo/ | + | API_ROOT_PATH | /foo/ | When I make a "GET" request to Gotenberg at the "/foo/version" endpoint Then the response status code should be 200 diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index 29413268..0423c4c4 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -18,7 +18,6 @@ import ( "time" "github.com/cucumber/godog" - "github.com/google/uuid" "github.com/mholt/archives" "github.com/testcontainers/testcontainers-go" ) @@ -163,7 +162,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct fields[name] = value case "file": if strings.Contains(value, "teststore") { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { return fmt.Errorf("directory %q does not exist", dirPath) @@ -235,7 +234,7 @@ func (s *scenario) iMakeARequestToGotenbergWithTheFollowingFormDataAndHeaders(ct return errors.New("no filename in Content-Disposition header") } - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) err = os.MkdirAll(dirPath, 0o755) if err != nil { return fmt.Errorf("create working directory: %w", err) @@ -361,7 +360,7 @@ func (s *scenario) iMakeConcurrentRequestsToGotenberg(ctx context.Context, count _, params, parseErr := mime.ParseMediaType(cd) if parseErr == nil { if filename, ok := params["filename"]; ok { - traceID := resp.Header.Get("Gotenberg-Trace") + traceID := resp.Header.Get("X-Correlation-ID") dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID) mu.Lock() @@ -416,7 +415,7 @@ func (s *scenario) allConcurrentResponsesShouldHavePdfs(expected int) error { } for i, resp := range s.concurrentResps { - traceID := resp.Header().Get("Gotenberg-Trace") + traceID := resp.Header().Get("X-Correlation-ID") dirPath := fmt.Sprintf("%s/%s", s.workdir, traceID) _, err := os.Stat(dirPath) @@ -637,7 +636,7 @@ func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocStr } func (s *scenario) thereShouldBePdfs(expected int, kind string) error { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { @@ -666,7 +665,7 @@ func (s *scenario) thereShouldBePdfs(expected int, kind string) error { } func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog.Table) error { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { @@ -708,7 +707,7 @@ func (s *scenario) thereShouldBeTheFollowingFiles(kind string, filesTable *godog } func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kind, validate string, tolerance int) error { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { @@ -780,7 +779,7 @@ func (s *scenario) thePdfsShouldBeValidWithAToleranceOf(ctx context.Context, kin func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages int) error { var path string if !strings.HasPrefix(name, "*_") { - path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID"), name) _, err := os.Stat(path) if os.IsNotExist(err) { @@ -788,7 +787,7 @@ func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages } } else { substr := strings.ReplaceAll(name, "*_", "") - err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error { + err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")), func(currentPath string, info os.FileInfo, pathErr error) error { if pathErr != nil { return pathErr } @@ -836,7 +835,7 @@ func (s *scenario) thePdfShouldHavePages(ctx context.Context, name string, pages func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, name string, kind string) error { var path string if !strings.HasPrefix(name, "*_") { - path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID"), name) _, err := os.Stat(path) if os.IsNotExist(err) { @@ -844,7 +843,7 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, } } else { substr := strings.ReplaceAll(name, "*_", "") - err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error { + err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")), func(currentPath string, info os.FileInfo, pathErr error) error { if pathErr != nil { return pathErr } @@ -903,7 +902,7 @@ func (s *scenario) thePdfShouldBeSetToLandscapeOrientation(ctx context.Context, func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context, name, kind string, page int, expected *godog.DocString) error { var path string if !strings.HasPrefix(name, "*_") { - path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace"), name) + path = fmt.Sprintf("%s/%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID"), name) _, err := os.Stat(path) if os.IsNotExist(err) { @@ -911,7 +910,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context } } else { substr := strings.ReplaceAll(name, "*_", "") - err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")), func(currentPath string, info os.FileInfo, pathErr error) error { + err := filepath.Walk(fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")), func(currentPath string, info os.FileInfo, pathErr error) error { if pathErr != nil { return pathErr } @@ -955,7 +954,7 @@ func (s *scenario) thePdfShouldHaveTheFollowingContentAtPage(ctx context.Context } func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should string) error { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { @@ -1005,7 +1004,7 @@ func (s *scenario) thePdfsShouldBeFlatten(ctx context.Context, kind, should stri } func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, should string) error { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { @@ -1056,8 +1055,8 @@ func (s *scenario) thePdfsShouldBeEncrypted(ctx context.Context, kind string, sh return nil } -func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, should, embed string) error { - dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("Gotenberg-Trace")) +func (s *scenario) thePdfsShouldHaveAttachedFile(ctx context.Context, kind, should, attachments string) error { + dirPath := fmt.Sprintf("%s/%s", s.workdir, s.resp.Header().Get("X-Correlation-ID")) _, err := os.Stat(dirPath) if os.IsNotExist(err) { @@ -1096,20 +1095,27 @@ func (s *scenario) thePdfsShouldHaveEmbeddedFile(ctx context.Context, kind, shou return fmt.Errorf("exec %q: %w", cmd, err) } - found := strings.Contains(output, fmt.Sprintf("%s", embed)) + found := strings.Contains(output, fmt.Sprintf("%s", attachments)) if invert && found { - return fmt.Errorf("embed %q found", embed) + return fmt.Errorf("attachment %q found", attachments) } if !invert && !found { - return fmt.Errorf("embed %q not found", embed) + return fmt.Errorf("attachment %q not found", attachments) } } return nil } +var nonAlphanumericRegex = regexp.MustCompile(`[^a-zA-Z0-9]+`) + +func sanitizeDirName(name string) string { + sanitized := nonAlphanumericRegex.ReplaceAllString(name, "_") + return strings.Trim(sanitized, "_") +} + func InitializeScenario(ctx *godog.ScenarioContext) { s := &scenario{} ctx.Before(func(ctx context.Context, sc *godog.Scenario) (context.Context, error) { @@ -1117,7 +1123,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { if err != nil { return ctx, fmt.Errorf("get current directory: %w", err) } - s.workdir = fmt.Sprintf("%s/teststore/%s", wd, uuid.NewString()) + s.workdir = fmt.Sprintf("%s/teststore/%s_%s", wd, sanitizeDirName(sc.Name), sc.Id) err = os.MkdirAll(s.workdir, 0o755) if err != nil { return ctx, fmt.Errorf("create working directory: %w", err) @@ -1146,7 +1152,7 @@ func InitializeScenario(ctx *godog.ScenarioContext) { ctx.Then(`^the (response|webhook request) PDF\(s\) should be valid "([^"]*)" with a tolerance of (\d+) failed rule\(s\)$`, s.thePdfsShouldBeValidWithAToleranceOf) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be flatten$`, s.thePdfsShouldBeFlatten) ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) be encrypted`, s.thePdfsShouldBeEncrypted) - ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file embedded$`, s.thePdfsShouldHaveEmbeddedFile) + ctx.Then(`^the (response|webhook request) PDF\(s\) (should|should NOT) have the "([^"]*)" file attached$`, s.thePdfsShouldHaveAttachedFile) ctx.Then(`^the "([^"]*)" PDF should have (\d+) page\(s\)$`, s.thePdfShouldHavePages) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) be set to landscape orientation$`, s.thePdfShouldBeSetToLandscapeOrientation) ctx.Then(`^the "([^"]*)" PDF (should|should NOT) have the following content at page (\d+):$`, s.thePdfShouldHaveTheFollowingContentAtPage) diff --git a/test/integration/scenario/server.go b/test/integration/scenario/server.go index 41b67298..d00ce8e5 100644 --- a/test/integration/scenario/server.go +++ b/test/integration/scenario/server.go @@ -78,7 +78,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) { } } - dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace")) + dirPath := fmt.Sprintf("%s/%s", workdir, s.req.Header.Get("X-Correlation-ID")) // #nosec err = os.MkdirAll(dirPath, 0o755) if err != nil { @@ -148,7 +148,7 @@ func newServer(ctx context.Context, workdir string) (*server, error) { s.req = c.Request() path := c.Param("path") if strings.Contains(path, "teststore") { - return c.Attachment(fmt.Sprintf("%s/%s/%s", workdir, s.req.Header.Get("Gotenberg-Trace"), filepath.Base(path)), filepath.Base(path)) + return c.Attachment(fmt.Sprintf("%s/%s/%s", workdir, s.req.Header.Get("X-Correlation-ID"), filepath.Base(path)), filepath.Base(path)) } return c.Attachment(fmt.Sprintf("%s/%s", wd, path), filepath.Base(path)) }) diff --git a/test/integration/testdata/embed_1.xml b/test/integration/testdata/attachment_1.xml similarity index 100% rename from test/integration/testdata/embed_1.xml rename to test/integration/testdata/attachment_1.xml diff --git a/test/integration/testdata/embed_2.xml b/test/integration/testdata/attachment_2.xml similarity index 100% rename from test/integration/testdata/embed_2.xml rename to test/integration/testdata/attachment_2.xml