diff --git a/.golangci.yml b/.golangci.yml index 1108eb2..655453d 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -24,13 +24,14 @@ linters: - ineffassign - misspell - prealloc - - promlinter - staticcheck - testableexamples - tparallel - unconvert - unused + - sloglint - usetesting + - gocritic - wastedassign - whitespace exclusions: @@ -40,10 +41,6 @@ linters: - common-false-positives - legacy - std-error-handling - paths: - - third_party$ - - builtin$ - - examples$ formatters: enable: - gci @@ -59,7 +56,3 @@ formatters: custom-order: true exclusions: generated: lax - paths: - - third_party$ - - builtin$ - - examples$ diff --git a/Makefile b/Makefile index 52e470c..dd6fc00 100644 --- a/Makefile +++ b/Makefile @@ -20,16 +20,16 @@ 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_ENABLE_DEBUG_ROUTE=false CHROMIUM_RESTART_AFTER=100 CHROMIUM_MAX_QUEUE_SIZE=0 @@ -54,9 +54,9 @@ 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 +LOG_STD_FORMAT=auto +LOG_STD_ENABLE_GCP_FIELDS=false PDFENGINES_DISABLE_ROUTES=false PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk @@ -76,6 +76,13 @@ PROMETHEUS_COLLECT_INTERVAL=1s PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_COLLECT=false PROMETHEUS_METRICS_PATH=/prometheus/metrics +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= @@ -87,88 +94,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-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \ - --pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \ - --pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \ - --pdfengines-rotate-engines=$(PDFENGINES_ROTATE_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 diff --git a/build/Dockerfile b/build/Dockerfile index 5ce278d..fc3e639 100644 --- a/build/Dockerfile +++ b/build/Dockerfile @@ -277,6 +277,11 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu +# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled). +ENV OTEL_TRACES_EXPORTER=none +ENV OTEL_METRICS_EXPORTER=none +ENV OTEL_LOGS_EXPORTER=none + USER gotenberg WORKDIR /home/gotenberg diff --git a/cmd/gotenberg.go b/cmd/gotenberg.go index 92df0cd..1b00bf5 100644 --- a/cmd/gotenberg.go +++ b/cmd/gotenberg.go @@ -44,6 +44,24 @@ func Run() { 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") + // Logging & telemetry flags. + fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level") + fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key") + fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output") + fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output") + + // Deprecated logging flags. + fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format") + fs.Bool("log-enable-gcp-fields", false, "Use GCP-compatible field names") + + if err := errors.Join( + fs.MarkDeprecated("log-format", "use --log-std-format instead"), + fs.MarkDeprecated("log-enable-gcp-fields", "use --log-std-enable-gcp-fields instead"), + ); err != nil { + fmt.Printf("[FATAL] mark deprecated flags: %s\n", err) + os.Exit(1) + } + descriptors := gotenberg.GetModuleDescriptors() var modsInfo strings.Builder for _, desc := range descriptors { @@ -76,10 +94,11 @@ func Run() { fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err) os.Exit(1) } + f.Changed = true return } - err = f.Value.Set(val) + err = fs.Set(f.Name, val) if err != nil { fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err) os.Exit(1) @@ -91,6 +110,35 @@ func Run() { hideBanner := parsedFlags.MustBool("gotenberg-hide-banner") gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration") + // Initialize telemetry (logging + OTEL). + serviceName := os.Getenv("OTEL_SERVICE_NAME") + if serviceName == "" { + serviceName = "gotenberg" + } + + telemetryCfg := gotenberg.TelemetryConfig{ + ServiceName: serviceName, + ServiceVersion: Version, + LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"), + 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"), + } + // LogLevel uses its own flag, not the format flag. + telemetryCfg.LogLevel = parsedFlags.MustString("log-level") + + err = telemetryCfg.Validate() + if err != nil { + fmt.Printf("[FATAL] invalid telemetry config: %s\n", err) + os.Exit(1) + } + + shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryCfg) + if err != nil { + fmt.Printf("[FATAL] start telemetry: %s\n", err) + os.Exit(1) + } + if !hideBanner { fmt.Printf(banner, Version) } @@ -190,8 +238,17 @@ func Run() { err = eg.Wait() if err != nil { + cancel() fmt.Printf("[FATAL] %v\n", err) - os.Exit(1) + os.Exit(1) //nolint:gocritic // defers are already called explicitly above + } + + // Shutdown telemetry (flush spans, metrics, logs). + err = shutdownTelemetry(gracefulShutdownCtx) + if err != nil { + cancel() + fmt.Printf("[FATAL] %v\n", err) + os.Exit(1) //nolint:gocritic // defers are already called explicitly above } os.Exit(0) diff --git a/compose.yaml b/compose.yaml new file mode 100644 index 0000000..ae1b5e4 --- /dev/null +++ b/compose.yaml @@ -0,0 +1,116 @@ +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-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-read-bookmarks-engines=${PDFENGINES_READ_BOOKMARKS_ENGINES}" + - "--pdfengines-write-bookmarks-engines=${PDFENGINES_WRITE_BOOKMARKS_ENGINES}" + - "--pdfengines-watermark-engines=${PDFENGINES_WATERMARK_ENGINES}" + - "--pdfengines-stamp-engines=${PDFENGINES_STAMP_ENGINES}" + - "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}" + - "--pdfengines-rotate-engines=${PDFENGINES_ROTATE_ENGINES}" + - "--pdfengines-embed-engines=${PDFENGINES_EMBED_ENGINES}" + - "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}" + - "--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}" + + 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: + condition: service_started + restart: on-failure + + openobserve: + image: public.ecr.aws/zinclabs/openobserve:latest + restart: always + ports: + - "5080:5080" + - "5081:5081" + environment: + ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev + ZO_ROOT_USER_PASSWORD: telemetry + +networks: + default: + enable_ipv6: false diff --git a/go.mod b/go.mod index 54dd77b..4b3beeb 100644 --- a/go.mod +++ b/go.mod @@ -5,8 +5,8 @@ go 1.26.0 require ( github.com/alexliesenfeld/health v0.8.1 github.com/barasher/go-exiftool v1.10.0 - github.com/chromedp/cdproto v0.0.0-20260320225252-cf654f46fc63 - github.com/chromedp/chromedp v0.15.0 + github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc + github.com/chromedp/chromedp v0.15.1 github.com/cucumber/godog v0.15.1 github.com/dlclark/regexp2 v1.11.5 github.com/docker/docker v28.5.2+incompatible @@ -21,9 +21,18 @@ require ( github.com/prometheus/client_golang v1.23.2 github.com/shirou/gopsutil/v4 v4.26.2 github.com/spf13/pflag v1.0.10 + github.com/stretchr/testify v1.11.1 github.com/testcontainers/testcontainers-go v0.41.0 + go.opentelemetry.io/contrib/bridges/otelslog v0.17.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.52.0 golang.org/x/sync v0.20.0 golang.org/x/term v0.41.0 @@ -67,14 +76,15 @@ 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 github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect - github.com/klauspost/compress v1.18.4 // indirect + github.com/klauspost/compress v1.18.5 // indirect github.com/klauspost/pgzip v1.2.6 // indirect - github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect + github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect github.com/magiconair/properties v1.8.10 // indirect github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-isatty v0.0.20 // indirect @@ -82,7 +92,7 @@ require ( github.com/minio/minlz v1.1.0 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/go-archive v0.2.0 // indirect - github.com/moby/patternmatcher v0.6.0 // indirect + github.com/moby/patternmatcher v0.6.1 // indirect github.com/moby/sys/sequential v0.6.0 // indirect github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/userns v0.1.0 // indirect @@ -98,11 +108,11 @@ require ( github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/common v0.67.5 // 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 @@ -110,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.67.0 // indirect - go.opentelemetry.io/otel v1.42.0 // indirect - go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect - go.opentelemetry.io/otel/metric v1.42.0 // indirect - go.opentelemetry.io/otel/trace v1.42.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.10.0 // indirect go.yaml.in/yaml/v2 v2.4.4 // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect golang.org/x/crypto v0.49.0 // indirect golang.org/x/sys v0.42.0 // indirect golang.org/x/time v0.15.0 // indirect + google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect + google.golang.org/grpc v1.79.3 // 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 b67456d..3ec14d3 100644 --- a/go.sum +++ b/go.sum @@ -30,10 +30,10 @@ github.com/cenkalti/backoff/v5 v5.0.3 h1:ZN+IMa753KfX5hd8vVaMixjnqRZ3y8CuJKRKj1x 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-20260320225252-cf654f46fc63 h1:f027idvVkILyihTa8Z2vQCxTYPqHNdKlW7cXdMSZegU= -github.com/chromedp/cdproto v0.0.0-20260320225252-cf654f46fc63/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= -github.com/chromedp/chromedp v0.15.0 h1:B5abPcaCVetu6GDHsQKj8HbMhDWdiOM52E/TuOfuwyY= -github.com/chromedp/chromedp v0.15.0/go.mod h1:V5szO2ASqoBZsIQ88EfNMRhi9737Equm5HFL9tsBt3Q= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU= +github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= +github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ= +github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4= github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= @@ -98,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= @@ -107,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= @@ -132,8 +134,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw= github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A= -github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= -github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= +github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE= +github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ= github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek= github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU= github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= @@ -152,8 +154,8 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0 github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo= github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= -github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM= -github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= +github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk= +github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= @@ -172,8 +174,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= -github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk= -github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= +github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U= +github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= @@ -210,6 +212,8 @@ 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/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= @@ -259,30 +263,58 @@ 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.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k= +go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE= +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.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= 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/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.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= -go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= +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.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= -go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= +go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g= +go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk= 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.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= @@ -307,13 +339,14 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI= +google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w= +google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8= +google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE= +google.golang.org/grpc v1.79.3/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 0000000..cec12ed --- /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 83dd3ba..bf63368 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), 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(context.Background(), fmt.Sprintf("pipe unix process output error: %s", err)) } break } if len(line) != 0 { - logger.Debug(string(line)) + logger.DebugContext(context.Background(), 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(context.Background(), "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(context.Background(), "unix process already killed") return nil } diff --git a/pkg/gotenberg/gc.go b/pkg/gotenberg/gc.go index 169dd50..53d95db 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 @@ -38,12 +38,12 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, for _, substr := range includeSubstr { if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) { - err := os.RemoveAll(path) + err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory if err != nil { 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 833a0a6..ed60022 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.DiscardHandler), 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 e9a9d97..840abfb 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 0000000..453895b --- /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 0000000..70d91b6 --- /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 0000000..0793d42 --- /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 0000000..3a43105 --- /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 0000000..3ca2eb4 --- /dev/null +++ b/pkg/gotenberg/internal/log/stdhandler.go @@ -0,0 +1,128 @@ +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) + switch { + case format == textLoggingFormat && isTerminal: + if enableGcpFields { + a.Value = slog.StringValue(gcpSeverityColorEncoder(l)) + } else { + a.Value = slog.StringValue(levelToColor(l).Add(l.String())) + } + case enableGcpFields && format != textLoggingFormat: + a.Key = "severity" + a.Value = slog.StringValue(gcpSeverity(l)) + default: + 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 0000000..dd12008 --- /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 0000000..d11bd60 --- /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 index be502c2..3f7502c 100644 --- a/pkg/gotenberg/logging.go +++ b/pkg/gotenberg/logging.go @@ -1,57 +1 @@ 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/mocks.go b/pkg/gotenberg/mocks.go index eb1463c..ad87dfc 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,75 +45,75 @@ 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) - PageCountMock func(ctx context.Context, logger *zap.Logger, inputPath string) (int, 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 - WatermarkMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error - StampMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error - RotateMock func(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) 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) + PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) + WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error + ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) + EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error + EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error + WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error + WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error + StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error + RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages 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) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { return engine.PageCountMock(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) { +func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.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 { +func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.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 { +func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error { return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks) } -func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error { +func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error { return engine.WatermarkMock(ctx, logger, inputPath, stamp) } -func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error { +func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error { return engine.StampMock(ctx, logger, inputPath, stamp) } -func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { return engine.RotateMock(ctx, logger, inputPath, angle, pages) } @@ -129,31 +128,32 @@ 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) } // ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface. type ProcessSupervisorMock struct { - LaunchMock func() error - ShutdownMock func() error - HealthyMock func() bool - RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error - ReqQueueSizeMock func() int64 - RestartsCountMock func() int64 + LaunchMock func() error + ShutdownMock func() error + HealthyMock func() bool + RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error + ReqQueueSizeMock func() int64 + RestartsCountMock func() int64 + ActiveTasksCountMock func() int64 } func (s *ProcessSupervisorMock) Launch() error { @@ -168,7 +168,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) } @@ -180,13 +180,8 @@ 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) +func (s *ProcessSupervisorMock) ActiveTasksCount() int64 { + return s.ActiveTasksCountMock() } // MetricsProviderMock is a mock for the [MetricsProvider] interface. @@ -224,7 +219,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 80c5438..0bb99bd 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 ( @@ -160,57 +159,57 @@ 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) // PageCount returns the number of pages in a PDF file. - PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) + PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) // WriteMetadata writes the metadata into a given PDF file. - WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error + WriteMetadata(ctx context.Context, logger *slog.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) + ReadBookmarks(ctx context.Context, logger *slog.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 + WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) 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 + EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error // Watermark applies a watermark (behind page content) to a PDF file. - Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error + Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error // Stamp applies a stamp (on top of page content) to a PDF file. - Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error + Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error // Rotate rotates pages of a PDF file by the given angle (90, 180, 270). // If pages is empty, all pages are rotated. - Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error + Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages 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 0000000..8b52b02 --- /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 0000000..1df3a0d --- /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 0000000..8228ddf --- /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 0000000..f372ec8 --- /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 0000000..2a6751b --- /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 0000000..a8a0d58 --- /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 0000000..24b7942 --- /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 0000000..9b3c63b --- /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 0000000..4c905e2 --- /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 0000000..131fda4 --- /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 0000000..9e56609 --- /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 a717c5d..0766266 100644 --- a/pkg/gotenberg/supervisor.go +++ b/pkg/gotenberg/supervisor.go @@ -4,11 +4,10 @@ import ( "context" "errors" "fmt" + "log/slog" "sync" "sync/atomic" "time" - - "go.uber.org/zap" ) // ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying @@ -28,15 +27,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 @@ -67,17 +66,20 @@ 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 // RestartsCount returns the current number of restart. RestartsCount() int64 + + // ActiveTasksCount returns the current number of active tasks. + ActiveTasksCount() int64 } type processSupervisor struct { - logger *zap.Logger + logger *slog.Logger process Process maxReqLimit int64 maxQueueSize int64 @@ -99,7 +101,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 } @@ -122,38 +124,38 @@ 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 { // Not necessarily critical — chances are the process is already stopped, // but worth flagging in case it indicates a real issue. - s.logger.Warn(fmt.Sprintf("stop process before restart: %s", err)) + s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err)) } err = s.Launch() @@ -163,7 +165,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 } @@ -186,7 +188,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 { // Atomically check and increment the queue size to avoid the TOCTOU race // originally reported in https://github.com/gotenberg/gotenberg/issues/951. for { @@ -219,7 +221,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu defer func() { s.activeTasks.Add(-1) if semaphoreOwned { - logger.Debug("process lock released") + logger.DebugContext(ctx, "process lock released") <-s.semaphore } }() @@ -243,7 +245,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...") time.Sleep(10 * time.Millisecond) continue } @@ -255,7 +257,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu // acquireSlot attempts to acquire a semaphore slot, yielding it back if a // restart drain is in progress. -func (s *processSupervisor) acquireSlot(ctx context.Context, logger *zap.Logger) error { +func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger) error { select { case s.semaphore <- struct{}{}: // If a restart drain is in progress, release the slot @@ -265,11 +267,11 @@ func (s *processSupervisor) acquireSlot(ctx context.Context, logger *zap.Logger) return ErrProcessAlreadyRestarting } - logger.Debug("process lock acquired") + logger.DebugContext(ctx, "process lock acquired") return nil case <-ctx.Done(): - logger.Debug("failed to acquire process lock before deadline") + logger.DebugContext(ctx, "failed to acquire process lock before deadline") return fmt.Errorf("acquire process lock: %w", ctx.Err()) } @@ -303,7 +305,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error { return nil } - s.logger.Debug("process is unhealthy, cannot handle task, restarting...") + s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...") if err := s.doRestart(ctx); err != nil { return fmt.Errorf("process restart before task: %w", err) @@ -316,7 +318,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error { // and, if so, triggers an asynchronous restart. If a restart is initiated, it // takes ownership of the caller's semaphore slot (the caller must not release // it). Returns true if ownership was taken. -func (s *processSupervisor) maybeRestartAfterTask(logger *zap.Logger) bool { +func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool { if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit { return false } @@ -325,15 +327,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *zap.Logger) bool { return false } - s.logger.Debug("max request limit reached, restarting eagerly...") + s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...") 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 }() @@ -405,6 +407,10 @@ func (s *processSupervisor) RestartsCount() int64 { return s.restartsCounter.Load() } +func (s *processSupervisor) ActiveTasksCount() int64 { + return s.activeTasks.Load() +} + // Interface guards. var ( _ ProcessSupervisor = (*processSupervisor)(nil) diff --git a/pkg/gotenberg/supervisor_test.go b/pkg/gotenberg/supervisor_test.go index 29ca63e..b44cd5c 100644 --- a/pkg/gotenberg/supervisor_test.go +++ b/pkg/gotenberg/supervisor_test.go @@ -3,12 +3,11 @@ package gotenberg import ( "context" "errors" + "log/slog" "sync" "sync/atomic" "testing" "time" - - "go.uber.org/zap" ) func TestProcessSupervisor_Launch(t *testing.T) { @@ -38,10 +37,10 @@ func TestProcessSupervisor_Launch(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) process := &ProcessMock{ - StartMock: func(logger *zap.Logger) error { + StartMock: func(logger *slog.Logger) error { return tc.startError }, } @@ -86,10 +85,10 @@ func TestProcessSupervisor_Shutdown(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) process := &ProcessMock{ - StopMock: func(logger *zap.Logger) error { + StopMock: func(logger *slog.Logger) error { return tc.stopError }, } @@ -135,13 +134,13 @@ func TestProcessSupervisor_restart(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 +193,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) process := &ProcessMock{ - HealthyMock: func(logger *zap.Logger) bool { + HealthyMock: func(logger *slog.Logger) bool { return tc.processHealthy }, } @@ -365,7 +364,7 @@ func TestProcessSupervisor_Run(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) var startCalls, healthyCalls, stopCalls atomic.Int64 startCalls.Store(0) @@ -373,15 +372,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 +470,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.DiscardHandler), new(ProcessMock), 0, 0, 1).(*processSupervisor) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) if tc.ctxDone { @@ -496,12 +495,12 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) { } func TestProcessSupervisor_ReqQueueSize(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 }, } @@ -553,13 +552,13 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) { } func TestProcessSupervisor_QueueSizeCAS(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 }, } @@ -608,13 +607,13 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) { } func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 }, } @@ -698,13 +697,13 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) { }, } { t.Run(tc.scenario, func(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 }, } @@ -725,18 +724,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) { } func TestProcessSupervisor_ConcurrentRun(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 }, } @@ -789,16 +788,16 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) { } func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) { - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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 0000000..dd189a0 --- /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 0eec39e..ecc38b0 100644 --- a/pkg/modules/api/api.go +++ b/pkg/modules/api/api.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "log/slog" "net" "net/http" "sort" @@ -15,7 +16,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" @@ -29,20 +29,20 @@ func init() { // Api is a module that provides an HTTP server. Other modules may add routes, // 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 + correlationIdHeader string + basicAuthUsername string + basicAuthPassword string + downloadFromCfg downloadFromConfig + disableHealthCheckRouteTelemetry bool + enableDebugRoute bool routes []Route externalMiddlewares []Middleware @@ -50,7 +50,7 @@ type Api struct { readyFn []func() error asyncCounters []AsynchronousCounter fs *gotenberg.FileSystem - logger *zap.Logger + logger *slog.Logger srv *echo.Echo } @@ -80,9 +80,10 @@ type Route struct { // Optional. IsMultipart bool - // DisableLogging disables the logging for this route. + // DisableTelemetry disables telemetry (logging, tracing, metrics) for + // this route. // Optional. - DisableLogging bool + DisableTelemetry bool // Handler is the function that handles the request. // Required. @@ -190,14 +191,26 @@ 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.String("api-correlation-id-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.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values") fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values") 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 telemetry for health check route") fs.Bool("api-enable-debug-route", false, "Enable the debug route") + + // Deprecated flags. + fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests") + fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") + + err := errors.Join( + fs.MarkDeprecated("api-trace-header", "use --api-correlation-id-header instead"), + fs.MarkDeprecated("api-disable-health-check-logging", "use --api-disable-health-check-route-telemetry instead"), + ) + if err != nil { + panic(err) + } return fs }(), New: func() gotenberg.Module { return new(Api) }, @@ -215,14 +228,14 @@ 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.MustDeprecatedString("api-trace-header", "api-correlation-id-header") a.downloadFromCfg = downloadFromConfig{ allowList: flags.MustRegexpSlice("api-download-from-allow-list"), denyList: flags.MustRegexpSlice("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.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry") a.enableDebugRoute = flags.MustBool("api-enable-debug-route") // Port from env? @@ -328,17 +341,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,7 +381,7 @@ func (a *Api) Validate() error { ) } - if len(strings.TrimSpace(a.traceHeader)) == 0 { + if len(strings.TrimSpace(a.correlationIdHeader)) == 0 { err = multierr.Append(err, errors.New("trace header must not be empty"), ) @@ -442,28 +445,28 @@ 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 the health check route. + if a.disableHealthCheckRouteTelemetry { + disableTelemetryForPaths = append(disableTelemetryForPaths, "health") } + serverName := fmt.Sprintf("%s:%d", a.bindIp, a.port) + // Add the API middlewares. a.srv.Pre( latencyMiddleware(), rootPathMiddleware(a.rootPath), - traceMiddleware(a.traceHeader), outputFilenameMiddleware(), - loggerMiddleware(a.logger, disableLoggingForPaths), + telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths), ) // Add the modules' middlewares in their respective stacks. @@ -535,7 +538,9 @@ func (a *Api) Start() error { ) // Let's not forget the health check routes... - checks := append(a.healthChecks, health.WithTimeout(a.timeout)) + checks := make([]health.CheckerOption, len(a.healthChecks), len(a.healthChecks)+1) + copy(checks, a.healthChecks) + checks = append(checks, health.WithTimeout(a.timeout)) checker := health.NewChecker(checks...) healthCheckHandler := health.NewHandler(checker) @@ -600,7 +605,7 @@ 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()) } }() @@ -627,12 +632,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 5c03467..4c925e5 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,7 +20,8 @@ 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" "golang.org/x/sync/errgroup" "golang.org/x/text/unicode/norm" @@ -36,7 +38,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 +47,7 @@ type Context struct { outputPaths []string cancelled bool - logger *zap.Logger + logger *slog.Logger echoCtx echo.Context mkdirAll gotenberg.MkdirAll pathRename gotenberg.PathRename @@ -92,7 +94,7 @@ type downloadFrom struct { } // 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 @@ -137,12 +139,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(context.Background(), 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(context.Background(), fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath)) ctx.cancelled = true } }() @@ -230,7 +232,7 @@ 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) if err != nil { @@ -241,7 +243,16 @@ 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) + + // Inject OTEL trace context into outbound request. + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + + // Propagate correlation ID header. + if correlationIdHeader, ok := echoCtx.Get("correlationIdHeader").(string); ok { + if correlationId, ok := echoCtx.Get("correlationId").(string); ok { + req.Header.Set(correlationIdHeader, correlationId) + } + } client := &retryablehttp.Client{ HTTPClient: &http.Client{ @@ -265,7 +276,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst 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)) } }() @@ -316,7 +327,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)) } }() @@ -359,7 +370,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(context.Background(), fmt.Sprintf("close file header: %s", err)) } }() @@ -379,7 +390,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(context.Background(), fmt.Sprintf("close local file: %s", err)) } }() @@ -407,10 +418,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 } @@ -462,7 +473,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) @@ -488,8 +499,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 } @@ -505,7 +516,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 } @@ -528,7 +539,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) @@ -537,7 +548,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 c0d3304..9ea502a 100644 --- a/pkg/modules/api/context_test.go +++ b/pkg/modules/api/context_test.go @@ -3,6 +3,7 @@ package api import ( "bytes" "context" + "log/slog" "mime/multipart" "net/http" "net/http/httptest" @@ -10,7 +11,6 @@ import ( "time" "github.com/labstack/echo/v4" - "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -35,14 +35,14 @@ func TestNewContext_Cancellation(t *testing.T) { rec := httptest.NewRecorder() c := e.NewContext(req, rec) - logger := zap.NewNop() + logger := slog.New(slog.DiscardHandler) 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/middlewares.go b/pkg/modules/api/middlewares.go index b13e199..278f99f 100644 --- a/pkg/modules/api/middlewares.go +++ b/pkg/modules/api/middlewares.go @@ -5,6 +5,7 @@ import ( "crypto/subtle" "errors" "fmt" + "log/slog" "net/http" "path/filepath" "strings" @@ -13,9 +14,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 ( @@ -69,8 +74,7 @@ func ParseError(err error) (int, string) { return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues" } - var invalidArgsError *gotenberg.PdfEngineInvalidArgsError - if errors.As(err, &invalidArgsError) { + if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok { return http.StatusBadRequest, invalidArgsError.Error() } @@ -87,14 +91,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())) } } } @@ -138,32 +142,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". // @@ -183,59 +161,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 == "" { @@ -243,21 +190,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.DiscardHandler)) + + 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 } } @@ -284,13 +337,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() @@ -344,7 +395,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. @@ -361,7 +412,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)) } }() @@ -373,7 +424,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 7de02d6..b9bdc4f 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.Default()) +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 69d4301..57395d9 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,14 @@ import ( "github.com/chromedp/chromedp" "github.com/dlclark/regexp2" "github.com/shirou/gopsutil/v4/process" - "go.uber.org/zap" "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,7 +72,7 @@ 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") } @@ -164,7 +164,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 +181,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 +199,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(context.Background(), fmt.Sprintf("kill process: %v", err)) } else { - logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid)) + logger.DebugContext(context.Background(), fmt.Sprintf("Chromium process %d killed", p.Pid)) } }() } @@ -215,15 +215,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 +239,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 +266,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(context.Background(), 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 +299,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 +326,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") } diff --git a/pkg/modules/chromium/chromium.go b/pkg/modules/chromium/chromium.go index 06e4e5a..fe22a3d 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/attribute" + "go.opentelemetry.io/otel/metric" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/api" @@ -91,10 +93,17 @@ type Chromium struct { maxConcurrency int64 args browserArguments - logger *zap.Logger + logger *slog.Logger browser browser supervisor gotenberg.ProcessSupervisor engine gotenberg.PdfEngine + + reqsCounter metric.Int64Counter + errsCounter metric.Int64Counter + conversionDurationCounter metric.Float64Histogram + queueWaitDurationCounter metric.Float64Histogram + pdfOutputSizeCounter metric.Int64Histogram + imageOutputSizeCounter metric.Int64Histogram } // Options are the common options for all conversions. @@ -400,8 +409,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] @@ -488,15 +497,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) @@ -513,6 +514,109 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error { } mod.engine = engine + // Metrics. + meter := gotenberg.Meter() + + // Observable gauges. + _, err = meter.Int64ObservableGauge( + "chromium.requests.active", + metric.WithDescription("Current number of active Chromium requests"), + metric.WithUnit("{request}"), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + o.Observe(mod.supervisor.ActiveTasksCount()) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create chromium.requests.active gauge: %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 { + o.Observe(mod.supervisor.ReqQueueSize()) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create chromium.requests.queue_size gauge: %w", err) + } + + _, 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 { + o.Observe(mod.supervisor.RestartsCount()) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create chromium.process.restarts.total counter: %w", err) + } + + // Counters. + mod.reqsCounter, err = meter.Int64Counter( + "chromium.requests.total", + metric.WithDescription("Total number of Chromium conversion requests"), + metric.WithUnit("{request}"), + ) + if err != nil { + return fmt.Errorf("create chromium.requests.total counter: %w", err) + } + + mod.errsCounter, err = meter.Int64Counter( + "chromium.errors.total", + metric.WithDescription("Total number of Chromium conversion errors"), + metric.WithUnit("{error}"), + ) + if err != nil { + return fmt.Errorf("create chromium.errors.total counter: %w", err) + } + + // Histograms. + durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60) + + mod.conversionDurationCounter, err = meter.Float64Histogram( + "chromium.conversion.duration", + metric.WithDescription("Duration of Chromium conversions"), + metric.WithUnit("s"), + durationBuckets, + ) + if err != nil { + return fmt.Errorf("create chromium.conversion.duration histogram: %w", err) + } + + mod.queueWaitDurationCounter, err = meter.Float64Histogram( + "chromium.queue.wait.duration", + metric.WithDescription("Duration of waiting in queue for Chromium conversions"), + metric.WithUnit("s"), + durationBuckets, + ) + if err != nil { + return fmt.Errorf("create chromium.queue.wait.duration histogram: %w", err) + } + + mod.pdfOutputSizeCounter, err = meter.Int64Histogram( + "chromium.pdf.output.size", + metric.WithDescription("Size of PDF output from Chromium conversions"), + metric.WithUnit("By"), + ) + if err != nil { + return fmt.Errorf("create chromium.pdf.output.size histogram: %w", err) + } + + mod.imageOutputSizeCounter, err = meter.Int64Histogram( + "chromium.image.output.size", + metric.WithDescription("Size of image output from Chromium screenshots"), + metric.WithUnit("By"), + ) + if err != nil { + return fmt.Errorf("create chromium.image.output.size histogram: %w", err) + } + return nil } @@ -563,7 +667,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() @@ -679,20 +783,138 @@ 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 { - // 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 { +func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error { + start := time.Now() + var conversionStart time.Time + + err := mod.supervisor.Run(ctx, logger, func() error { + conversionStart = time.Now() return mod.browser.pdf(ctx, logger, url, outputPath, options) }) + + end := time.Now() + + status := "success" + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + status = "timeout" + } else { + status = "error" + } + + reason := "unknown" + switch { + case errors.Is(err, context.DeadlineExceeded): + reason = "timeout" + case errors.Is(err, context.Canceled): + reason = "context_cancelled" + case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery): + reason = "invalid_input" + case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting): + reason = "chromium_unavailable" + } + + mod.errsCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("reason", reason), + )) + } + + if !conversionStart.IsZero() { + waitDuration := conversionStart.Sub(start).Seconds() + conversionDuration := end.Sub(conversionStart).Seconds() + + mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes( + attribute.String("status", status), + )) + mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes( + attribute.String("status", status), + )) + } else { + waitDuration := end.Sub(start).Seconds() + mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes( + attribute.String("status", status), + )) + } + + mod.reqsCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", status), + )) + + if err == nil { + if fileInfo, statErr := os.Stat(outputPath); statErr == nil { + mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size()) + } + } + + return err } -func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.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 { +func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error { + start := time.Now() + var conversionStart time.Time + + err := mod.supervisor.Run(ctx, logger, func() error { + conversionStart = time.Now() return mod.browser.screenshot(ctx, logger, url, outputPath, options) }) + + end := time.Now() + + status := "success" + if err != nil { + if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) { + status = "timeout" + } else { + status = "error" + } + + reason := "unknown" + switch { + case errors.Is(err, context.DeadlineExceeded): + reason = "timeout" + case errors.Is(err, context.Canceled): + reason = "context_cancelled" + case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery): + reason = "invalid_input" + case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded): + reason = "chromium_maximum_queue_size_exceeded" + case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting): + reason = "chromium_unavailable" + } + + mod.errsCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("reason", reason), + )) + } + + if !conversionStart.IsZero() { + waitDuration := conversionStart.Sub(start).Seconds() + conversionDuration := end.Sub(conversionStart).Seconds() + + mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes( + attribute.String("status", status), + )) + mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes( + attribute.String("status", status), + )) + } else { + waitDuration := end.Sub(start).Seconds() + mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes( + attribute.String("status", status), + )) + } + + mod.reqsCounter.Add(ctx, 1, metric.WithAttributes( + attribute.String("status", status), + )) + + if err == nil { + if fileInfo, statErr := os.Stat(outputPath); statErr == nil { + mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size()) + } + } + + return err } // Interface guards. diff --git a/pkg/modules/chromium/debug.go b/pkg/modules/chromium/debug.go index 1aabd0f..7c26e22 100644 --- a/pkg/modules/chromium/debug.go +++ b/pkg/modules/chromium/debug.go @@ -1,28 +1,28 @@ 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 + 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(context.Background(), 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(context.Background(), fmt.Sprintf(format, v...)) } // Interface guards. diff --git a/pkg/modules/chromium/events.go b/pkg/modules/chromium/events.go index 0cbed46..7773f4d 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" @@ -33,29 +33,28 @@ 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: + if e, ok := ev.(*fetch.EventRequestPaused); ok { 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 } @@ -73,7 +72,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option } if !prefixMatch { - logger.Warn(fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL)) + logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL)) allow = false } } @@ -85,7 +84,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 } @@ -101,25 +100,26 @@ 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)) - } else if ok { - logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL)) + switch { + case err != nil: + logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) + case ok: + 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)) + default: + 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) @@ -129,7 +129,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)) } } @@ -152,7 +152,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)) } }() } @@ -177,7 +177,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) @@ -197,10 +197,9 @@ func listenForEventResponseReceived( } chromedp.ListenTarget(ctx, func(ev any) { - switch ev := ev.(type) { - case *network.EventResponseReceived: + if ev, ok := ev.(*network.EventResponseReceived); ok { 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() @@ -212,11 +211,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 } @@ -318,11 +317,10 @@ 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)) + if ev, ok := ev.(*network.EventLoadingFailed); ok { + logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText)) // We are looking for common errors. // TODO: sufficient? @@ -341,14 +339,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() @@ -358,7 +356,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() @@ -374,11 +372,10 @@ 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)) + if ev, ok := ev.(*runtime.EventExceptionThrown); ok { + logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails)) consoleExceptionsMu.Lock() defer consoleExceptionsMu.Unlock() @@ -390,13 +387,12 @@ 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) chromedp.ListenTarget(cctx, func(ev any) { - switch ev.(type) { - case *page.EventDomContentEventFired: + if _, ok := ev.(*page.EventDomContentEventFired); ok { cancel() close(ch) } @@ -404,7 +400,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()) @@ -414,13 +410,12 @@ 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) chromedp.ListenTarget(cctx, func(ev any) { - switch ev.(type) { - case *page.EventLoadEventFired: + if _, ok := ev.(*page.EventLoadEventFired); ok { cancel() close(ch) } @@ -428,7 +423,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()) @@ -438,23 +433,20 @@ 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) chromedp.ListenTarget(cctx, func(ev any) { - switch e := ev.(type) { - case *page.EventLifecycleEvent: - if e.Name == "networkIdle" { - cancel() - close(ch) - } + if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" { + cancel() + close(ch) } }) 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()) @@ -469,12 +461,9 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun ch := make(chan struct{}) cctx, cancel := context.WithCancel(ctx) chromedp.ListenTarget(cctx, func(ev any) { - switch e := ev.(type) { - case *page.EventLifecycleEvent: - if e.Name == "networkIdle2" { - cancel() - close(ch) - } + if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" { + cancel() + close(ch) } }) @@ -490,13 +479,12 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun // 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) chromedp.ListenTarget(cctx, func(ev any) { - switch ev.(type) { - case *network.EventLoadingFinished: + if _, ok := ev.(*network.EventLoadingFinished); ok { cancel() close(ch) } @@ -504,7 +492,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 cbd4884..36a2f5f 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 3e15779..fa866ab 100644 --- a/pkg/modules/chromium/routes.go +++ b/pkg/modules/chromium/routes.go @@ -187,10 +187,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) { invalidScopeToken = true break } - } else { - if token != "" { - valueTokens = append(valueTokens, token) - } + } else if token != "" { + valueTokens = append(valueTokens, token) } } diff --git a/pkg/modules/chromium/tasks.go b/pkg/modules/chromium/tasks.go index e04a6b8..3b3e904 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,32 +303,32 @@ 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 { -// return func(ctx context.Context) error { -// if len(extraHttpHeaders) == 0 { -// logger.Debug("no extra HTTP headers") -// return nil -// } +// func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { +// return func(ctx context.Context) error { +// if len(extraHttpHeaders) == 0 { +// 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 { -// headers[key] = value -// } +// headers := make(network.Headers, len(extraHttpHeaders)) +// for key, value := range extraHttpHeaders { +// headers[key] = value +// } // -// err := network.SetExtraHTTPHeaders(headers).Do(ctx) -// if err == nil { -// return nil -// } +// err := network.SetExtraHTTPHeaders(headers).Do(ctx) +// if err == nil { +// return nil +// } // -// return fmt.Errorf("set extra HTTP headers: %w", err) -// } -//} +// return fmt.Errorf("set extra HTTP headers: %w", err) +// } +// } -func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent bool) chromedp.ActionFunc { +func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent 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,13 +344,13 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent, sk if !skipNetworkIdleEvent { waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger)) } else { - logger.Debug("skipping network idle event") + logger.DebugContext(ctx, "skipping network idle event") } if !skipNetworkAlmostIdleEvent { waitFunc = append(waitFunc, waitForEventNetworkAlmostIdle(ctx, logger)) } else { - logger.Debug("skipping network almost idle event") + logger.DebugContext(ctx, "skipping network almost idle event") } err = runBatch( @@ -366,11 +366,11 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent, sk } } -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 } @@ -379,7 +379,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{ @@ -397,7 +397,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 { @@ -405,7 +405,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 } @@ -429,10 +429,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 } @@ -443,12 +443,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 { @@ -470,21 +470,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(): @@ -495,21 +495,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 { @@ -537,14 +537,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 2db64c4..db91c8c 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,6 @@ import ( "syscall" "github.com/barasher/go-exiftool" - "go.uber.org/zap" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -74,27 +74,27 @@ 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 { +func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // 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) { +func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Flatten is not available in this implementation. -func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { +func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Convert is not available in this implementation. -func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { +func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } // 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) { exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) if err != nil { return nil, fmt.Errorf("new ExifTool: %w", err) @@ -103,7 +103,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, in 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) @@ -116,7 +116,7 @@ func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, in } // 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 { exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) if err != nil { return fmt.Errorf("new ExifTool: %w", err) @@ -125,7 +125,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m 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) @@ -204,7 +204,7 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m } // PageCount returns the number of pages in a PDF file using ExifTool. -func (engine *ExifTool) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (engine *ExifTool) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { metadata, err := engine.ReadMetadata(ctx, logger, inputPath) if err != nil { return 0, fmt.Errorf("read metadata with ExifTool: %w", err) @@ -235,37 +235,37 @@ func (engine *ExifTool) PageCount(ctx context.Context, logger *zap.Logger, input } // WriteBookmarks is not available in this implementation. -func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { +func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.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) { +func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.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 { +func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) } // EmbedFiles is not available in this implementation. -func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { +func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Watermark is not available in this implementation. -func (engine *ExifTool) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Stamp is not available in this implementation. -func (engine *ExifTool) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Rotate is not available in this implementation. -func (engine *ExifTool) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { return fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) } diff --git a/pkg/modules/libreoffice/api/api.go b/pkg/modules/libreoffice/api/api.go index 6df6ad6..00b5643 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/attribute" + "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,9 +47,15 @@ type Api struct { autoStart bool args libreOfficeArguments - logger *zap.Logger + logger *slog.Logger libreOffice libreOffice supervisor gotenberg.ProcessSupervisor + + reqsCounter metric.Int64Counter + errsCounter metric.Int64Counter + conversionDurationCounter metric.Float64Histogram + queueWaitDurationCounter metric.Float64Histogram + pdfOutputSizeCounter metric.Int64Histogram } // Options gathers available options when converting a document to PDF. @@ -216,7 +224,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 } @@ -270,20 +278,108 @@ 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) + // Metrics. + meter := gotenberg.Meter() + + // Observable gauges. + var err error + + _, err = meter.Int64ObservableGauge( + "libreoffice.requests.active", + metric.WithDescription("Current number of active LibreOffice requests"), + metric.WithUnit("{request}"), + metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error { + o.Observe(a.supervisor.ActiveTasksCount()) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create libreoffice.requests.active gauge: %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 { + o.Observe(a.supervisor.ReqQueueSize()) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create libreoffice.requests.queue_size gauge: %w", err) + } + + _, 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 { + o.Observe(a.supervisor.RestartsCount()) + return nil + }), + ) + if err != nil { + return fmt.Errorf("create libreoffice.process.restarts.total counter: %w", err) + } + + // Counters. + a.reqsCounter, err = meter.Int64Counter( + "libreoffice.requests.total", + metric.WithDescription("Total number of LibreOffice conversion requests"), + metric.WithUnit("{request}"), + ) + if err != nil { + return fmt.Errorf("create libreoffice.requests.total counter: %w", err) + } + + a.errsCounter, err = meter.Int64Counter( + "libreoffice.errors.total", + metric.WithDescription("Total number of LibreOffice conversion errors"), + metric.WithUnit("{error}"), + ) + if err != nil { + return fmt.Errorf("create libreoffice.errors.total counter: %w", err) + } + + // Histograms. + durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60) + + a.conversionDurationCounter, err = meter.Float64Histogram( + "libreoffice.conversion.duration", + metric.WithDescription("Duration of LibreOffice conversions"), + metric.WithUnit("s"), + durationBuckets, + ) + if err != nil { + return fmt.Errorf("create libreoffice.conversion.duration histogram: %w", err) + } + + a.queueWaitDurationCounter, err = meter.Float64Histogram( + "libreoffice.queue.wait.duration", + metric.WithDescription("Duration of waiting in queue for LibreOffice conversions"), + metric.WithUnit("s"), + durationBuckets, + ) + if err != nil { + return fmt.Errorf("create libreoffice.queue.wait.duration histogram: %w", err) + } + + a.pdfOutputSizeCounter, err = meter.Int64Histogram( + "libreoffice.pdf.output.size", + metric.WithDescription("Size of PDF output from LibreOffice conversions"), + metric.WithUnit("By"), + ) + if err != nil { + return fmt.Errorf("create libreoffice.pdf.output.size histogram: %w", err) + } + return nil } @@ -332,7 +428,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() @@ -431,18 +527,64 @@ 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 { + start := time.Now() + var conversionStart time.Time + err := a.supervisor.Run(ctx, logger, func() error { + conversionStart = time.Now() return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options) }) + // Determine status and error reason. + status := "success" + reason := "" + + if err != nil { + switch { + case errors.Is(err, context.DeadlineExceeded): + status = "timeout" + reason = "timeout" + case errors.Is(err, context.Canceled): + status = "error" + reason = "context_cancelled" + case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting): + status = "error" + reason = "libreoffice_unavailable" + default: + status = "error" + reason = "unknown" + } + } + + // Record metrics. + attrs := metric.WithAttributes(attribute.String("status", status)) + a.reqsCounter.Add(ctx, 1, attrs) + + if reason != "" { + a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason))) + } + + if !conversionStart.IsZero() { + queueWait := conversionStart.Sub(start).Seconds() + a.queueWaitDurationCounter.Record(ctx, queueWait, attrs) + + conversionDuration := time.Since(conversionStart).Seconds() + a.conversionDurationCounter.Record(ctx, conversionDuration, attrs) + } + if err == nil { + stat, statErr := os.Stat(outputPath) + if statErr == nil { + a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs) + } + return nil } // See https://github.com/gotenberg/gotenberg/issues/639. if errors.Is(err, ErrCoreDumped) { - logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err)) + logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err)) return a.Pdf(ctx, logger, inputPath, outputPath, options) } diff --git a/pkg/modules/libreoffice/api/freeport.go b/pkg/modules/libreoffice/api/freeport.go index 0a3e71d..7409a5d 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 d185b66..759f487 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,12 @@ import ( "sync/atomic" "time" - "go.uber.org/zap" - "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 +47,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 +85,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(context.Background(), "got exit code 81, e.g., LibreOffice first start") // Second start (daemon). cmd = gotenberg.Command(logger, p.arguments.binPath, args...) @@ -123,7 +122,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(context.Background(), fmt.Sprintf("close connection after health checking the LibreOffice: %v", err)) } break @@ -148,19 +147,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(context.Background(), "waiting for the LibreOffice socket to be available...") for { select { @@ -169,7 +168,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(context.Background(), "LibreOffice socket available") success = true return nil @@ -179,7 +178,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 +191,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 +220,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 +236,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 +245,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 +258,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") } @@ -362,7 +360,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP return fmt.Errorf("create uno command: %w", err) } - logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) + logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", options)) exitCode, err := cmd.Exec() if err == nil { diff --git a/pkg/modules/libreoffice/api/mocks.go b/pkg/modules/libreoffice/api/mocks.go index 9c8c5c1..7ba3283 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 7a9afb9..c9096a0 100644 --- a/pkg/modules/libreoffice/pdfengine/pdfengine.go +++ b/pkg/modules/libreoffice/pdfengine/pdfengine.go @@ -4,8 +4,7 @@ import ( "context" "errors" "fmt" - - "go.uber.org/zap" + "log/slog" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" @@ -47,17 +46,17 @@ 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 { +func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // 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) { +func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Flatten is not available in this implementation. -func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { +func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } @@ -65,7 +64,7 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Log // 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 { opts := api.DefaultOptions() opts.PdfFormats = formats err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts) @@ -82,52 +81,52 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log } // ReadMetadata is not available in this implementation. -func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteMetadata is not available in this implementation. -func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // PageCount is not available in this implementation. -func (engine *LibreOfficePdfEngine) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (engine *LibreOfficePdfEngine) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { return 0, fmt.Errorf("page count with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteBookmarks is not available in this implementation. -func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { +func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // ReadBookmarks is not available in this implementation. -func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { +func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Encrypt is not available in this implementation. -func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { +func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error { return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // EmbedFiles is not available in this implementation. -func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { +func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Watermark is not available in this implementation. -func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Stamp is not available in this implementation. -func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Rotate is not available in this implementation. -func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) } diff --git a/pkg/modules/logging/doc.go b/pkg/modules/logging/doc.go deleted file mode 100644 index 1d04f60..0000000 --- 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 e9ddc9f..0000000 --- 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 9d4220d..0000000 --- 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 5fee762..461bd9d 100644 --- a/pkg/modules/pdfcpu/pdfcpu.go +++ b/pkg/modules/pdfcpu/pdfcpu.go @@ -6,6 +6,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" @@ -14,8 +15,6 @@ import ( "strings" "syscall" - "go.uber.org/zap" - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -96,7 +95,7 @@ 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 { args := make([]string, 0, 2+len(inputPaths)) args = append(args, "merge", outputPath) args = append(args, inputPaths...) @@ -115,7 +114,7 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths } // 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) { var args []string switch mode.Mode { @@ -165,32 +164,32 @@ 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 { +func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Convert is not available in this implementation. -func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { +func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } // ReadMetadata is not available in this implementation. -func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteMetadata is not available in this implementation. -func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // PageCount is not available in this implementation. -func (engine *PdfCpu) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { return 0, fmt.Errorf("page count with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // 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) { +func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.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...) @@ -201,7 +200,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inp defer func() { err := os.Remove(tmpPath) if err != nil && !os.IsNotExist(err) { - logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err)) + logger.ErrorContext(ctx, fmt.Sprintf("remove temporary bookmarks JSON file: %v", err)) } }() @@ -269,7 +268,7 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inp } // 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 { +func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { if len(bookmarks) == 0 { return nil } @@ -305,7 +304,7 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, in defer func() { err := os.Remove(tmpPath) if err != nil { - logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err)) + logger.ErrorContext(ctx, fmt.Sprintf("remove temporary bookmarks JSON file: %v", err)) } }() @@ -325,12 +324,12 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, in // 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 { +func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.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("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths)) args := make([]string, 0, 3+len(filePaths)) args = append(args, "attachments", "add", inputPath) @@ -350,7 +349,7 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePa } // 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 { if userPassword == "" { return errors.New("user password cannot be empty") } @@ -381,17 +380,17 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath } // Watermark applies a watermark (behind page content) to a PDF file using pdfcpu. -func (engine *PdfCpu) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return engine.applyStampOrWatermark(ctx, logger, "watermark", inputPath, stamp) } // Stamp applies a stamp (on top of page content) to a PDF file using pdfcpu. -func (engine *PdfCpu) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp) } // Rotate rotates pages of a PDF file by the given angle using pdfcpu. -func (engine *PdfCpu) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (engine *PdfCpu) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { args := []string{"rotate"} if pages != "" { args = append(args, "-pages", pages) @@ -411,7 +410,7 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *zap.Logger, inputPath return nil } -func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error { +func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Logger, command string, inputPath string, stamp gotenberg.Stamp) error { var mode string switch stamp.Source { case gotenberg.StampSourceText: diff --git a/pkg/modules/pdfengines/multi.go b/pkg/modules/pdfengines/multi.go index b73397a..f60f041 100644 --- a/pkg/modules/pdfengines/multi.go +++ b/pkg/modules/pdfengines/multi.go @@ -3,10 +3,12 @@ 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" ) @@ -61,7 +63,13 @@ func newMultiPdfEngines( // 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 { +// +//nolint:dupl +func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Merge", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -74,6 +82,7 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp case mergeErr := <-errChan: errored := multierr.AppendInto(&err, mergeErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -81,7 +90,11 @@ 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 { @@ -91,7 +104,11 @@ 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) { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Split", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error var mu sync.Mutex // to safely append errors. @@ -110,6 +127,7 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod err = multierr.Append(err, result.err) mu.Unlock() } else { + span.SetStatus(codes.Ok, "") return result.outputPaths, nil } case <-ctx.Done(): @@ -117,12 +135,20 @@ 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 { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Flatten", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -135,6 +161,7 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i case mergeErr := <-errChan: errored := multierr.AppendInto(&err, mergeErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -142,12 +169,20 @@ 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 { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Convert", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -160,6 +195,7 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f case mergeErr := <-errChan: errored := multierr.AppendInto(&err, mergeErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -167,7 +203,11 @@ 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 { @@ -177,7 +217,13 @@ 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) { +// +//nolint:dupl +func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error var mu sync.Mutex // to safely append errors. @@ -196,6 +242,7 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg err = multierr.Append(err, result.err) mu.Unlock() } else { + span.SetStatus(codes.Ok, "") return result.metadata, nil } case <-ctx.Done(): @@ -203,12 +250,20 @@ 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 // 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 { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -221,6 +276,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log case writeMetadataErr := <-errChan: errored := multierr.AppendInto(&err, writeMetadataErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -228,7 +284,11 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log } } - return fmt.Errorf("write PDF metadata 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 } type pageCountResult struct { @@ -238,7 +298,11 @@ type pageCountResult struct { // PageCount returns the number of pages in a PDF file using the first available // engine that supports metadata reading. -func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.PageCount", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error var mu sync.Mutex // to safely append errors. @@ -257,6 +321,7 @@ func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger, err = multierr.Append(err, result.err) mu.Unlock() } else { + span.SetStatus(codes.Ok, "") return result.pageCount, nil } case <-ctx.Done(): @@ -264,7 +329,11 @@ func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger, } } - return 0, fmt.Errorf("page count with multi PDF engines: %w", err) + err = fmt.Errorf("page count with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return 0, err } type readBookmarksResult struct { @@ -274,7 +343,13 @@ type readBookmarksResult struct { // 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) { +// +//nolint:dupl +func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.ReadBookmarks", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error var mu sync.Mutex // to safely append errors. @@ -293,6 +368,7 @@ func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Log err = multierr.Append(err, result.err) mu.Unlock() } else { + span.SetStatus(codes.Ok, "") return result.bookmarks, nil } case <-ctx.Done(): @@ -300,12 +376,20 @@ func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Log } } - return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) + err = fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return nil, 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 { +func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.WriteBookmarks", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -318,6 +402,7 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Lo case writeBookmarksErr := <-errChan: errored := multierr.AppendInto(&err, writeBookmarksErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -325,12 +410,20 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Lo } } - return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err) + err = fmt.Errorf("write PDF bookmarks 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 { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -343,6 +436,7 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i case protectErr := <-errChan: errored := multierr.AppendInto(&err, protectErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -350,12 +444,22 @@ 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 { +// +//nolint:dupl +func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.EmbedFiles", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -368,6 +472,7 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger case embedErr := <-errChan: errored := multierr.AppendInto(&err, embedErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -375,12 +480,22 @@ 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("embed files into PDF using multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err } // Watermark applies a watermark (behind page content) to a PDF file using the // first available engine that supports watermarking. -func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +// +//nolint:dupl +func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Watermark", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -393,6 +508,7 @@ func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger, case watermarkErr := <-errChan: errored := multierr.AppendInto(&err, watermarkErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -400,12 +516,22 @@ func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger, } } - return fmt.Errorf("watermark PDF with multi PDF engines: %w", err) + err = fmt.Errorf("watermark PDF with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err } // Stamp applies a stamp (on top of page content) to a PDF file using the // first available engine that supports stamping. -func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +// +//nolint:dupl +func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Stamp", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -418,6 +544,7 @@ func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inp case stampErr := <-errChan: errored := multierr.AppendInto(&err, stampErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -425,12 +552,20 @@ func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inp } } - return fmt.Errorf("stamp PDF with multi PDF engines: %w", err) + err = fmt.Errorf("stamp PDF with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err } // Rotate rotates pages of a PDF file using the first available engine that // supports rotation. -func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, "pdfengines.Rotate", trace.WithSpanKind(trace.SpanKindInternal)) + defer span.End() + var err error errChan := make(chan error, 1) @@ -443,6 +578,7 @@ func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, in case rotateErr := <-errChan: errored := multierr.AppendInto(&err, rotateErr) if !errored { + span.SetStatus(codes.Ok, "") return nil } case <-ctx.Done(): @@ -450,7 +586,11 @@ func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, in } } - return fmt.Errorf("rotate PDF with multi PDF engines: %w", err) + err = fmt.Errorf("rotate PDF with multi PDF engines: %w", err) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + + return err } // Interface guards. diff --git a/pkg/modules/pdfengines/pdfengines.go b/pkg/modules/pdfengines/pdfengines.go index cc33c21..38c3ab0 100644 --- a/pkg/modules/pdfengines/pdfengines.go +++ b/pkg/modules/pdfengines/pdfengines.go @@ -115,12 +115,12 @@ 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)) - // mod.defaultNames[i] = "libreoffice-pdfengine" - // } - //} + // for i, name := range defaultNames { + // if name == "unoconv-pdfengine" || name == "uno-pdfengine" { + // logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name)) + // mod.defaultNames[i] = "libreoffice-pdfengine" + // } + // } mod.mergeNames = defaultNames if len(mergeNames) > 0 { @@ -253,19 +253,19 @@ func (mod *PdfEngines) Validate() error { // modules. func (mod *PdfEngines) SystemMessages() []string { return []string{ - fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), - fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")), - fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")), - fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), - 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[:], " ")), - fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames[:], " ")), - fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames[:], " ")), - fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames[:], " ")), + fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames, " ")), + fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")), + fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")), + fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")), + 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, " ")), + fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")), + fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")), + fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")), } } diff --git a/pkg/modules/pdftk/pdftk.go b/pkg/modules/pdftk/pdftk.go index 9af5186..43bfc8a 100644 --- a/pkg/modules/pdftk/pdftk.go +++ b/pkg/modules/pdftk/pdftk.go @@ -5,13 +5,12 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" "syscall" - "go.uber.org/zap" - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -79,7 +78,7 @@ 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) { var args []string outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) @@ -107,7 +106,7 @@ func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenbe } // 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 { args := make([]string, 0, 3+len(inputPaths)) args = append(args, inputPaths...) args = append(args, "cat", "output", outputPath) @@ -126,42 +125,42 @@ func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths [ } // Flatten is not available in this implementation. -func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { +func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error { return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Convert is not available in this implementation. -func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { +func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } // ReadMetadata is not available in this implementation. -func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteMetadata is not available in this implementation. -func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // PageCount is not available in this implementation. -func (engine *PdfTk) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { return 0, fmt.Errorf("page count with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteBookmarks is not available in this implementation. -func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { +func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // ReadBookmarks is not available in this implementation. -func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { +func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { return nil, fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // 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 { if userPassword == "" { return errors.New("user password cannot be empty") } @@ -199,13 +198,13 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, } // EmbedFiles is not available in this implementation. -func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { +func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Watermark applies a watermark (behind page content) to a PDF file using PDFtk. // Only PDF source is supported. -func (engine *PdfTk) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { if stamp.Source != gotenberg.StampSourcePDF { return fmt.Errorf("watermark PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported) } @@ -234,7 +233,7 @@ func (engine *PdfTk) Watermark(ctx context.Context, logger *zap.Logger, inputPat // Stamp applies a stamp (on top of page content) to a PDF file using PDFtk. // Only PDF source is supported. -func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { if stamp.Source != gotenberg.StampSourcePDF { return fmt.Errorf("stamp PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported) } @@ -264,7 +263,7 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath st // Rotate rotates all pages of a PDF file by the given angle using PDFtk. // Page-specific rotation is not supported; if pages is non-empty, // ErrPdfEngineMethodNotSupported is returned. -func (engine *PdfTk) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { if pages != "" { return fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported) } diff --git a/pkg/modules/prometheus/prometheus.go b/pkg/modules/prometheus/prometheus.go index 51a7283..bbf4711 100644 --- a/pkg/modules/prometheus/prometheus.go +++ b/pkg/modules/prometheus/prometheus.go @@ -177,9 +177,9 @@ func (mod *Prometheus) Routes() ([]api.Route, error) { return []api.Route{ { - Method: http.MethodGet, - Path: mod.metricsPath, - DisableLogging: mod.disableRouteLogging, + Method: http.MethodGet, + Path: mod.metricsPath, + DisableTelemetry: mod.disableRouteLogging, Handler: echo.WrapHandler( promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}), ), diff --git a/pkg/modules/qpdf/qpdf.go b/pkg/modules/qpdf/qpdf.go index ce6c2d0..34d5c2a 100644 --- a/pkg/modules/qpdf/qpdf.go +++ b/pkg/modules/qpdf/qpdf.go @@ -5,13 +5,12 @@ import ( "context" "errors" "fmt" + "log/slog" "os" "os/exec" "path/filepath" "syscall" - "go.uber.org/zap" - "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) @@ -82,7 +81,7 @@ 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) { var args []string outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) @@ -113,7 +112,7 @@ func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenber } // 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 { args := make([]string, 0, 4+len(engine.globalArgs)+len(inputPaths)) args = append(args, "--empty") args = append(args, engine.globalArgs...) @@ -136,7 +135,7 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths [] // 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 { args := make([]string, 0, 4+len(engine.globalArgs)) args = append(args, inputPath) args = append(args, "--generate-appearances") @@ -158,37 +157,37 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath s } // Convert is not available in this implementation. -func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { +func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) } // ReadMetadata is not available in this implementation. -func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { +func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) { return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteMetadata is not available in this implementation. -func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { +func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error { return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // PageCount is not available in this implementation. -func (engine *QPdf) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { +func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) { return 0, fmt.Errorf("page count with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // WriteBookmarks is not available in this implementation. -func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { +func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error { return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // ReadBookmarks is not available in this implementation. -func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) { +func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) { return nil, fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // 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 { if userPassword == "" { return errors.New("user password cannot be empty") } @@ -217,22 +216,22 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, } // EmbedFiles is not available in this implementation. -func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { +func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error { return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Watermark is not available in this implementation. -func (engine *QPdf) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return fmt.Errorf("watermark PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Stamp is not available in this implementation. -func (engine *QPdf) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { +func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error { return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } // Rotate is not available in this implementation. -func (engine *QPdf) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error { +func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error { return fmt.Errorf("rotate PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported) } diff --git a/pkg/modules/webhook/client.go b/pkg/modules/webhook/client.go index 39d91bd..5a45da3 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/codes" + "go.opentelemetry.io/otel/propagation" + "go.opentelemetry.io/otel/trace" + + "github.com/gotenberg/gotenberg/v8/pkg/gotenberg" ) // 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 map[string]string, errored bool) error { url := c.url if errored { url = c.errorUrl @@ -37,11 +44,25 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er method = c.errorMethod } + spanName := fmt.Sprintf("%s Webhook", method) + if errored { + spanName = fmt.Sprintf("%s Webhook Error", method) + } + + tracer := gotenberg.Tracer() + ctx, span := tracer.Start(ctx, spanName, trace.WithSpanKind(trace.SpanKindClient)) + defer span.End() + req, err := retryablehttp.NewRequest(method, url, body) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) return fmt.Errorf("create '%s' request to '%s': %w", method, url, err) } + // Inject trace context into outbound request headers. + otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header)) + req.Header.Set("User-Agent", "Gotenberg") // Extra HTTP headers are the custom headers from the user. @@ -63,6 +84,8 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er bodySize, err := strconv.ParseInt(contentLength, 10, 64) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) return fmt.Errorf("parse content length entry: %w", err) } @@ -75,17 +98,22 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er resp, err := c.client.Do(req) if err != nil { + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) return fmt.Errorf("send '%s' request to '%s': %w", method, url, err) } if resp.StatusCode >= http.StatusBadRequest { - return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status) + err := fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status) + span.RecordError(err) + span.SetStatus(codes.Error, err.Error()) + return err } 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 +121,22 @@ 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) + attrs := []any{ + slog.String("webhook_url", url), + slog.String("method", method), + slog.Int64("latency", int64(finishTime.Sub(c.startTime))), + slog.String("latency_human", finishTime.Sub(c.startTime).String()), + 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", attrs...) + span.SetStatus(codes.Ok, "") return nil } - c.logger.Info("request to webhook handled", fields...) + c.logger.InfoContext(ctx, "request to webhook handled", attrs...) + span.SetStatus(codes.Ok, "") return nil } diff --git a/pkg/modules/webhook/middleware.go b/pkg/modules/webhook/middleware.go index 8771f04..a6e2df1 100644 --- a/pkg/modules/webhook/middleware.go +++ b/pkg/modules/webhook/middleware.go @@ -21,13 +21,13 @@ import ( ) type sendOutputFileParams struct { - ctx *api.Context - outputPath string - extraHttpHeaders map[string]string - traceHeader string - trace string - client *client - handleError func(error) + ctx *api.Context + outputPath string + extraHttpHeaders map[string]string + correlationIdHeader string + correlationId string + client *client + handleError func(error) } func webhookMiddleware(w *Webhook) api.Middleware { @@ -72,16 +72,16 @@ func webhookMiddleware(w *Webhook) api.Middleware { } headers := map[string]string{ - echo.HeaderContentType: http.DetectContentType(fileHeader), - echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), - params.traceHeader: params.trace, + echo.HeaderContentType: http.DetectContentType(fileHeader), + echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), + params.correlationIdHeader: params.correlationId, } _, 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) + err = params.client.send(params.ctx, bufio.NewReader(outputFile), headers, false) if err != nil { params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err)) params.handleError(err) @@ -179,8 +179,8 @@ func webhookMiddleware(w *Webhook) api.Middleware { // Retrieve values from echo.Context before it gets recycled. // See https://github.com/gotenberg/gotenberg/issues/1000. 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, @@ -226,10 +226,10 @@ func webhookMiddleware(w *Webhook) api.Middleware { headers := map[string]string{ echo.HeaderContentType: echo.MIMEApplicationJSON, - traceHeader: trace, + correlationIdHeader: correlationId, } - err = client.send(bytes.NewReader(b), headers, true) + err = client.send(ctx, bytes.NewReader(b), headers, true) if err != nil { ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error())) } @@ -262,13 +262,13 @@ func webhookMiddleware(w *Webhook) api.Middleware { } // 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, + ctx: ctx, + outputPath: outputPath, + extraHttpHeaders: extraHttpHeaders, + correlationIdHeader: correlationIdHeader, + correlationId: correlationId, + client: client, + handleError: handleError, }) return c.NoContent(http.StatusNoContent) } @@ -332,13 +332,13 @@ func webhookMiddleware(w *Webhook) api.Middleware { } sendOutputFile(sendOutputFileParams{ - ctx: ctx, - outputPath: outputPath, - extraHttpHeaders: extraHttpHeaders, - traceHeader: traceHeader, - trace: trace, - client: client, - handleError: handleError, + ctx: ctx, + outputPath: outputPath, + extraHttpHeaders: extraHttpHeaders, + correlationIdHeader: correlationIdHeader, + correlationId: correlationId, + client: client, + handleError: handleError, }) }() diff --git a/pkg/standard/imports.go b/pkg/standard/imports.go index de45e9d..561d09f 100644 --- a/pkg/standard/imports.go +++ b/pkg/standard/imports.go @@ -8,7 +8,6 @@ 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" diff --git a/test/integration/features/chromium_convert_html.feature b/test/integration/features/chromium_convert_html.feature index 5d21033..516d8a7 100644 --- a/test/integration/features/chromium_convert_html.feature +++ b/test/integration/features/chromium_convert_html.feature @@ -1102,7 +1102,7 @@ Feature: /forms/chromium/convert/html Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" 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" | @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 05bdce0..dfb3d11 100644 --- a/test/integration/features/chromium_convert_markdown.feature +++ b/test/integration/features/chromium_convert_markdown.feature @@ -1074,7 +1074,7 @@ Feature: /forms/chromium/convert/markdown Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" 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" | @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 622ed57..de984c7 100644 --- a/test/integration/features/chromium_convert_url.feature +++ b/test/integration/features/chromium_convert_url.feature @@ -1169,7 +1169,7 @@ Feature: /forms/chromium/convert/url Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" 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" | @webhook Scenario: POST /forms/chromium/convert/url (Webhook) diff --git a/test/integration/features/debug.feature b/test/integration/features/debug.feature index bc56157..d3fb43e 100644 --- a/test/integration/features/debug.feature +++ b/test/integration/features/debug.feature @@ -25,7 +25,6 @@ Feature: /debug "libreoffice", "libreoffice-api", "libreoffice-pdfengine", - "logging", "pdfcpu", "pdfengines", "pdftk", @@ -56,8 +55,10 @@ Feature: /debug "flags": { "api-bind-ip": "", "api-body-limit": "", + "api-correlation-id-header": "Gotenberg-Trace", "api-disable-download-from": "false", "api-disable-health-check-logging": "false", + "api-disable-health-check-route-telemetry": "false", "api-download-from-allow-list": "[]", "api-download-from-deny-list": "[]", "api-download-from-max-retry": "4", @@ -96,9 +97,12 @@ Feature: /debug "libreoffice-max-queue-size": "0", "libreoffice-restart-after": "10", "libreoffice-start-timeout": "20s", + "log-enable-gcp-fields": "false", "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": "[]", @@ -147,7 +151,6 @@ Feature: /debug "libreoffice", "libreoffice-api", "libreoffice-pdfengine", - "logging", "pdfcpu", "pdfengines", "pdftk", @@ -178,8 +181,10 @@ Feature: /debug "flags": { "api-bind-ip": "", "api-body-limit": "", + "api-correlation-id-header": "Gotenberg-Trace", "api-disable-download-from": "false", "api-disable-health-check-logging": "false", + "api-disable-health-check-route-telemetry": "false", "api-download-from-allow-list": "[]", "api-download-from-deny-list": "[]", "api-download-from-max-retry": "4", @@ -218,9 +223,12 @@ Feature: /debug "libreoffice-max-queue-size": "0", "libreoffice-restart-after": "10", "libreoffice-start-timeout": "20s", + "log-enable-gcp-fields": "false", "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": "[]", @@ -276,7 +284,7 @@ Feature: /debug Then the response status code should be 200 Then the response header "Gotenberg-Trace" should be "debug" Then the Gotenberg container should log the following entries: - | "trace":"debug" | + | "correlation_id":"debug" | 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 81ae4b7..e70bbde 100644 --- a/test/integration/features/health.feature +++ b/test/integration/features/health.feature @@ -31,7 +31,7 @@ Feature: /health Scenario: GET /health (No Logging) Given I have a Gotenberg container with the following environment variable(s): - | API_DISABLE_HEALTH_CHECK_LOGGING | true | + | API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY | 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: @@ -44,7 +44,7 @@ Feature: /health Then the response status code should be 200 Then the response header "Gotenberg-Trace" should be "get_health" Then the Gotenberg container should log the following entries: - | "trace":"get_health" | + | "correlation_id":"get_health" | Scenario: GET /health (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): @@ -78,11 +78,11 @@ Feature: /health Then the response status code should be 200 Then the response header "Gotenberg-Trace" should be "head_health" Then the Gotenberg container should log the following entries: - | "trace":"head_health" | + | "correlation_id":"head_health" | Scenario: HEAD /health (No Logging) Given I have a Gotenberg container with the following environment variable(s): - | API_DISABLE_HEALTH_CHECK_LOGGING | true | + | API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY | true | When I make a "HEAD" 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: diff --git a/test/integration/features/libreoffice_convert.feature b/test/integration/features/libreoffice_convert.feature index 3cffdf2..7e1e6bd 100644 --- a/test/integration/features/libreoffice_convert.feature +++ b/test/integration/features/libreoffice_convert.feature @@ -716,7 +716,7 @@ Feature: /forms/libreoffice/convert Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert" Then the Gotenberg container should log the following entries: - | "trace":"forms_libreoffice_convert" | + | "correlation_id":"forms_libreoffice_convert" | @download-from Scenario: POST /forms/libreoffice/convert (Download From) diff --git a/test/integration/features/pdfengines_bookmarks.feature b/test/integration/features/pdfengines_bookmarks.feature index 2bc34af..a3f162a 100644 --- a/test/integration/features/pdfengines_bookmarks.feature +++ b/test/integration/features/pdfengines_bookmarks.feature @@ -193,7 +193,7 @@ Feature: /forms/pdfengines/bookmarks/{write|read} 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" | + | "correlation_id":"forms_pdfengines_bookmarks_write" | Scenario: POST /forms/pdfengines/bookmarks/read (Gotenberg Trace) Given I have a default Gotenberg container @@ -204,7 +204,7 @@ Feature: /forms/pdfengines/bookmarks/{write|read} 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" | + | "correlation_id":"forms_pdfengines_bookmarks_read" | @output-filename Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Single PDF) diff --git a/test/integration/features/pdfengines_convert.feature b/test/integration/features/pdfengines_convert.feature index e2157eb..b974318 100644 --- a/test/integration/features/pdfengines_convert.feature +++ b/test/integration/features/pdfengines_convert.feature @@ -115,7 +115,7 @@ Feature: /forms/pdfengines/convert Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_convert" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_convert" | + | "correlation_id":"forms_pdfengines_convert" | @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 0a072c7..c397054 100644 --- a/test/integration/features/pdfengines_encrypt.feature +++ b/test/integration/features/pdfengines_encrypt.feature @@ -133,7 +133,7 @@ Feature: /forms/pdfengines/encrypt Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_encrypt" | + | "correlation_id":"forms_pdfengines_encrypt" | @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 11529a7..10bff85 100644 --- a/test/integration/features/pdfengines_flatten.feature +++ b/test/integration/features/pdfengines_flatten.feature @@ -49,7 +49,7 @@ Feature: /forms/pdfengines/flatten Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_flatten" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_flatten" | + | "correlation_id":"forms_pdfengines_flatten" | @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 663dbd6..f418591 100644 --- a/test/integration/features/pdfengines_merge.feature +++ b/test/integration/features/pdfengines_merge.feature @@ -549,7 +549,7 @@ Feature: /forms/pdfengines/merge Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_merge" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_merge" | + | "correlation_id":"forms_pdfengines_merge" | @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 8a5b914..de2a430 100644 --- a/test/integration/features/pdfengines_metadata.feature +++ b/test/integration/features/pdfengines_metadata.feature @@ -141,7 +141,7 @@ Feature: /forms/pdfengines/metadata/{write|read} Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" 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" | Scenario: POST /forms/pdfengines/metadata/read (Gotenberg Trace) Given I have a default Gotenberg container @@ -152,7 +152,7 @@ Feature: /forms/pdfengines/metadata/{write|read} Then the response header "Content-Type" should be "application/json" Then the response header "Gotenberg-Trace" 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" | @output-filename Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Single PDF) diff --git a/test/integration/features/pdfengines_rotate.feature b/test/integration/features/pdfengines_rotate.feature index a2de0b4..358e19a 100644 --- a/test/integration/features/pdfengines_rotate.feature +++ b/test/integration/features/pdfengines_rotate.feature @@ -127,7 +127,7 @@ Feature: /forms/pdfengines/rotate Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_rotate" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_rotate" | + | "correlation_id":"forms_pdfengines_rotate" | @webhook Scenario: POST /forms/pdfengines/rotate (Webhook) diff --git a/test/integration/features/pdfengines_split.feature b/test/integration/features/pdfengines_split.feature index bfa90a7..f9b1764 100644 --- a/test/integration/features/pdfengines_split.feature +++ b/test/integration/features/pdfengines_split.feature @@ -632,7 +632,7 @@ Feature: /forms/pdfengines/split Then the response header "Content-Type" should be "application/zip" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_split" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_split" | + | "correlation_id":"forms_pdfengines_split" | @output-filename Scenario: POST /forms/pdfengines/split (Output Filename - Single PDF) diff --git a/test/integration/features/pdfengines_stamp.feature b/test/integration/features/pdfengines_stamp.feature index c9f288d..a12ab06 100644 --- a/test/integration/features/pdfengines_stamp.feature +++ b/test/integration/features/pdfengines_stamp.feature @@ -197,7 +197,7 @@ Feature: /forms/pdfengines/stamp Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_stamp" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_stamp" | + | "correlation_id":"forms_pdfengines_stamp" | @webhook Scenario: POST /forms/pdfengines/stamp (Webhook) diff --git a/test/integration/features/pdfengines_watermark.feature b/test/integration/features/pdfengines_watermark.feature index 95292f6..f03534f 100644 --- a/test/integration/features/pdfengines_watermark.feature +++ b/test/integration/features/pdfengines_watermark.feature @@ -197,7 +197,7 @@ Feature: /forms/pdfengines/watermark Then the response header "Content-Type" should be "application/pdf" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_watermark" Then the Gotenberg container should log the following entries: - | "trace":"forms_pdfengines_watermark" | + | "correlation_id":"forms_pdfengines_watermark" | @webhook Scenario: POST /forms/pdfengines/watermark (Webhook) diff --git a/test/integration/features/prometheus_metrics.feature b/test/integration/features/prometheus_metrics.feature index 19a1e40..d20eda2 100644 --- a/test/integration/features/prometheus_metrics.feature +++ b/test/integration/features/prometheus_metrics.feature @@ -98,7 +98,7 @@ Feature: /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" | + | "correlation_id":"prometheus_metrics" | Scenario: GET /prometheus/metrics (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): diff --git a/test/integration/features/root.feature b/test/integration/features/root.feature index a1c6f50..76b00c4 100644 --- a/test/integration/features/root.feature +++ b/test/integration/features/root.feature @@ -18,7 +18,7 @@ Feature: / Then the response status code should be 200 Then the response header "Gotenberg-Trace" should be "root" Then the Gotenberg container should log the following entries: - | "trace":"root" | + | "correlation_id":"root" | Scenario: GET / (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): @@ -46,7 +46,7 @@ Feature: / 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" | + | "correlation_id":"favicon" | 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 0297550..910d08c 100644 --- a/test/integration/features/version.feature +++ b/test/integration/features/version.feature @@ -18,7 +18,7 @@ Feature: /version Then the response status code should be 200 Then the response header "Gotenberg-Trace" should be "version" Then the Gotenberg container should log the following entries: - | "trace":"version" | + | "correlation_id":"version" | Scenario: GET /version (Basic Auth) Given I have a Gotenberg container with the following environment variable(s): diff --git a/test/integration/scenario/scenario.go b/test/integration/scenario/scenario.go index bc8d6ab..7ca8b31 100644 --- a/test/integration/scenario/scenario.go +++ b/test/integration/scenario/scenario.go @@ -525,13 +525,14 @@ func (s *scenario) theResponseStatusCodeShouldBe(expected int) error { func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) error { var actual string - if kind == "response" { + switch { + case kind == "response": actual = s.resp.Header().Get(name) - } else if s.server == nil { + case s.server == nil: return errors.New("server not initialized") - } else if s.server.req == nil { + case s.server.req == nil: return errors.New("no webhook request found") - } else { + default: actual = s.server.req.Header.Get(name) } @@ -543,13 +544,14 @@ func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) er func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error { var cookies []*http.Cookie - if kind == "response" { + switch { + case kind == "response": cookies = s.resp.Result().Cookies() - } else if s.server == nil { + case s.server == nil: return errors.New("server not initialized") - } else if s.server.req == nil { + case s.server.req == nil: return errors.New("no webhook request found") - } else { + default: cookies = s.server.req.Cookies() } @@ -577,13 +579,14 @@ func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error { func (s *scenario) theBodyShouldMatchString(kind string, expectedDoc *godog.DocString) error { var actual string - if kind == "response" { + switch { + case kind == "response": actual = s.resp.Body.String() - } else if s.server == nil { + case s.server == nil: return errors.New("server not initialized") - } else if s.server.req == nil { + case s.server.req == nil: return errors.New("no webhook request found") - } else { + default: actual = string(s.server.bodyCopy) } @@ -597,13 +600,14 @@ func (s *scenario) theBodyShouldMatchString(kind string, expectedDoc *godog.DocS func (s *scenario) theBodyShouldContainString(kind string, expectedDoc *godog.DocString) error { var actual string - if kind == "response" { + switch { + case kind == "response": actual = s.resp.Body.String() - } else if s.server == nil { + case s.server == nil: return errors.New("server not initialized") - } else if s.server.req == nil { + case s.server.req == nil: return errors.New("no webhook request found") - } else { + default: actual = string(s.server.bodyCopy) } @@ -617,13 +621,14 @@ func (s *scenario) theBodyShouldContainString(kind string, expectedDoc *godog.Do func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocString) error { var body []byte - if kind == "response" { + switch { + case kind == "response": body = s.resp.Body.Bytes() - } else if s.server == nil { + case s.server == nil: return errors.New("server not initialized") - } else if s.server.req == nil { + case s.server.req == nil: return errors.New("no webhook request found") - } else { + default: body = s.server.bodyCopy }