feat(otel): add OpenTelemetry support

This commit is contained in:
Julien Neuhart
2026-03-27 16:28:45 +01:00
parent 08088c15f4
commit 4e9f63004d
86 changed files with 4396 additions and 1283 deletions

View File

@@ -24,13 +24,14 @@ linters:
- ineffassign - ineffassign
- misspell - misspell
- prealloc - prealloc
- promlinter
- staticcheck - staticcheck
- testableexamples - testableexamples
- tparallel - tparallel
- unconvert - unconvert
- unused - unused
- sloglint
- usetesting - usetesting
- gocritic
- wastedassign - wastedassign
- whitespace - whitespace
exclusions: exclusions:
@@ -40,10 +41,6 @@ linters:
- common-false-positives - common-false-positives
- legacy - legacy
- std-error-handling - std-error-handling
paths:
- third_party$
- builtin$
- examples$
formatters: formatters:
enable: enable:
- gci - gci
@@ -59,7 +56,3 @@ formatters:
custom-order: true custom-order: true
exclusions: exclusions:
generated: lax generated: lax
paths:
- third_party$
- builtin$
- examples$

119
Makefile
View File

@@ -20,16 +20,16 @@ API_BIND_IP=
API_START_TIMEOUT=30s API_START_TIMEOUT=30s
API_TIMEOUT=30s API_TIMEOUT=30s
API_BODY_LIMIT= API_BODY_LIMIT=
API_ROOT_PATH="/" API_ROOT_PATH=/
API_TRACE_HEADER=Gotenberg-Trace API_CORRELATION_ID_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME= GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD= GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST= API_DOWNLOAD_FROM_ALLOW_LIST=
API-DOWNLOAD-FROM-DENY-LIST= API_DOWNLOAD_FROM_DENY_LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4 API_DOWNLOAD_FROM_MAX_RETRY=4
API-DISABLE-DOWNLOAD-FROM=false API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=false
API_ENABLE_DEBUG_ROUTE=false API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=100 CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0 CHROMIUM_MAX_QUEUE_SIZE=0
@@ -54,9 +54,9 @@ LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX= LOG_FIELDS_PREFIX=
LOG_ENABLE_GCP_FIELDS=false LOG_STD_FORMAT=auto
LOG_STD_ENABLE_GCP_FIELDS=false
PDFENGINES_DISABLE_ROUTES=false PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
@@ -76,6 +76,13 @@ PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_COLLECT=false PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics 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_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST= WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST= WEBHOOK_DENY_LIST=
@@ -87,88 +94,20 @@ WEBHOOK_RETRY_MAX_WAIT=30s
WEBHOOK_CLIENT_TIMEOUT=30s WEBHOOK_CLIENT_TIMEOUT=30s
WEBHOOK_DISABLE=false WEBHOOK_DISABLE=false
# Export all variables so they are available to Compose
export
.PHONY: run .PHONY: run
run: ## Start a Gotenberg container run: ## Start a Gotenberg container via Compose
docker run --rm -it \ docker compose up gotenberg
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \ .PHONY: telemetry
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \ telemetry: ## Start an OpenTelemetry collector and OpenObserve containers via Compose
-e TZ=$(TZ) \ docker compose up otel-collector openobserve
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \ .PHONY: down
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \ down: ## Stop all containers
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \ docker compose down -v
--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)
.PHONY: test-unit .PHONY: test-unit
test-unit: ## Run unit tests test-unit: ## Run unit tests

View File

@@ -277,6 +277,11 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu 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 USER gotenberg
WORKDIR /home/gotenberg WORKDIR /home/gotenberg

View File

@@ -44,6 +44,24 @@ func Run() {
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration") fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed") fs.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() descriptors := gotenberg.GetModuleDescriptors()
var modsInfo strings.Builder var modsInfo strings.Builder
for _, desc := range descriptors { for _, desc := range descriptors {
@@ -76,10 +94,11 @@ func Run() {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err) fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1) os.Exit(1)
} }
f.Changed = true
return return
} }
err = f.Value.Set(val) err = fs.Set(f.Name, val)
if err != nil { if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err) fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1) os.Exit(1)
@@ -91,6 +110,35 @@ func Run() {
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner") hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration") 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 { if !hideBanner {
fmt.Printf(banner, Version) fmt.Printf(banner, Version)
} }
@@ -190,8 +238,17 @@ func Run() {
err = eg.Wait() err = eg.Wait()
if err != nil { if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err) 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) os.Exit(0)

116
compose.yaml Normal file
View File

@@ -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

45
go.mod
View File

@@ -5,8 +5,8 @@ go 1.26.0
require ( require (
github.com/alexliesenfeld/health v0.8.1 github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0 github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20260320225252-cf654f46fc63 github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.0 github.com/chromedp/chromedp v0.15.1
github.com/cucumber/godog v0.15.1 github.com/cucumber/godog v0.15.1
github.com/dlclark/regexp2 v1.11.5 github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible github.com/docker/docker v28.5.2+incompatible
@@ -21,9 +21,18 @@ require (
github.com/prometheus/client_golang v1.23.2 github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.2 github.com/shirou/gopsutil/v4 v4.26.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0 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/multierr v1.11.0
go.uber.org/zap v1.27.1
golang.org/x/net v0.52.0 golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0 golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0 golang.org/x/term v0.41.0
@@ -67,14 +76,15 @@ require (
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gorilla/css v1.0.1 // 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-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // 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/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/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // 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/minio/minlz v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // 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/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.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/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // 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/prometheus/procfs v0.20.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // 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/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect github.com/ulikunitz/xz v0.5.15 // indirect
@@ -110,17 +120,28 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // 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/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // 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 go.yaml.in/yaml/v2 v2.4.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.15.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 google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

87
go.sum
View File

@@ -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/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 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= 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-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260320225252-cf654f46fc63/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag= github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.0 h1:B5abPcaCVetu6GDHsQKj8HbMhDWdiOM52E/TuOfuwyY= github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.0/go.mod h1:V5szO2ASqoBZsIQ88EfNMRhi9737Equm5HFL9tsBt3Q= 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 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8= github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI= 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.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 h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= 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 h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= 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= 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/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 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= 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.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
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/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= 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-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= 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/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/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.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c= github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4= 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/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 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs= 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/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 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs= 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-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg= 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 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0= github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= 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/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 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU= 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.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc= 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 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs= github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU= 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/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 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= 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 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 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= 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 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= 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 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= 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 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= 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/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
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/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 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= 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 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= 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 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= 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 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= 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.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= 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 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= 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 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= 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 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ= go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw= 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 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= 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= 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= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= 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-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= 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 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= 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= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -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]

View File

@@ -6,17 +6,16 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"os/exec" "os/exec"
"strings" "strings"
"syscall" "syscall"
"go.uber.org/zap"
) )
// Cmd wraps an [exec.Cmd]. // Cmd wraps an [exec.Cmd].
type Cmd struct { type Cmd struct {
ctx context.Context ctx context.Context
logger *zap.Logger logger *slog.Logger
process *exec.Cmd process *exec.Cmd
} }
@@ -25,13 +24,13 @@ type Cmd struct {
// children without creating orphans. // children without creating orphans.
// //
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. // 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 := exec.Command(binPath, args...)
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
return &Cmd{ return &Cmd{
ctx: nil, ctx: nil,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
process: cmd, process: cmd,
} }
} }
@@ -41,7 +40,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
// children without creating orphans. // children without creating orphans.
// //
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773. // 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 { if ctx == nil {
return nil, errors.New("nil context") return nil, errors.New("nil context")
} }
@@ -51,7 +50,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
return &Cmd{ return &Cmd{
ctx: ctx, ctx: ctx,
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")), logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
process: cmd, process: cmd,
}, nil }, nil
} }
@@ -63,7 +62,7 @@ func (cmd *Cmd) Start() error {
return fmt.Errorf("pipe unix process output: %w", err) 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() err = cmd.process.Start()
if err != nil { if err != nil {
@@ -110,7 +109,7 @@ func (cmd *Cmd) Exec() (int, error) {
case err = <-errChan: case err = <-errChan:
errProc := cmd.Kill() errProc := cmd.Kill()
if errProc != nil { if errProc != nil {
cmd.logger.Error(errProc.Error()) cmd.logger.ErrorContext(context.Background(), errProc.Error())
} }
if err == nil { if err == nil {
@@ -125,7 +124,7 @@ func (cmd *Cmd) Exec() (int, error) {
case <-cmd.ctx.Done(): case <-cmd.ctx.Done():
errProc := cmd.Kill() errProc := cmd.Kill()
if errProc != nil { 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()) 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. // pipeOutput creates logs entries according to the process stdout and stderr.
// It does nothing if the logging level is not debug. // It does nothing if the logging level is not debug.
func (cmd *Cmd) pipeOutput() error { func (cmd *Cmd) pipeOutput() error {
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output") if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
if checkedEntry == nil {
return nil return nil
} }
@@ -152,12 +150,12 @@ func (cmd *Cmd) pipeOutput() error {
// logCommandOutput creates logs entries according to a reader // logCommandOutput creates logs entries according to a reader
// (either stdout or stderr). // (either stdout or stderr).
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) { logCommandOutput := func(logger *slog.Logger, reader io.ReadCloser) {
r := bufio.NewReader(reader) r := bufio.NewReader(reader)
defer func(reader io.ReadCloser) { defer func(reader io.ReadCloser) {
err := reader.Close() err := reader.Close()
if err != nil && !strings.Contains(err.Error(), "file already closed") { 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) }(reader)
@@ -165,20 +163,20 @@ func (cmd *Cmd) pipeOutput() error {
line, _, err := r.ReadLine() line, _, err := r.ReadLine()
if err != nil { if err != nil {
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") { 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 break
} }
if len(line) != 0 { 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.With(slog.String("logger", "stdout")), stdout)
go logCommandOutput(cmd.logger.Named("stderr"), stderr) go logCommandOutput(cmd.logger.With(slog.String("logger", "stderr")), stderr)
return nil return nil
} }
@@ -196,13 +194,13 @@ func (cmd *Cmd) Kill() error {
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL) err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
if err == nil { if err == nil {
cmd.logger.Debug("unix process killed") cmd.logger.DebugContext(context.Background(), "unix process killed")
return nil return nil
} }
// If the process does not exist anymore, the error is irrelevant. // If the process does not exist anymore, the error is irrelevant.
if strings.Contains(err.Error(), "no such process") { 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 return nil
} }

View File

@@ -1,19 +1,19 @@
package gotenberg package gotenberg
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"os" "os"
"path/filepath" "path/filepath"
"strings" "strings"
"time" "time"
"go.uber.org/zap"
) )
// GarbageCollect scans the root path and deletes files or directories with // GarbageCollect scans the root path and deletes files or directories with
// names containing specific substrings and before a given expiration time. // names containing specific substrings and before a given expiration time.
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error { func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
logger = logger.Named("gc") logger = logger.With(slog.String("logger", "gc"))
// To make sure that the next Walk method stays on // To make sure that the next Walk method stays on
// the root level of the considered path, we have to // 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 { for _, substr := range includeSubstr {
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) { 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 { if err != nil {
return fmt.Errorf("garbage collect '%s': %w", path, err) 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) return skipDirOrNil(info)
} }

View File

@@ -1,14 +1,15 @@
package gotenberg package gotenberg
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"os" "os"
"path" "path"
"testing" "testing"
"time" "time"
"github.com/google/uuid" "github.com/google/uuid"
"go.uber.org/zap"
) )
func TestGarbageCollect(t *testing.T) { 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 { if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err) t.Fatalf("expected no error but got: %v", err)

View File

@@ -1,9 +1,8 @@
package logging package log
import ( import (
"fmt" "fmt"
"log/slog"
"go.uber.org/zap/zapcore"
) )
// Foreground colors. // Foreground colors.
@@ -25,23 +24,15 @@ func (c color) Add(s string) string {
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s) 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 { switch l {
case zapcore.DebugLevel: case slog.LevelDebug:
return cyan return cyan
case zapcore.InfoLevel: case slog.LevelInfo:
return blue return blue
case zapcore.WarnLevel: case slog.LevelWarn:
return yellow return yellow
case zapcore.ErrorLevel: case slog.LevelError:
return red
case zapcore.DPanicLevel:
return red
case zapcore.PanicLevel:
return red
case zapcore.FatalLevel:
return red
case zapcore.InvalidLevel:
return red return red
default: default:
return red return red

View File

@@ -0,0 +1,2 @@
// Package log gathers internal logging utilities.
package log

View File

@@ -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"
}
}

View File

@@ -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}
}

View File

@@ -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
)

View File

@@ -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)
}

View File

@@ -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

View File

@@ -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

View File

@@ -1,57 +1 @@
package gotenberg 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)
)

View File

@@ -2,9 +2,8 @@ package gotenberg
import ( import (
"context" "context"
"log/slog"
"os" "os"
"go.uber.org/zap"
) )
// ModuleMock is a mock for the [Module] interface. // ModuleMock is a mock for the [Module] interface.
@@ -46,75 +45,75 @@ func (mod *DebuggableMock) Debug() map[string]any {
// //
//nolint:dupl //nolint:dupl
type PdfEngineMock struct { type PdfEngineMock struct {
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
PageCountMock func(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
ReadBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
WatermarkMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
StampMock func(ctx context.Context, logger *zap.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 *zap.Logger, inputPath string, angle int, pages string) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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) 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. // ProcessMock is a mock for the [Process] interface.
type ProcessMock struct { type ProcessMock struct {
StartMock func(logger *zap.Logger) error StartMock func(logger *slog.Logger) error
StopMock func(logger *zap.Logger) error StopMock func(logger *slog.Logger) error
HealthyMock func(logger *zap.Logger) bool 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) return p.StartMock(logger)
} }
func (p *ProcessMock) Stop(logger *zap.Logger) error { func (p *ProcessMock) Stop(logger *slog.Logger) error {
return p.StopMock(logger) return p.StopMock(logger)
} }
func (p *ProcessMock) Healthy(logger *zap.Logger) bool { func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
return p.HealthyMock(logger) return p.HealthyMock(logger)
} }
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface. // ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
type ProcessSupervisorMock struct { type ProcessSupervisorMock struct {
LaunchMock func() error LaunchMock func() error
ShutdownMock func() error ShutdownMock func() error
HealthyMock func() bool HealthyMock func() bool
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
ReqQueueSizeMock func() int64 ReqQueueSizeMock func() int64
RestartsCountMock func() int64 RestartsCountMock func() int64
ActiveTasksCountMock func() int64
} }
func (s *ProcessSupervisorMock) Launch() error { func (s *ProcessSupervisorMock) Launch() error {
@@ -168,7 +168,7 @@ func (s *ProcessSupervisorMock) Healthy() bool {
return s.HealthyMock() 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) return s.RunMock(ctx, logger, task)
} }
@@ -180,13 +180,8 @@ func (s *ProcessSupervisorMock) RestartsCount() int64 {
return s.RestartsCountMock() return s.RestartsCountMock()
} }
// LoggerProviderMock is a mock for the [LoggerProvider] interface. func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
type LoggerProviderMock struct { return s.ActiveTasksCountMock()
LoggerMock func(mod Module) (*zap.Logger, error)
}
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
return provider.LoggerMock(mod)
} }
// MetricsProviderMock is a mock for the [MetricsProvider] interface. // MetricsProviderMock is a mock for the [MetricsProvider] interface.
@@ -224,7 +219,6 @@ var (
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil) _ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
_ Process = (*ProcessMock)(nil) _ Process = (*ProcessMock)(nil)
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil) _ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
_ LoggerProvider = (*LoggerProviderMock)(nil)
_ MetricsProvider = (*MetricsProviderMock)(nil) _ MetricsProvider = (*MetricsProviderMock)(nil)
_ MkdirAll = (*MkdirAllMock)(nil) _ MkdirAll = (*MkdirAllMock)(nil)
_ PathRename = (*PathRenameMock)(nil) _ PathRename = (*PathRenameMock)(nil)

View File

@@ -4,8 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"go.uber.org/zap"
) )
var ( var (
@@ -160,57 +159,57 @@ type Bookmark struct {
type PdfEngine interface { type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order // Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths. // 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 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, // Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten // effectively deleting the original annotations. This process can flatten
// forms as well as forms share a relationship with annotations. Note that // forms as well as forms share a relationship with annotations. Note that
// this operation is irreversible. // 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 // Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing. // 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 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 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 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 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. // WriteBookmarks adds a document outline (bookmarks) to a PDF file.
// The bookmarks parameter represents the hierarchical tree of the outline. // 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. // Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document. // The userPassword is required to open the document.
// The ownerPassword provides full access to the document. // The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword. // 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 // EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content. // without modifying the main PDF content.
// TODO: attachments instead? Rename the route? // 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 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 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). // Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated. // 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]. // PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -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{})
}
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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{}))
}

View File

@@ -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

View File

@@ -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

View File

@@ -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"
}

View File

@@ -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
}

View File

@@ -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)
})
}
}

View File

@@ -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
}

View File

@@ -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))
}
}

View File

@@ -4,11 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"sync/atomic" "sync/atomic"
"time" "time"
"go.uber.org/zap"
) )
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying // ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
@@ -28,15 +27,15 @@ var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
type Process interface { type Process interface {
// Start initiates the process and returns an error if the process cannot // Start initiates the process and returns an error if the process cannot
// be started. // be started.
Start(logger *zap.Logger) error Start(logger *slog.Logger) error
// Stop terminates the process and returns an error if the process cannot // Stop terminates the process and returns an error if the process cannot
// be stopped. // 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 // Healthy checks the health of the process. It returns true if the process
// is healthy; otherwise, it returns false. // is healthy; otherwise, it returns false.
Healthy(logger *zap.Logger) bool Healthy(logger *slog.Logger) bool
} }
// ProcessSupervisor provides methods to manage a [Process], including // 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 // It returns an error if the task cannot be run or if the process state
// cannot be managed properly. // 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 returns the current size of the request queue.
ReqQueueSize() int64 ReqQueueSize() int64
// RestartsCount returns the current number of restart. // RestartsCount returns the current number of restart.
RestartsCount() int64 RestartsCount() int64
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
} }
type processSupervisor struct { type processSupervisor struct {
logger *zap.Logger logger *slog.Logger
process Process process Process
maxReqLimit int64 maxReqLimit int64
maxQueueSize int64 maxQueueSize int64
@@ -99,7 +101,7 @@ type processSupervisor struct {
} }
// NewProcessSupervisor initializes a new [ProcessSupervisor]. // 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 { if maxConcurrency < 1 {
maxConcurrency = 1 maxConcurrency = 1
} }
@@ -122,38 +124,38 @@ func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQ
} }
func (s *processSupervisor) Launch() error { func (s *processSupervisor) Launch() error {
s.logger.Debug("start process") s.logger.DebugContext(context.Background(), "start process")
err := s.process.Start(s.logger) err := s.process.Start(s.logger)
if err != nil { if err != nil {
return fmt.Errorf("start process: %w", err) return fmt.Errorf("start process: %w", err)
} }
s.firstStart.Store(true) s.firstStart.Store(true)
s.logger.Debug("process successfully started") s.logger.DebugContext(context.Background(), "process successfully started")
return nil return nil
} }
func (s *processSupervisor) Shutdown() error { func (s *processSupervisor) Shutdown() error {
s.logger.Debug("shutdown process") s.logger.DebugContext(context.Background(), "shutdown process")
err := s.process.Stop(s.logger) err := s.process.Stop(s.logger)
if err != nil { if err != nil {
return fmt.Errorf("shutdown process: %w", err) return fmt.Errorf("shutdown process: %w", err)
} }
s.logger.Debug("process successfully shutdown") s.logger.DebugContext(context.Background(), "process successfully shutdown")
return nil return nil
} }
func (s *processSupervisor) restart() error { func (s *processSupervisor) restart() error {
s.logger.Debug("restart process") s.logger.DebugContext(context.Background(), "restart process")
err := s.Shutdown() err := s.Shutdown()
if err != nil { if err != nil {
// Not necessarily critical — chances are the process is already stopped, // Not necessarily critical — chances are the process is already stopped,
// but worth flagging in case it indicates a real issue. // 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() err = s.Launch()
@@ -163,7 +165,7 @@ func (s *processSupervisor) restart() error {
s.reqCounter.Store(0) s.reqCounter.Store(0)
s.restartsCounter.Add(1) s.restartsCounter.Add(1)
s.logger.Debug("process successfully restarted") s.logger.DebugContext(context.Background(), "process successfully restarted")
return nil return nil
} }
@@ -186,7 +188,7 @@ func (s *processSupervisor) Healthy() bool {
return s.process.Healthy(s.logger) 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 // Atomically check and increment the queue size to avoid the TOCTOU race
// originally reported in https://github.com/gotenberg/gotenberg/issues/951. // originally reported in https://github.com/gotenberg/gotenberg/issues/951.
for { for {
@@ -219,7 +221,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
defer func() { defer func() {
s.activeTasks.Add(-1) s.activeTasks.Add(-1)
if semaphoreOwned { if semaphoreOwned {
logger.Debug("process lock released") logger.DebugContext(ctx, "process lock released")
<-s.semaphore <-s.semaphore
} }
}() }()
@@ -243,7 +245,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
}() }()
if errors.Is(err, ErrProcessAlreadyRestarting) { 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) time.Sleep(10 * time.Millisecond)
continue 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 // acquireSlot attempts to acquire a semaphore slot, yielding it back if a
// restart drain is in progress. // 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 { select {
case s.semaphore <- struct{}{}: case s.semaphore <- struct{}{}:
// If a restart drain is in progress, release the slot // 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 return ErrProcessAlreadyRestarting
} }
logger.Debug("process lock acquired") logger.DebugContext(ctx, "process lock acquired")
return nil return nil
case <-ctx.Done(): 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()) return fmt.Errorf("acquire process lock: %w", ctx.Err())
} }
@@ -303,7 +305,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
return nil 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 { if err := s.doRestart(ctx); err != nil {
return fmt.Errorf("process restart before task: %w", err) 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 // 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 // takes ownership of the caller's semaphore slot (the caller must not release
// it). Returns true if ownership was taken. // 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 { if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
return false return false
} }
@@ -325,15 +327,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *zap.Logger) bool {
return false return false
} }
s.logger.Debug("max request limit reached, restarting eagerly...") s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
go func() { go func() {
restartErr := s.doRestartLocked(context.Background()) restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock() s.restartMutex.Unlock()
if restartErr != nil { 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 <-s.semaphore
}() }()
@@ -405,6 +407,10 @@ func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load() return s.restartsCounter.Load()
} }
func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
// Interface guards. // Interface guards.
var ( var (
_ ProcessSupervisor = (*processSupervisor)(nil) _ ProcessSupervisor = (*processSupervisor)(nil)

View File

@@ -3,12 +3,11 @@ package gotenberg
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"go.uber.org/zap"
) )
func TestProcessSupervisor_Launch(t *testing.T) { func TestProcessSupervisor_Launch(t *testing.T) {
@@ -38,10 +37,10 @@ func TestProcessSupervisor_Launch(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return tc.startError return tc.startError
}, },
} }
@@ -86,10 +85,10 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return tc.stopError return tc.stopError
}, },
} }
@@ -135,13 +134,13 @@ func TestProcessSupervisor_restart(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return tc.startError return tc.startError
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return tc.stopError return tc.stopError
}, },
} }
@@ -194,10 +193,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return tc.processHealthy return tc.processHealthy
}, },
} }
@@ -365,7 +364,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(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 var startCalls, healthyCalls, stopCalls atomic.Int64
startCalls.Store(0) startCalls.Store(0)
@@ -373,15 +372,15 @@ func TestProcessSupervisor_Run(t *testing.T) {
stopCalls.Store(0) stopCalls.Store(0)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
startCalls.Add(1) startCalls.Add(1)
return tc.startError return tc.startError
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1) stopCalls.Add(1)
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
healthyCalls.Add(1) healthyCalls.Add(1)
return tc.processHealthy return tc.processHealthy
}, },
@@ -471,7 +470,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(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) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone { if tc.ctxDone {
@@ -496,12 +495,12 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
} }
func TestProcessSupervisor_ReqQueueSize(t *testing.T) { func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }
@@ -553,13 +552,13 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
} }
func TestProcessSupervisor_QueueSizeCAS(t *testing.T) { func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }
@@ -608,13 +607,13 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
} }
func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) { func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }
@@ -698,13 +697,13 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return tc.startError return tc.startError
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return tc.stopError return tc.stopError
}, },
} }
@@ -725,18 +724,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
} }
func TestProcessSupervisor_ConcurrentRun(t *testing.T) { func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
var startCalls atomic.Int64 var startCalls atomic.Int64
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
startCalls.Add(1) startCalls.Add(1)
return nil return nil
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }
@@ -789,16 +788,16 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
} }
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) { func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return nil return nil
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }

238
pkg/gotenberg/telemetry.go Normal file
View File

@@ -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)
)

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"sort" "sort"
@@ -15,7 +16,6 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/sync/errgroup" "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, // Api is a module that provides an HTTP server. Other modules may add routes,
// middlewares or health checks. // middlewares or health checks.
type Api struct { type Api struct {
port int port int
bindIp string bindIp string
tlsCertFile string tlsCertFile string
tlsKeyFile string tlsKeyFile string
startTimeout time.Duration startTimeout time.Duration
bodyLimit int64 bodyLimit int64
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string correlationIdHeader string
basicAuthUsername string basicAuthUsername string
basicAuthPassword string basicAuthPassword string
downloadFromCfg downloadFromConfig downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool disableHealthCheckRouteTelemetry bool
enableDebugRoute bool enableDebugRoute bool
routes []Route routes []Route
externalMiddlewares []Middleware externalMiddlewares []Middleware
@@ -50,7 +50,7 @@ type Api struct {
readyFn []func() error readyFn []func() error
asyncCounters []AsynchronousCounter asyncCounters []AsynchronousCounter
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
logger *zap.Logger logger *slog.Logger
srv *echo.Echo srv *echo.Echo
} }
@@ -80,9 +80,10 @@ type Route struct {
// Optional. // Optional.
IsMultipart bool IsMultipart bool
// DisableLogging disables the logging for this route. // DisableTelemetry disables telemetry (logging, tracing, metrics) for
// this route.
// Optional. // Optional.
DisableLogging bool DisableTelemetry bool
// Handler is the function that handles the request. // Handler is the function that handles the request.
// Required. // 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.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-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-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.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-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.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.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-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") 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 return fs
}(), }(),
New: func() gotenberg.Module { return new(Api) }, 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.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit") a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path") 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{ a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexpSlice("api-download-from-allow-list"), allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"), denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"), maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"), 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") a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// Port from env? // Port from env?
@@ -328,17 +341,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
} }
// Logger. // Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) a.logger = gotenberg.Logger(a)
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
// File system. // File system.
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) 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, err = multierr.Append(err,
errors.New("trace header must not be empty"), errors.New("trace header must not be empty"),
) )
@@ -442,28 +445,28 @@ func (a *Api) Start() error {
a.srv.HTTPErrorHandler = httpErrorHandler() a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes. // Let's prepare the modules' routes.
var disableLoggingForPaths []string var disableTelemetryForPaths []string
for i, route := range a.routes { for i, route := range a.routes {
a.routes[i].Path = strings.TrimPrefix(route.Path, "/") a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
if route.DisableLogging { if route.DisableTelemetry {
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/")) disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
} }
} }
// Check if the user wishes to add logging entries related to the health // Check if the user wishes to disable telemetry for the health check route.
// check route. if a.disableHealthCheckRouteTelemetry {
if a.disableHealthCheckLogging { disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
disableLoggingForPaths = append(disableLoggingForPaths, "health")
} }
serverName := fmt.Sprintf("%s:%d", a.bindIp, a.port)
// Add the API middlewares. // Add the API middlewares.
a.srv.Pre( a.srv.Pre(
latencyMiddleware(), latencyMiddleware(),
rootPathMiddleware(a.rootPath), rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
outputFilenameMiddleware(), outputFilenameMiddleware(),
loggerMiddleware(a.logger, disableLoggingForPaths), telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths),
) )
// Add the modules' middlewares in their respective stacks. // 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... // 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...) checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker) 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) err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
} }
if !errors.Is(err, http.ErrServerClosed) { 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(): case <-ctx.Done():
return a.srv.Shutdown(ctx) return a.srv.Shutdown(ctx)
default: default:
a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count)) a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count))
if count > 0 { if count > 0 {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
continue 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) err := a.srv.Shutdown(ctx)
if err != nil { if err != nil {
return fmt.Errorf("shutdown: %w", err) return fmt.Errorf("shutdown: %w", err)

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -19,7 +20,8 @@ import (
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mholt/archives" "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/sync/errgroup"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
@@ -36,7 +38,7 @@ var (
ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory") 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 { type Context struct {
dirPath string dirPath string
values map[string][]string values map[string][]string
@@ -45,7 +47,7 @@ type Context struct {
outputPaths []string outputPaths []string
cancelled bool cancelled bool
logger *zap.Logger logger *slog.Logger
echoCtx echo.Context echoCtx echo.Context
mkdirAll gotenberg.MkdirAll mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename pathRename gotenberg.PathRename
@@ -92,7 +94,7 @@ type downloadFrom struct {
} }
// newContext returns a [Context] by parsing a "multipart/form-data" request. // 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) processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given // 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) err := os.RemoveAll(ctx.dirPath)
if err != nil { 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 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 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) return fmt.Errorf("filter URL: %w", err)
} }
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url)) logger.DebugContext(ctx, fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil) req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
if err != 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 { for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value) 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{ client := &retryablehttp.Client{
HTTPClient: &http.Client{ HTTPClient: &http.Client{
@@ -265,7 +276,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() { defer func() {
err := resp.Body.Close() err := resp.Body.Close()
if err != nil { 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() { defer func() {
err := out.Close() err := out.Close()
if err != nil { 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() { defer func() {
err := in.Close() err := in.Close()
if err != nil { 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() { defer func() {
err := out.Close() err := out.Close()
if err != nil { 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().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField)) ctx.Log().DebugContext(ctx, 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("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err 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 // Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests. // behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error { 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) err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil { if err != nil {
return fmt.Errorf("rename path: %w", err) return fmt.Errorf("rename path: %w", err)
@@ -488,8 +499,8 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil return nil
} }
// Log returns the context [zap.Logger]. // Log returns the context [slog.Logger].
func (ctx *Context) Log() *zap.Logger { func (ctx *Context) Log() *slog.Logger {
return ctx.logger return ctx.logger
} }
@@ -505,7 +516,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
} }
if len(ctx.outputPaths) == 1 { 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 return ctx.outputPaths[0], nil
} }
@@ -528,7 +539,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
defer func(out *os.File) { defer func(out *os.File) {
err := out.Close() err := out.Close()
if err != nil { 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) }(out)
@@ -537,7 +548,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
return "", fmt.Errorf("archive output files: %w", err) 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 return archivePath, nil
} }

View File

@@ -3,6 +3,7 @@ package api
import ( import (
"bytes" "bytes"
"context" "context"
"log/slog"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
@@ -10,7 +11,6 @@ import (
"time" "time"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -35,14 +35,14 @@ func TestNewContext_Cancellation(t *testing.T) {
rec := httptest.NewRecorder() rec := httptest.NewRecorder()
c := e.NewContext(req, rec) c := e.NewContext(req, rec)
logger := zap.NewNop() logger := slog.New(slog.DiscardHandler)
fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) fs := gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
timeout := time.Duration(10) * time.Second timeout := time.Duration(10) * time.Second
downloadFromCfg := downloadFromConfig{ downloadFromCfg := downloadFromConfig{
disable: true, 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 { if err != nil {
t.Fatalf("expected no error from newContext, got: %v", err) t.Fatalf("expected no error from newContext, got: %v", err)
} }

View File

@@ -5,6 +5,7 @@ import (
"crypto/subtle" "crypto/subtle"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"path/filepath" "path/filepath"
"strings" "strings"
@@ -13,9 +14,13 @@ import (
"github.com/google/uuid" "github.com/google/uuid"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware" "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" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
) )
var ( 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" 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 invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
if errors.As(err, &invalidArgsError) {
return http.StatusBadRequest, invalidArgsError.Error() return http.StatusBadRequest, invalidArgsError.Error()
} }
@@ -87,14 +91,14 @@ func ParseError(err error) (int, string) {
// returns a response as "text/plain; charset=UTF-8". // returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler { func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) { return func(err error, c echo.Context) {
logger := c.Get("logger").(*zap.Logger) logger := c.Get("logger").(*slog.Logger)
status, message := ParseError(err) status, message := ParseError(err)
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8) c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
err = c.String(status, message) err = c.String(status, message)
if err != nil { 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] // outputFilenameMiddleware sets the output filename in the [echo.Context]
// under "outputFilename". // under "outputFilename".
// //
@@ -183,59 +161,28 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
} }
} }
// loggerMiddleware sets the logger in the [echo.Context] under "logger" and // telemetryMiddleware manages telemetry. It sets the correlation ID in the
// logs a synchronous request result. // [echo.Context] under "correlationId".
// //
// logger := c.Get("logger").(*zap.Logger) // correlationIdHeader := c.Get("correlationIdHeader").(string)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc { // 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(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
startTime := c.Get("startTime").(time.Time) startTime := c.Get("startTime").(time.Time)
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string) rootPath := c.Get("rootPath").(string)
// Create the application logger and add it to our locals. request := c.Request()
appLogger := logger. savedCtx := request.Context()
With(zap.String("log_type", "application")). defer func() {
With(zap.String("trace", trace)) request = request.WithContext(savedCtx)
c.SetRequest(request)
}()
c.Set("logger", appLogger.Named(func() string { routePath := 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 {
path := c.Request().URL.Path path := c.Request().URL.Path
if path == "" { if path == "" {
@@ -243,21 +190,127 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
} }
return path return path
}()) }()
fields[5] = zap.String("referer", c.Request().Referer())
fields[6] = zap.String("user_agent", c.Request().UserAgent()) // Evaluate if we should skip telemetry for this path.
fields[7] = zap.Int("status", c.Response().Status) skipTelemetry := false
fields[8] = zap.Int64("latency", int64(finishTime.Sub(startTime))) for _, path := range disableTelemetryForPaths {
fields[9] = zap.String("latency_human", finishTime.Sub(startTime).String()) URI := fmt.Sprintf("%s%s", rootPath, path)
fields[10] = zap.Int64("bytes_in", c.Request().ContentLength) if c.Request().RequestURI == URI {
fields[11] = zap.Int64("bytes_out", c.Response().Size) 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 { if err != nil {
accessLogger.Error(err.Error(), fields...) accessLogger.ErrorContext(ctx, err.Error())
} else { } 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 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 { func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger) logger := c.Get("logger").(*slog.Logger)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
// We create a context with a timeout so that underlying processes are // We create a context with a timeout so that underlying processes are
// able to stop early and correctly handle a timeout scenario. // 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 { if err != nil {
cancel() cancel()
@@ -344,7 +395,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc { func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc { return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error { 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 // Define a hard timeout if the route handler fails to timeout as
// expected. // expected.
@@ -361,7 +412,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
// This deferred function allows us to recover from such scenarios. // This deferred function allows us to recover from such scenarios.
defer func() { defer func() {
if r := recover(); r != nil { 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: case err := <-errChan:
return err return err
case <-hardTimeoutCtx.Done(): 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()) return fmt.Errorf("hard timeout: %w", hardTimeoutCtx.Err())
} }

View File

@@ -1,9 +1,10 @@
package api package api
import ( import (
"log/slog"
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -73,8 +74,8 @@ func (ctx *ContextMock) OutputPaths() []string {
// SetLogger sets the logger. // SetLogger sets the logger.
// //
// ctx := &api.ContextMock{Context: &api.Context{}} // ctx := &api.ContextMock{Context: &api.Context{}}
// ctx.SetLogger(zap.NewNop()) // ctx.SetLogger(slog.Default())
func (ctx *ContextMock) SetLogger(logger *zap.Logger) { func (ctx *ContextMock) SetLogger(logger *slog.Logger) {
ctx.logger = logger ctx.logger = logger
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -18,15 +19,14 @@ import (
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process" "github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type browser interface { type browser interface {
gotenberg.Process gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
} }
type browserArguments struct { type browserArguments struct {
@@ -72,7 +72,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
return b return b
} }
func (b *chromiumBrowser) Start(logger *zap.Logger) error { func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if b.isStarted.Load() { if b.isStarted.Load() {
return errors.New("browser is already started") return errors.New("browser is already started")
} }
@@ -164,7 +164,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
return nil return nil
} }
func (b *chromiumBrowser) Stop(logger *zap.Logger) error { func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
if !b.isStarted.Load() { if !b.isStarted.Load() {
// No big deal? Like calling cancel twice. // No big deal? Like calling cancel twice.
return nil return nil
@@ -181,7 +181,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Clean up stuck processes. // Clean up stuck processes.
ps, err := process.Processes() ps, err := process.Processes()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("list processes: %v", err))
} else { } else {
for _, p := range ps { for _, p := range ps {
func() { func() {
@@ -199,9 +199,9 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = p.KillWithContext(killCtx) err = p.KillWithContext(killCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("kill process: %v", err))
} else { } 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) err = os.RemoveAll(userProfileDirPath)
if err != nil { 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 { } 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. // 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 { if err != nil {
logger.Error(err.Error()) logger.ErrorContext(context.Background(), err.Error())
} }
}() }()
}(copyUserProfileDirPath, expirationTime) }(copyUserProfileDirPath, expirationTime)
@@ -239,7 +239,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
return nil 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 // Good to know: the supervisor does not call this method if no first start
// or if the process is restarting. // or if the process is restarting.
@@ -266,14 +266,14 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
return err return err
})) }))
if err != nil { 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 false
} }
return true 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 // Note: no error wrapping because it leaks on errors we want to display to
// the end user. // the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ 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 // Note: no error wrapping because it leaks on errors we want to display to
// the end user. // the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ 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() { if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks") return errors.New("browser not started, cannot handle tasks")
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -14,7 +15,8 @@ import (
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
flag "github.com/spf13/pflag" 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/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -91,10 +93,17 @@ type Chromium struct {
maxConcurrency int64 maxConcurrency int64
args browserArguments args browserArguments
logger *zap.Logger logger *slog.Logger
browser browser browser browser
supervisor gotenberg.ProcessSupervisor supervisor gotenberg.ProcessSupervisor
engine gotenberg.PdfEngine 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. // 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. // Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface { type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) 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] // 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. // Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) mod.logger = gotenberg.Logger(mod).With(slog.String("logger", "browser"))
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")
// Process. // Process.
mod.browser = newChromiumBrowser(mod.args) mod.browser = newChromiumBrowser(mod.args)
@@ -513,6 +514,109 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
} }
mod.engine = engine 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 return nil
} }
@@ -563,7 +667,7 @@ func (mod *Chromium) StartupMessage() string {
func (mod *Chromium) Stop(ctx context.Context) error { func (mod *Chromium) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully // Block until the context is done so that another module may gracefully
// stop before we do a shutdown. // 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() <-ctx.Done()
@@ -679,20 +783,138 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
} }
// Pdf converts a URL to PDF. // Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to start := time.Now()
// the end user. var conversionStart time.Time
return mod.supervisor.Run(ctx, logger, func() error {
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.pdf(ctx, logger, url, outputPath, options) 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 { func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to start := time.Now()
// the end user. var conversionStart time.Time
return mod.supervisor.Run(ctx, logger, func() error {
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.screenshot(ctx, logger, url, outputPath, options) 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. // Interface guards.

View File

@@ -1,28 +1,28 @@
package chromium package chromium
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log/slog"
"go.uber.org/zap"
) )
// 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. // Chromium.
type debugLogger struct { type debugLogger struct {
logger *zap.Logger logger *slog.Logger
} }
// Write logs the bytes in a debug message. // Write logs the bytes in a debug message.
func (debug *debugLogger) Write(p []byte) (n int, err error) { 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 return len(p), nil
} }
// Printf logs a debug message. // Printf logs a debug message.
func (debug *debugLogger) Printf(format string, v ...any) { 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. // Interface guards.

View File

@@ -3,6 +3,7 @@ package chromium
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@@ -17,7 +18,6 @@ import (
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "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. // allowed or not. It also set the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011. // See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now). // 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 { if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers") logger.DebugContext(ctx, "no extra HTTP headers")
} else { } 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) { chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) { if e, ok := ev.(*fetch.EventRequestPaused); ok {
case *fetch.EventRequestPaused:
go func() { 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 allow := true
deadline, ok := ctx.Deadline() deadline, ok := ctx.Deadline()
if !ok { if !ok {
logger.Error("context has no deadline, cannot filter URL") logger.ErrorContext(ctx, "context has no deadline, cannot filter URL")
return return
} }
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline) err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil { if err != nil {
logger.Warn(err.Error()) logger.WarnContext(ctx, err.Error())
allow = false allow = false
} }
@@ -73,7 +72,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
} }
if !prefixMatch { 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 allow = false
} }
} }
@@ -85,7 +84,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("fail request: %s", err))
} }
return return
} }
@@ -101,25 +100,26 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
for _, header := range options.extraHttpHeaders { for _, header := range options.extraHttpHeaders {
if header.Scope == nil { if header.Scope == nil {
// Non-scoped header. // 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) extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue continue
} }
ok, err := header.Scope.MatchString(e.Request.URL) ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil { switch {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) case err != nil:
} else if ok { logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL)) 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) extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else { default:
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL)) logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
} }
} }
} }
if len(extraHttpHeadersToSet) > 0 { 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 originalHeaders := e.Request.Headers
headers := make(map[string]string) headers := make(map[string]string)
@@ -129,7 +129,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
if ok { if ok {
headers[key] = strValue headers[key] = strValue
} else { } 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) err = req.Do(executorCtx)
if err != nil { 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. // https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventResponseReceived( func listenForEventResponseReceived(
ctx context.Context, ctx context.Context,
logger *zap.Logger, logger *slog.Logger,
options eventResponseReceivedOptions, options eventResponseReceivedOptions,
) { ) {
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains) normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
@@ -197,10 +197,9 @@ func listenForEventResponseReceived(
} }
chromedp.ListenTarget(ctx, func(ev any) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { if ev, ok := ev.(*network.EventResponseReceived); ok {
case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl { 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) { if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
options.invalidHttpStatusCodeMu.Lock() options.invalidHttpStatusCodeMu.Lock()
@@ -212,11 +211,11 @@ func listenForEventResponseReceived(
return 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 slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) { 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 return
} }
@@ -318,11 +317,10 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/913. // https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959. // https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021. // 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) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { if ev, ok := ev.(*network.EventLoadingFailed); ok {
case *network.EventLoadingFailed: logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
// We are looking for common errors. // We are looking for common errors.
// TODO: sufficient? // TODO: sufficient?
@@ -341,14 +339,14 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
"net::ERR_HTTP2_PROTOCOL_ERROR", "net::ERR_HTTP2_PROTOCOL_ERROR",
} }
if !slices.Contains(errors, ev.ErrorText) { 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 return
} }
if ev.Type == network.ResourceTypeDocument { if ev.Type == network.ResourceTypeDocument {
// Supposition: except iframe, an event loading failed with a // Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page. // 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() options.loadingFailedMu.Lock()
defer options.loadingFailedMu.Unlock() defer options.loadingFailedMu.Unlock()
@@ -358,7 +356,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
return return
} }
logger.Debug("event EventLoadingFailed fired for a resource") logger.DebugContext(ctx, "event EventLoadingFailed fired for a resource")
options.resourceLoadingFailedMu.Lock() options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock() 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 // listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer. // appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262. // 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) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { if ev, ok := ev.(*runtime.EventExceptionThrown); ok {
case *runtime.EventExceptionThrown: logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
consoleExceptionsMu.Lock() consoleExceptionsMu.Lock()
defer consoleExceptionsMu.Unlock() defer consoleExceptionsMu.Unlock()
@@ -390,13 +387,12 @@ func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, cons
// waitForEventDomContentEventFired waits until the event DomContentEventFired // waitForEventDomContentEventFired waits until the event DomContentEventFired
// is fired or the context timeout. // 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 { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) { chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) { if _, ok := ev.(*page.EventDomContentEventFired); ok {
case *page.EventDomContentEventFired:
cancel() cancel()
close(ch) close(ch)
} }
@@ -404,7 +400,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
select { select {
case <-ch: case <-ch:
logger.Debug("event DomContentEventFired fired") logger.DebugContext(ctx, "event DomContentEventFired fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err()) 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 // waitForEventLoadEventFired waits until the event LoadEventFired is fired or
// the context timeout. // 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 { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) { chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) { if _, ok := ev.(*page.EventLoadEventFired); ok {
case *page.EventLoadEventFired:
cancel() cancel()
close(ch) close(ch)
} }
@@ -428,7 +423,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
select { select {
case <-ch: case <-ch:
logger.Debug("event LoadEventFired fired") logger.DebugContext(ctx, "event LoadEventFired fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err()) 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 // waitForEventNetworkIdle waits until the event networkIdle is fired or the
// context timeout. // 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 { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) { chromedp.ListenTarget(cctx, func(ev any) {
switch e := ev.(type) { if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
case *page.EventLifecycleEvent: cancel()
if e.Name == "networkIdle" { close(ch)
cancel()
close(ch)
}
} }
}) })
select { select {
case <-ch: case <-ch:
logger.Debug("event networkIdle fired") logger.DebugContext(ctx, "event networkIdle fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err()) 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{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) { chromedp.ListenTarget(cctx, func(ev any) {
switch e := ev.(type) { if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
case *page.EventLifecycleEvent: cancel()
if e.Name == "networkIdle2" { close(ch)
cancel()
close(ch)
}
} }
}) })
@@ -490,13 +479,12 @@ func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) fun
// waitForEventLoadingFinished waits until the event LoadingFinished is fired // waitForEventLoadingFinished waits until the event LoadingFinished is fired
// or the context timeout. // 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 { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) { chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) { if _, ok := ev.(*network.EventLoadingFinished); ok {
case *network.EventLoadingFinished:
cancel() cancel()
close(ch) close(ch)
} }
@@ -504,7 +492,7 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
select { select {
case <-ch: case <-ch:
logger.Debug("event LoadingFinished fired") logger.DebugContext(ctx, "event LoadingFinished fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err()) return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err())

View File

@@ -2,38 +2,37 @@ package chromium
import ( import (
"context" "context"
"log/slog"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// ApiMock is a mock for the [Api] interface. // ApiMock is a mock for the [Api] interface.
type ApiMock struct { type ApiMock struct {
PdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error PdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
ScreenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) 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) 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) return api.ScreenshotMock(ctx, logger, url, outputPath, options)
} }
// browserMock is a mock for the [browser] interface. // browserMock is a mock for the [browser] interface.
type browserMock struct { type browserMock struct {
gotenberg.ProcessMock gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error pdfMock func(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshotMock func(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) 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) 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) return b.screenshotMock(ctx, logger, url, outputPath, options)
} }

View File

@@ -187,10 +187,8 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
invalidScopeToken = true invalidScopeToken = true
break break
} }
} else { } else if token != "" {
if token != "" { valueTokens = append(valueTokens, token)
valueTokens = append(valueTokens, token)
}
} }
} }

View File

@@ -5,6 +5,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"time" "time"
@@ -13,16 +14,15 @@ import (
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page" "github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp" "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 { return func(ctx context.Context) error {
paperHeight := options.PaperHeight paperHeight := options.PaperHeight
pageRanges := options.PageRanges pageRanges := options.PageRanges
if options.SinglePage { if options.SinglePage {
logger.Debug("single page PDF") logger.DebugContext(ctx, "single page PDF")
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx) _, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil { if err != nil {
@@ -56,11 +56,11 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
options.FooterTemplate != DefaultPdfOptions().FooterTemplate options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter { if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer") logger.DebugContext(ctx, "no custom header nor footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false) printToPdf = printToPdf.WithDisplayHeaderFooter(false)
} else { } else {
logger.Debug("with custom header and/or footer") logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf. printToPdf = printToPdf.
WithDisplayHeaderFooter(true). WithDisplayHeaderFooter(true).
@@ -68,7 +68,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
WithFooterTemplate(options.FooterTemplate) 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) _, stream, err := printToPdf.Do(ctx)
if err != nil { if err != nil {
@@ -86,7 +86,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
defer func() { defer func() {
err = reader.Close() err = reader.Close()
if err != nil { 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() { defer func() {
err = file.Close() err = file.Close()
if err != nil { 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 { return func(ctx context.Context) error {
captureScreenshot := page.CaptureScreenshot(). captureScreenshot := page.CaptureScreenshot().
WithCaptureBeyondViewport(true). WithCaptureBeyondViewport(true).
@@ -134,7 +134,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
WithQuality(int64(options.Quality)) 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) buffer, err := captureScreenshot.Do(ctx)
if err != nil { if err != nil {
@@ -149,7 +149,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
defer func() { defer func() {
err = file.Close() err = file.Close()
if err != nil { 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 { 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) err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
if err == nil { 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 { return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753. // See https://github.com/gotenberg/gotenberg/issues/753.
if !clear { if !clear {
logger.Debug("cache not cleared") logger.DebugContext(ctx, "cache not cleared")
return nil return nil
} }
logger.Debug("clear cache") logger.DebugContext(ctx, "clear cache")
err := network.ClearBrowserCache().Do(ctx) err := network.ClearBrowserCache().Do(ctx)
if err == nil { 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 { return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753. // See https://github.com/gotenberg/gotenberg/issues/753.
if !clear { if !clear {
logger.Debug("cookies not cleared") logger.DebugContext(ctx, "cookies not cleared")
return nil return nil
} }
logger.Debug("clear cookies") logger.DebugContext(ctx, "clear cookies")
err := network.ClearBrowserCookies().Do(ctx) err := network.ClearBrowserCookies().Do(ctx)
if err == nil { 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 { return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175. // See https://github.com/gotenberg/gotenberg/issues/175.
if !disable { if !disable {
logger.Debug("JavaScript not disabled") logger.DebugContext(ctx, "JavaScript not disabled")
return nil return nil
} }
logger.Debug("disable JavaScript") logger.DebugContext(ctx, "disable JavaScript")
err := emulation.SetScriptExecutionDisabled(true).Do(ctx) err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
if err == nil { 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 { return func(ctx context.Context) error {
if len(cookies) == 0 { if len(cookies) == 0 {
logger.Debug("no cookies to set") logger.DebugContext(ctx, "no cookies to set")
return nil 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) 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 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 { return func(ctx context.Context) error {
if len(userAgent) == 0 { if len(userAgent) == 0 {
logger.Debug("no user agent override") logger.DebugContext(ctx, "no user agent override")
return nil 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) err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
if err == nil { if err == nil {
return 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. // network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
// See https://github.com/gotenberg/gotenberg/issues/1011. // See https://github.com/gotenberg/gotenberg/issues/1011.
// //
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc { // func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error { // return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 { // if len(extraHttpHeaders) == 0 {
// logger.Debug("no extra HTTP headers") // logger.DebugContext(ctx,"no extra HTTP headers")
// return nil // 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)) // headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders { // for key, value := range extraHttpHeaders {
// headers[key] = value // headers[key] = value
// } // }
// //
// err := network.SetExtraHTTPHeaders(headers).Do(ctx) // err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil { // if err == nil {
// return 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 { 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) _, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil { if err != nil {
@@ -344,13 +344,13 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent, sk
if !skipNetworkIdleEvent { if !skipNetworkIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger)) waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
} else { } else {
logger.Debug("skipping network idle event") logger.DebugContext(ctx, "skipping network idle event")
} }
if !skipNetworkAlmostIdleEvent { if !skipNetworkAlmostIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkAlmostIdle(ctx, logger)) waitFunc = append(waitFunc, waitForEventNetworkAlmostIdle(ctx, logger))
} else { } else {
logger.Debug("skipping network almost idle event") logger.DebugContext(ctx, "skipping network almost idle event")
} }
err = runBatch( 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 { return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/226. // See https://github.com/gotenberg/gotenberg/issues/226.
if !omitBackground { if !omitBackground {
logger.Debug("default white background not hidden") logger.DebugContext(ctx, "default white background not hidden")
return nil return nil
} }
@@ -379,7 +379,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground) 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( err := emulation.SetDefaultBackgroundColorOverride().WithColor(
&cdp.RGBA{ &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 { return func(ctx context.Context) error {
css := "html { -webkit-print-color-adjust: exact !important; }" css := "html { -webkit-print-color-adjust: exact !important; }"
if !printBackground { if !printBackground {
@@ -405,7 +405,7 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
// print of the background, whatever the printToPDF args. // print of the background, whatever the printToPDF args.
// See https://github.com/gotenberg/gotenberg/issues/1154. // See https://github.com/gotenberg/gotenberg/issues/1154.
additionalCss := "html, body { background: none !important; }" 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 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 { return func(ctx context.Context) error {
if mediaType == "" && len(mediaFeatures) == 0 { if mediaType == "" && len(mediaFeatures) == 0 {
logger.Debug("no emulated media type or features") logger.DebugContext(ctx, "no emulated media type or features")
return nil return nil
} }
@@ -443,12 +443,12 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu
emulatedMedia := emulation.SetEmulatedMedia() emulatedMedia := emulation.SetEmulatedMedia()
if mediaType != "" { 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) emulatedMedia = emulatedMedia.WithMedia(mediaType)
} }
if len(mediaFeatures) > 0 { 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)) features := make([]*emulation.MediaFeature, len(mediaFeatures))
for i, f := range 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 { return func(ctx context.Context) error {
if disableJavaScript { if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay") logger.DebugContext(ctx, "JavaScript disabled, skipping wait delay")
return nil return nil
} }
if delay <= 0 { if delay <= 0 {
logger.Debug("no wait delay") logger.DebugContext(ctx, "no wait delay")
return nil return nil
} }
// We wait for a given amount of time so that JavaScript // We wait for a given amount of time so that JavaScript
// scripts have a chance to finish before printing the page. // 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 { select {
case <-ctx.Done(): 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 { return func(ctx context.Context) error {
if disableJavaScript { if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait expression") logger.DebugContext(ctx, "JavaScript disabled, skipping wait expression")
return nil return nil
} }
if expression == "" { if expression == "" {
logger.Debug("no wait expression") logger.DebugContext(ctx, "no wait expression")
return nil return nil
} }
// We wait until the evaluation of the expression is true or // We wait until the evaluation of the expression is true or
// until the context is done. // 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) ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for { 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 { return func(ctx context.Context) error {
if selector == "" { if selector == "" {
logger.Debug("no wait selector") logger.DebugContext(ctx, "no wait selector")
return nil 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) err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx)
if err != nil { if err != nil {
return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery) return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery)

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"reflect" "reflect"
@@ -11,7 +12,6 @@ import (
"syscall" "syscall"
"github.com/barasher/go-exiftool" "github.com/barasher/go-exiftool"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "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. // 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) return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Split is not available in this implementation. // 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) return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Flatten is not available in this implementation. // 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) return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Convert is not available in this implementation. // 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) return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadMetadata extracts the metadata of a given PDF file. // 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)) exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil { if err != nil {
return nil, fmt.Errorf("new ExifTool: %w", err) 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) { defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close() err := exifTool.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err)) logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
} }
}(exifTool) }(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. // 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)) exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil { if err != nil {
return fmt.Errorf("new ExifTool: %w", err) 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) { defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close() err := exifTool.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err)) logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
} }
}(exifTool) }(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. // 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) metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
if err != nil { if err != nil {
return 0, fmt.Errorf("read metadata with ExifTool: %w", err) 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. // 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) return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadBookmarks is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Encrypt is not available in this implementation. // 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) return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
} }
// EmbedFiles is not available in this implementation. // 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) return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Watermark is not available in this implementation. // 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) return fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Stamp is not available in this implementation. // 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) return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Rotate is not available in this implementation. // 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) return fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -12,8 +13,9 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -45,9 +47,15 @@ type Api struct {
autoStart bool autoStart bool
args libreOfficeArguments args libreOfficeArguments
logger *zap.Logger logger *slog.Logger
libreOffice libreOffice libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor 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. // 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. // Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface { 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 Extensions() []string
} }
@@ -270,20 +278,108 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
} }
// Logger. // Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) a.logger = gotenberg.Logger(a).With(slog.String("logger", "libreoffice"))
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")
// Process. // Process.
a.libreOffice = newLibreOfficeProcess(a.args) 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) 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 return nil
} }
@@ -332,7 +428,7 @@ func (a *Api) StartupMessage() string {
func (a *Api) Stop(ctx context.Context) error { func (a *Api) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully // Block until the context is done so that another module may gracefully
// stop before we do a shutdown. // 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() <-ctx.Done()
@@ -431,18 +527,64 @@ func (a *Api) LibreOffice() (Uno, error) {
} }
// Pdf converts a document to PDF. // 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 { err := a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options) 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 { if err == nil {
stat, statErr := os.Stat(outputPath)
if statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
}
return nil return nil
} }
// See https://github.com/gotenberg/gotenberg/issues/639. // See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) { 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) return a.Pdf(ctx, logger, inputPath, outputPath, options)
} }

View File

@@ -1,14 +1,14 @@
package api package api
import ( import (
"context"
"fmt" "fmt"
"log/slog"
"net" "net"
"strconv" "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") netListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil { if err != nil {
return 0, fmt.Errorf("listen on the local network address: %w", err) 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() { defer func() {
err := netListener.Close() err := netListener.Close()
if err != nil { 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()))
} }
}() }()

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net" "net"
"os" "os"
"strings" "strings"
@@ -11,14 +12,12 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type libreOffice interface { type libreOffice interface {
gotenberg.Process 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 { type libreOfficeArguments struct {
@@ -48,7 +47,7 @@ func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
return p return p
} }
func (p *libreOfficeProcess) Start(logger *zap.Logger) error { func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
if p.isStarted.Load() { if p.isStarted.Load() {
return errors.New("LibreOffice is already started") 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) 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). // Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...) cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
@@ -123,7 +122,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
connChan <- nil connChan <- nil
err = conn.Close() err = conn.Close()
if err != nil { 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 break
@@ -148,19 +147,19 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
// Let's make sure the process is killed. // Let's make sure the process is killed.
err = cmd.Kill() err = cmd.Kill()
if err != nil { 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. // And the user profile directory is deleted.
err = os.RemoveAll(userProfileDirPath) err = os.RemoveAll(userProfileDirPath)
if err != nil { 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 { for {
select { select {
@@ -169,7 +168,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("LibreOffice socket not available: %w", err) return fmt.Errorf("LibreOffice socket not available: %w", err)
} }
logger.Debug("LibreOffice socket available") logger.DebugContext(context.Background(), "LibreOffice socket available")
success = true success = true
return nil 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() { if !p.isStarted.Load() {
// No big deal? Like calling cancel twice. // No big deal? Like calling cancel twice.
return nil return nil
@@ -192,15 +191,15 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
go func() { go func() {
err := os.RemoveAll(userProfileDirPath) err := os.RemoveAll(userProfileDirPath)
if err != nil { 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 { } 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. // 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 { if err != nil {
logger.Error(err.Error()) logger.ErrorContext(context.Background(), err.Error())
} }
}() }()
}(copyUserProfileDirPath, expirationTime) }(copyUserProfileDirPath, expirationTime)
@@ -221,7 +220,7 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
return nil 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 // Good to know: the supervisor does not call this method if no first start
// or if the process is restarting. // or if the process is restarting.
@@ -237,7 +236,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
if err == nil { if err == nil {
err = conn.Close() err = conn.Close()
if err != nil { 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 return true
@@ -246,7 +245,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
return false 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() { if !p.isStarted.Load() {
return errors.New("LibreOffice not started, cannot handle PDF conversion") 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)) args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity") if logger.Enabled(ctx, slog.LevelDebug) {
if checkedEntry != nil {
args = append(args, "-vvv") 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) 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() exitCode, err := cmd.Exec()
if err == nil { if err == nil {

View File

@@ -3,19 +3,18 @@ package api
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// ApiMock is a mock for the [Uno] interface. // ApiMock is a mock for the [Uno] interface.
type ApiMock struct { 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 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) return api.PdfMock(ctx, logger, inputPath, outputPath, options)
} }
@@ -37,10 +36,10 @@ type libreOfficeMock struct {
errCoreDumpedCount int errCoreDumpedCount int
gotenberg.ProcessMock 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) err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
if errors.Is(err, ErrCoreDumped) { if errors.Is(err, ErrCoreDumped) {
b.errCoreDumpedCount += 1 b.errCoreDumpedCount += 1

View File

@@ -4,8 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" "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. // 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) return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Split is not available in this implementation. // 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) return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Flatten is not available in this implementation. // 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) 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/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] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
// error. // 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 := api.DefaultOptions()
opts.PdfFormats = formats opts.PdfFormats = formats
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts) 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. // 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) return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // 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) return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// PageCount is not available in this implementation. // 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) return 0, fmt.Errorf("page count with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteBookmarks is not available in this implementation. // 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) return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadBookmarks is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Encrypt is not available in this implementation. // 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) return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// EmbedFiles is not available in this implementation. // 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) return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Watermark is not available in this implementation. // 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) return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Stamp is not available in this implementation. // 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) return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Rotate is not available in this implementation. // 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) return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }

View File

@@ -1,3 +0,0 @@
// Package logging provides a module which creates a zap.Logger instance for
// other modules.
package logging

View File

@@ -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))
}

View File

@@ -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)
)

View File

@@ -6,6 +6,7 @@ import (
"encoding/json" "encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
@@ -14,8 +15,6 @@ import (
"strings" "strings"
"syscall" "syscall"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "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. // 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 := make([]string, 0, 2+len(inputPaths))
args = append(args, "merge", outputPath) args = append(args, "merge", outputPath)
args = append(args, inputPaths...) 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. // 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 var args []string
switch mode.Mode { 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. // 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) return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Convert is not available in this implementation. // 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) return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadMetadata is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // 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) return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// PageCount is not available in this implementation. // 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) return 0, fmt.Errorf("page count with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadBookmarks reads the document outline (bookmarks) of a PDF file using pdfcpu. // 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) tmpPath := fmt.Sprintf("%s.read.json", inputPath)
args := []string{"bookmarks", "export", inputPath, tmpPath} args := []string{"bookmarks", "export", inputPath, tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) 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() { defer func() {
err := os.Remove(tmpPath) err := os.Remove(tmpPath)
if err != nil && !os.IsNotExist(err) { 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. // 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 { if len(bookmarks) == 0 {
return nil return nil
} }
@@ -305,7 +304,7 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, in
defer func() { defer func() {
err := os.Remove(tmpPath) err := os.Remove(tmpPath)
if err != nil { 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 // EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content. // 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 { if len(filePaths) == 0 {
return nil 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 := make([]string, 0, 3+len(filePaths))
args = append(args, "attachments", "add", inputPath) 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. // 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 == "" { if userPassword == "" {
return errors.New("user password cannot be empty") 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. // 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) return engine.applyStampOrWatermark(ctx, logger, "watermark", inputPath, stamp)
} }
// Stamp applies a stamp (on top of page content) to a PDF file using pdfcpu. // 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) return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp)
} }
// Rotate rotates pages of a PDF file by the given angle using pdfcpu. // 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"} args := []string{"rotate"}
if pages != "" { if pages != "" {
args = append(args, "-pages", pages) args = append(args, "-pages", pages)
@@ -411,7 +410,7 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *zap.Logger, inputPath
return nil 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 var mode string
switch stamp.Source { switch stamp.Source {
case gotenberg.StampSourceText: case gotenberg.StampSourceText:

View File

@@ -3,10 +3,12 @@ package pdfengines
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "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 // Merge combines multiple PDF files into a single document using the first
// available engine that supports PDF merging. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -74,6 +82,7 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
case mergeErr := <-errChan: case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr) errored := multierr.AppendInto(&err, mergeErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 { type splitResult struct {
@@ -91,7 +104,11 @@ type splitResult struct {
// Split divides the PDF into separate pages using the first available engine // Split divides the PDF into separate pages using the first available engine
// that supports PDF splitting. // 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 err error
var mu sync.Mutex // to safely append errors. 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) err = multierr.Append(err, result.err)
mu.Unlock() mu.Unlock()
} else { } else {
span.SetStatus(codes.Ok, "")
return result.outputPaths, nil return result.outputPaths, nil
} }
case <-ctx.Done(): 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 // Flatten merges existing annotation appearances with page content using the
// first available engine that supports flattening. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -135,6 +161,7 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i
case mergeErr := <-errChan: case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr) errored := multierr.AppendInto(&err, mergeErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 // Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -160,6 +195,7 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
case mergeErr := <-errChan: case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr) errored := multierr.AppendInto(&err, mergeErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 { type readMetadataResult struct {
@@ -177,7 +217,13 @@ type readMetadataResult struct {
// ReadMetadata extracts metadata from a PDF file using the first available // ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading. // 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 err error
var mu sync.Mutex // to safely append errors. 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) err = multierr.Append(err, result.err)
mu.Unlock() mu.Unlock()
} else { } else {
span.SetStatus(codes.Ok, "")
return result.metadata, nil return result.metadata, nil
} }
case <-ctx.Done(): 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 // WriteMetadata embeds metadata into a PDF file using the first available
// engine that supports metadata writing. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -221,6 +276,7 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
case writeMetadataErr := <-errChan: case writeMetadataErr := <-errChan:
errored := multierr.AppendInto(&err, writeMetadataErr) errored := multierr.AppendInto(&err, writeMetadataErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 { type pageCountResult struct {
@@ -238,7 +298,11 @@ type pageCountResult struct {
// PageCount returns the number of pages in a PDF file using the first available // PageCount returns the number of pages in a PDF file using the first available
// engine that supports metadata reading. // 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 err error
var mu sync.Mutex // to safely append errors. 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) err = multierr.Append(err, result.err)
mu.Unlock() mu.Unlock()
} else { } else {
span.SetStatus(codes.Ok, "")
return result.pageCount, nil return result.pageCount, nil
} }
case <-ctx.Done(): 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 { type readBookmarksResult struct {
@@ -274,7 +343,13 @@ type readBookmarksResult struct {
// ReadBookmarks reads bookmarks from a PDF file using the first available // ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading. // 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 err error
var mu sync.Mutex // to safely append errors. 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) err = multierr.Append(err, result.err)
mu.Unlock() mu.Unlock()
} else { } else {
span.SetStatus(codes.Ok, "")
return result.bookmarks, nil return result.bookmarks, nil
} }
case <-ctx.Done(): 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 // WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
// first available engine that supports bookmarks writing. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -318,6 +402,7 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Lo
case writeBookmarksErr := <-errChan: case writeBookmarksErr := <-errChan:
errored := multierr.AppendInto(&err, writeBookmarksErr) errored := multierr.AppendInto(&err, writeBookmarksErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 // Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -343,6 +436,7 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
case protectErr := <-errChan: case protectErr := <-errChan:
errored := multierr.AppendInto(&err, protectErr) errored := multierr.AppendInto(&err, protectErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 // EmbedFiles embeds files into a PDF using the first available
// engine that supports file embedding. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -368,6 +472,7 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger
case embedErr := <-errChan: case embedErr := <-errChan:
errored := multierr.AppendInto(&err, embedErr) errored := multierr.AppendInto(&err, embedErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 // Watermark applies a watermark (behind page content) to a PDF file using the
// first available engine that supports watermarking. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -393,6 +508,7 @@ func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger,
case watermarkErr := <-errChan: case watermarkErr := <-errChan:
errored := multierr.AppendInto(&err, watermarkErr) errored := multierr.AppendInto(&err, watermarkErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 // Stamp applies a stamp (on top of page content) to a PDF file using the
// first available engine that supports stamping. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -418,6 +544,7 @@ func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inp
case stampErr := <-errChan: case stampErr := <-errChan:
errored := multierr.AppendInto(&err, stampErr) errored := multierr.AppendInto(&err, stampErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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 // Rotate rotates pages of a PDF file using the first available engine that
// supports rotation. // 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 var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -443,6 +578,7 @@ func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, in
case rotateErr := <-errChan: case rotateErr := <-errChan:
errored := multierr.AppendInto(&err, rotateErr) errored := multierr.AppendInto(&err, rotateErr)
if !errored { if !errored {
span.SetStatus(codes.Ok, "")
return nil return nil
} }
case <-ctx.Done(): 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. // Interface guards.

View File

@@ -115,12 +115,12 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
} }
// Example in the case of deprecated module name. // Example in the case of deprecated module name.
//for i, name := range defaultNames { // for i, name := range defaultNames {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" { // if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name)) // logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.defaultNames[i] = "libreoffice-pdfengine" // mod.defaultNames[i] = "libreoffice-pdfengine"
// } // }
//} // }
mod.mergeNames = defaultNames mod.mergeNames = defaultNames
if len(mergeNames) > 0 { if len(mergeNames) > 0 {
@@ -253,19 +253,19 @@ func (mod *PdfEngines) Validate() error {
// modules. // modules.
func (mod *PdfEngines) SystemMessages() []string { func (mod *PdfEngines) SystemMessages() []string {
return []string{ return []string{
fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames[:], " ")), fmt.Sprintf("merge engines - %s", strings.Join(mod.mergeNames, " ")),
fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames[:], " ")), fmt.Sprintf("split engines - %s", strings.Join(mod.splitNames, " ")),
fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames[:], " ")), fmt.Sprintf("flatten engines - %s", strings.Join(mod.flattenNames, " ")),
fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames[:], " ")), fmt.Sprintf("convert engines - %s", strings.Join(mod.convertNames, " ")),
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames, " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames, " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")), fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames, " ")),
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")), fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames, " ")),
fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames[:], " ")), fmt.Sprintf("read bookmarks engines - %s", strings.Join(mod.readBookmarksNames, " ")),
fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames[:], " ")), fmt.Sprintf("write bookmarks engines - %s", strings.Join(mod.writeBookmarksNames, " ")),
fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames[:], " ")), fmt.Sprintf("watermark engines - %s", strings.Join(mod.watermarkNames, " ")),
fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames[:], " ")), fmt.Sprintf("stamp engines - %s", strings.Join(mod.stampNames, " ")),
fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames[:], " ")), fmt.Sprintf("rotate engines - %s", strings.Join(mod.rotateNames, " ")),
} }
} }

View File

@@ -5,13 +5,12 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"syscall" "syscall"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -79,7 +78,7 @@ func (engine *PdfTk) Debug() map[string]any {
} }
// Split splits a given PDF file. // 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 var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) 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. // 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 := make([]string, 0, 3+len(inputPaths))
args = append(args, inputPaths...) args = append(args, inputPaths...)
args = append(args, "cat", "output", outputPath) 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. // 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) return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Convert is not available in this implementation. // 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) return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadMetadata is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // 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) return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// PageCount is not available in this implementation. // 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) return 0, fmt.Errorf("page count with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteBookmarks is not available in this implementation. // 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) return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadBookmarks is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Encrypt adds password protection to a PDF file using PDFtk. // 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 == "" { if userPassword == "" {
return errors.New("user password cannot be empty") 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. // 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) return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Watermark applies a watermark (behind page content) to a PDF file using PDFtk. // Watermark applies a watermark (behind page content) to a PDF file using PDFtk.
// Only PDF source is supported. // 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 { if stamp.Source != gotenberg.StampSourcePDF {
return fmt.Errorf("watermark PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported) 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. // Stamp applies a stamp (on top of page content) to a PDF file using PDFtk.
// Only PDF source is supported. // 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 { if stamp.Source != gotenberg.StampSourcePDF {
return fmt.Errorf("stamp PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported) 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. // 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, // Page-specific rotation is not supported; if pages is non-empty,
// ErrPdfEngineMethodNotSupported is returned. // 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 != "" { if pages != "" {
return fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported) return fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }

View File

@@ -177,9 +177,9 @@ func (mod *Prometheus) Routes() ([]api.Route, error) {
return []api.Route{ return []api.Route{
{ {
Method: http.MethodGet, Method: http.MethodGet,
Path: mod.metricsPath, Path: mod.metricsPath,
DisableLogging: mod.disableRouteLogging, DisableTelemetry: mod.disableRouteLogging,
Handler: echo.WrapHandler( Handler: echo.WrapHandler(
promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}), promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}),
), ),

View File

@@ -5,13 +5,12 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"syscall" "syscall"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -82,7 +81,7 @@ func (engine *QPdf) Debug() map[string]any {
} }
// Split splits a given PDF file. // 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 var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath)) 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. // 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 := make([]string, 0, 4+len(engine.globalArgs)+len(inputPaths))
args = append(args, "--empty") args = append(args, "--empty")
args = append(args, engine.globalArgs...) 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 // Flatten merges annotation appearances with page content, deleting the
// original annotations. // 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 := make([]string, 0, 4+len(engine.globalArgs))
args = append(args, inputPath) args = append(args, inputPath)
args = append(args, "--generate-appearances") 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. // 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) return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadMetadata is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteMetadata is not available in this implementation. // 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) return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// PageCount is not available in this implementation. // 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) return 0, fmt.Errorf("page count with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// WriteBookmarks is not available in this implementation. // 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) return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// ReadBookmarks is not available in this implementation. // 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) return nil, fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Encrypt adds password protection to a PDF file using QPDF. // 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 == "" { if userPassword == "" {
return errors.New("user password cannot be empty") 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. // 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) return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Watermark is not available in this implementation. // 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) return fmt.Errorf("watermark PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Stamp is not available in this implementation. // 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) return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Rotate is not available in this implementation. // 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) return fmt.Errorf("rotate PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }

View File

@@ -1,15 +1,22 @@
package webhook package webhook
import ( import (
"context"
"fmt" "fmt"
"io" "io"
"log/slog"
"net/http" "net/http"
"strconv" "strconv"
"time" "time"
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "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. // client gathers all the data required to send a request to a webhook.
@@ -22,11 +29,11 @@ type client struct {
startTime time.Time startTime time.Time
client *retryablehttp.Client client *retryablehttp.Client
logger *zap.Logger logger *slog.Logger
} }
// send call the webhook either to send the success response or the error response. // 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 url := c.url
if errored { if errored {
url = c.errorUrl url = c.errorUrl
@@ -37,11 +44,25 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
method = c.errorMethod 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) req, err := retryablehttp.NewRequest(method, url, body)
if err != nil { if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err) 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") req.Header.Set("User-Agent", "Gotenberg")
// Extra HTTP headers are the custom headers from the user. // 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) bodySize, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil { if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("parse content length entry: %w", err) 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) resp, err := c.client.Do(req)
if err != nil { if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("send '%s' request to '%s': %w", method, url, err) return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
} }
if resp.StatusCode >= http.StatusBadRequest { 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() { defer func() {
err := resp.Body.Close() err := resp.Body.Close()
if err != nil { 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() finishTime := time.Now()
// Now let's log! // Now let's log!
fields := make([]zap.Field, 5) attrs := []any{
fields[0] = zap.String("webhook_url", url) slog.String("webhook_url", url),
fields[1] = zap.String("method", method) slog.String("method", method),
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime))) slog.Int64("latency", int64(finishTime.Sub(c.startTime))),
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String()) slog.String("latency_human", finishTime.Sub(c.startTime).String()),
fields[4] = zap.Int64("bytes_out", req.ContentLength) slog.Int64("bytes_out", req.ContentLength),
}
if errored { 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 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 return nil
} }

View File

@@ -21,13 +21,13 @@ import (
) )
type sendOutputFileParams struct { type sendOutputFileParams struct {
ctx *api.Context ctx *api.Context
outputPath string outputPath string
extraHttpHeaders map[string]string extraHttpHeaders map[string]string
traceHeader string correlationIdHeader string
trace string correlationId string
client *client client *client
handleError func(error) handleError func(error)
} }
func webhookMiddleware(w *Webhook) api.Middleware { func webhookMiddleware(w *Webhook) api.Middleware {
@@ -72,16 +72,16 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
headers := map[string]string{ headers := map[string]string{
echo.HeaderContentType: http.DetectContentType(fileHeader), echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10), echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.traceHeader: params.trace, params.correlationIdHeader: params.correlationId,
} }
_, ok := params.extraHttpHeaders[echo.HeaderContentDisposition] _, ok := params.extraHttpHeaders[echo.HeaderContentDisposition]
if !ok { if !ok {
headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath)) 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 { if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err)) params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err) params.handleError(err)
@@ -179,8 +179,8 @@ func webhookMiddleware(w *Webhook) api.Middleware {
// Retrieve values from echo.Context before it gets recycled. // Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000. // See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time) startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string) correlationIdHeader := c.Get("correlationIdHeader").(string)
trace := c.Get("trace").(string) correlationId := c.Get("correlationId").(string)
client := &client{ client := &client{
url: webhookUrl, url: webhookUrl,
@@ -226,10 +226,10 @@ func webhookMiddleware(w *Webhook) api.Middleware {
headers := map[string]string{ headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON, 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 { if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error())) 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. // No error, let's send the output file to the webhook URL.
sendOutputFile(sendOutputFileParams{ sendOutputFile(sendOutputFileParams{
ctx: ctx, ctx: ctx,
outputPath: outputPath, outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders, extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader, correlationIdHeader: correlationIdHeader,
trace: trace, correlationId: correlationId,
client: client, client: client,
handleError: handleError, handleError: handleError,
}) })
return c.NoContent(http.StatusNoContent) return c.NoContent(http.StatusNoContent)
} }
@@ -332,13 +332,13 @@ func webhookMiddleware(w *Webhook) api.Middleware {
} }
sendOutputFile(sendOutputFileParams{ sendOutputFile(sendOutputFileParams{
ctx: ctx, ctx: ctx,
outputPath: outputPath, outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders, extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader, correlationIdHeader: correlationIdHeader,
trace: trace, correlationId: correlationId,
client: client, client: client,
handleError: handleError, handleError: handleError,
}) })
}() }()

View File

@@ -8,7 +8,6 @@ import (
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice" _ "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/api"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/pdfengine" _ "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/pdfcpu"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdfengines"
_ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk" _ "github.com/gotenberg/gotenberg/v8/pkg/modules/pdftk"

View File

@@ -1102,7 +1102,7 @@ Feature: /forms/chromium/convert/html
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html" Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_chromium_convert_html" | | "correlation_id":"forms_chromium_convert_html" |
@download-from @download-from
Scenario: POST /forms/chromium/convert/html (Download From) Scenario: POST /forms/chromium/convert/html (Download From)

View File

@@ -1074,7 +1074,7 @@ Feature: /forms/chromium/convert/markdown
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html" Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_html"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_chromium_convert_html" | | "correlation_id":"forms_chromium_convert_html" |
@download-from @download-from
Scenario: POST /forms/chromium/convert/markdown (Download From) Scenario: POST /forms/chromium/convert/markdown (Download From)

View File

@@ -1169,7 +1169,7 @@ Feature: /forms/chromium/convert/url
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_url" Then the response header "Gotenberg-Trace" should be "forms_chromium_convert_url"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_chromium_convert_url" | | "correlation_id":"forms_chromium_convert_url" |
@webhook @webhook
Scenario: POST /forms/chromium/convert/url (Webhook) Scenario: POST /forms/chromium/convert/url (Webhook)

View File

@@ -25,7 +25,6 @@ Feature: /debug
"libreoffice", "libreoffice",
"libreoffice-api", "libreoffice-api",
"libreoffice-pdfengine", "libreoffice-pdfengine",
"logging",
"pdfcpu", "pdfcpu",
"pdfengines", "pdfengines",
"pdftk", "pdftk",
@@ -56,8 +55,10 @@ Feature: /debug
"flags": { "flags": {
"api-bind-ip": "", "api-bind-ip": "",
"api-body-limit": "", "api-body-limit": "",
"api-correlation-id-header": "Gotenberg-Trace",
"api-disable-download-from": "false", "api-disable-download-from": "false",
"api-disable-health-check-logging": "false", "api-disable-health-check-logging": "false",
"api-disable-health-check-route-telemetry": "false",
"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": "4", "api-download-from-max-retry": "4",
@@ -96,9 +97,12 @@ Feature: /debug
"libreoffice-max-queue-size": "0", "libreoffice-max-queue-size": "0",
"libreoffice-restart-after": "10", "libreoffice-restart-after": "10",
"libreoffice-start-timeout": "20s", "libreoffice-start-timeout": "20s",
"log-enable-gcp-fields": "false",
"log-fields-prefix": "", "log-fields-prefix": "",
"log-format": "auto", "log-format": "auto",
"log-level": "info", "log-level": "info",
"log-std-enable-gcp-fields": "false",
"log-std-format": "auto",
"pdfengines-convert-engines": "[libreoffice-pdfengine]", "pdfengines-convert-engines": "[libreoffice-pdfengine]",
"pdfengines-disable-routes": "false", "pdfengines-disable-routes": "false",
"pdfengines-engines": "[]", "pdfengines-engines": "[]",
@@ -147,7 +151,6 @@ Feature: /debug
"libreoffice", "libreoffice",
"libreoffice-api", "libreoffice-api",
"libreoffice-pdfengine", "libreoffice-pdfengine",
"logging",
"pdfcpu", "pdfcpu",
"pdfengines", "pdfengines",
"pdftk", "pdftk",
@@ -178,8 +181,10 @@ Feature: /debug
"flags": { "flags": {
"api-bind-ip": "", "api-bind-ip": "",
"api-body-limit": "", "api-body-limit": "",
"api-correlation-id-header": "Gotenberg-Trace",
"api-disable-download-from": "false", "api-disable-download-from": "false",
"api-disable-health-check-logging": "false", "api-disable-health-check-logging": "false",
"api-disable-health-check-route-telemetry": "false",
"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": "4", "api-download-from-max-retry": "4",
@@ -218,9 +223,12 @@ Feature: /debug
"libreoffice-max-queue-size": "0", "libreoffice-max-queue-size": "0",
"libreoffice-restart-after": "10", "libreoffice-restart-after": "10",
"libreoffice-start-timeout": "20s", "libreoffice-start-timeout": "20s",
"log-enable-gcp-fields": "false",
"log-fields-prefix": "", "log-fields-prefix": "",
"log-format": "auto", "log-format": "auto",
"log-level": "info", "log-level": "info",
"log-std-enable-gcp-fields": "false",
"log-std-format": "auto",
"pdfengines-convert-engines": "[libreoffice-pdfengine]", "pdfengines-convert-engines": "[libreoffice-pdfengine]",
"pdfengines-disable-routes": "false", "pdfengines-disable-routes": "false",
"pdfengines-engines": "[]", "pdfengines-engines": "[]",
@@ -276,7 +284,7 @@ Feature: /debug
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "debug" Then the response header "Gotenberg-Trace" should be "debug"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"debug" | | "correlation_id":"debug" |
Scenario: GET /debug (Basic Auth) Scenario: GET /debug (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):

View File

@@ -31,7 +31,7 @@ Feature: /health
Scenario: GET /health (No Logging) Scenario: GET /health (No Logging)
Given I have a Gotenberg container with the following environment variable(s): 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 When I make a "GET" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200 Then the response status code should be 200
Then the Gotenberg container should NOT log the following entries: 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 status code should be 200
Then the response header "Gotenberg-Trace" should be "get_health" Then the response header "Gotenberg-Trace" should be "get_health"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"get_health" | | "correlation_id":"get_health" |
Scenario: GET /health (Basic Auth) Scenario: GET /health (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s): 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 status code should be 200
Then the response header "Gotenberg-Trace" should be "head_health" Then the response header "Gotenberg-Trace" should be "head_health"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"head_health" | | "correlation_id":"head_health" |
Scenario: HEAD /health (No Logging) Scenario: HEAD /health (No Logging)
Given I have a Gotenberg container with the following environment variable(s): 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 When I make a "HEAD" request to Gotenberg at the "/health" endpoint
Then the response status code should be 200 Then the response status code should be 200
Then the Gotenberg container should NOT log the following entries: Then the Gotenberg container should NOT log the following entries:

View File

@@ -716,7 +716,7 @@ Feature: /forms/libreoffice/convert
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert" Then the response header "Gotenberg-Trace" should be "forms_libreoffice_convert"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_libreoffice_convert" | | "correlation_id":"forms_libreoffice_convert" |
@download-from @download-from
Scenario: POST /forms/libreoffice/convert (Download From) Scenario: POST /forms/libreoffice/convert (Download From)

View File

@@ -193,7 +193,7 @@ Feature: /forms/pdfengines/bookmarks/{write|read}
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_write" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_write"
Then the Gotenberg container should log the following entries: 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) Scenario: POST /forms/pdfengines/bookmarks/read (Gotenberg Trace)
Given I have a default Gotenberg container 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 "Content-Type" should be "application/json"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_read" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_bookmarks_read"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_bookmarks_read" | | "correlation_id":"forms_pdfengines_bookmarks_read" |
@output-filename @output-filename
Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Single PDF) Scenario: POST /forms/pdfengines/bookmarks/write (Output Filename - Single PDF)

View File

@@ -115,7 +115,7 @@ Feature: /forms/pdfengines/convert
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_convert" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_convert"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_convert" | | "correlation_id":"forms_pdfengines_convert" |
@output-filename @output-filename
Scenario: POST /forms/pdfengines/convert (Output Filename - Single PDF) Scenario: POST /forms/pdfengines/convert (Output Filename - Single PDF)

View File

@@ -133,7 +133,7 @@ Feature: /forms/pdfengines/encrypt
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_encrypt"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_encrypt" | | "correlation_id":"forms_pdfengines_encrypt" |
@download-from @download-from
Scenario: POST /forms/pdfengines/encrypt (Download From) Scenario: POST /forms/pdfengines/encrypt (Download From)

View File

@@ -49,7 +49,7 @@ Feature: /forms/pdfengines/flatten
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_flatten" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_flatten"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_flatten" | | "correlation_id":"forms_pdfengines_flatten" |
@output-filename @output-filename
Scenario: POST /forms/pdfengines/flatten (Output Filename - Single PDF) Scenario: POST /forms/pdfengines/flatten (Output Filename - Single PDF)

View File

@@ -549,7 +549,7 @@ Feature: /forms/pdfengines/merge
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_merge" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_merge"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_merge" | | "correlation_id":"forms_pdfengines_merge" |
@download-from @download-from
Scenario: POST /forms/pdfengines/merge (Download From) Scenario: POST /forms/pdfengines/merge (Download From)

View File

@@ -141,7 +141,7 @@ Feature: /forms/pdfengines/metadata/{write|read}
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_write" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_write"
Then the Gotenberg container should log the following entries: 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) Scenario: POST /forms/pdfengines/metadata/read (Gotenberg Trace)
Given I have a default Gotenberg container 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 "Content-Type" should be "application/json"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_read" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_metadata_read"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_metadata_read" | | "correlation_id":"forms_pdfengines_metadata_read" |
@output-filename @output-filename
Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Single PDF) Scenario: POST /forms/pdfengines/metadata/write (Output Filename - Single PDF)

View File

@@ -127,7 +127,7 @@ Feature: /forms/pdfengines/rotate
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_rotate" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_rotate"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_rotate" | | "correlation_id":"forms_pdfengines_rotate" |
@webhook @webhook
Scenario: POST /forms/pdfengines/rotate (Webhook) Scenario: POST /forms/pdfengines/rotate (Webhook)

View File

@@ -632,7 +632,7 @@ Feature: /forms/pdfengines/split
Then the response header "Content-Type" should be "application/zip" Then the response header "Content-Type" should be "application/zip"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_split" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_split"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_split" | | "correlation_id":"forms_pdfengines_split" |
@output-filename @output-filename
Scenario: POST /forms/pdfengines/split (Output Filename - Single PDF) Scenario: POST /forms/pdfengines/split (Output Filename - Single PDF)

View File

@@ -197,7 +197,7 @@ Feature: /forms/pdfengines/stamp
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_stamp" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_stamp"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_stamp" | | "correlation_id":"forms_pdfengines_stamp" |
@webhook @webhook
Scenario: POST /forms/pdfengines/stamp (Webhook) Scenario: POST /forms/pdfengines/stamp (Webhook)

View File

@@ -197,7 +197,7 @@ Feature: /forms/pdfengines/watermark
Then the response header "Content-Type" should be "application/pdf" Then the response header "Content-Type" should be "application/pdf"
Then the response header "Gotenberg-Trace" should be "forms_pdfengines_watermark" Then the response header "Gotenberg-Trace" should be "forms_pdfengines_watermark"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"forms_pdfengines_watermark" | | "correlation_id":"forms_pdfengines_watermark" |
@webhook @webhook
Scenario: POST /forms/pdfengines/watermark (Webhook) Scenario: POST /forms/pdfengines/watermark (Webhook)

View File

@@ -98,7 +98,7 @@ Feature: /prometheus/metrics
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "prometheus_metrics" Then the response header "Gotenberg-Trace" should be "prometheus_metrics"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"prometheus_metrics" | | "correlation_id":"prometheus_metrics" |
Scenario: GET /prometheus/metrics (Basic Auth) Scenario: GET /prometheus/metrics (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):

View File

@@ -18,7 +18,7 @@ Feature: /
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "root" Then the response header "Gotenberg-Trace" should be "root"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"root" | | "correlation_id":"root" |
Scenario: GET / (Basic Auth) Scenario: GET / (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s): 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 status code should be 204
Then the response header "Gotenberg-Trace" should be "favicon" Then the response header "Gotenberg-Trace" should be "favicon"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"favicon" | | "correlation_id":"favicon" |
Scenario: GET /favicon.ico (Basic Auth) Scenario: GET /favicon.ico (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):

View File

@@ -18,7 +18,7 @@ Feature: /version
Then the response status code should be 200 Then the response status code should be 200
Then the response header "Gotenberg-Trace" should be "version" Then the response header "Gotenberg-Trace" should be "version"
Then the Gotenberg container should log the following entries: Then the Gotenberg container should log the following entries:
| "trace":"version" | | "correlation_id":"version" |
Scenario: GET /version (Basic Auth) Scenario: GET /version (Basic Auth)
Given I have a Gotenberg container with the following environment variable(s): Given I have a Gotenberg container with the following environment variable(s):

View File

@@ -525,13 +525,14 @@ func (s *scenario) theResponseStatusCodeShouldBe(expected int) error {
func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) error { func (s *scenario) theHeaderValueShouldBe(kind, name string, expected string) error {
var actual string var actual string
if kind == "response" { switch {
case kind == "response":
actual = s.resp.Header().Get(name) actual = s.resp.Header().Get(name)
} else if s.server == nil { case s.server == nil:
return errors.New("server not initialized") return errors.New("server not initialized")
} else if s.server.req == nil { case s.server.req == nil:
return errors.New("no webhook request found") return errors.New("no webhook request found")
} else { default:
actual = s.server.req.Header.Get(name) 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 { func (s *scenario) theCookieValueShouldBe(kind, name, expected string) error {
var cookies []*http.Cookie var cookies []*http.Cookie
if kind == "response" { switch {
case kind == "response":
cookies = s.resp.Result().Cookies() cookies = s.resp.Result().Cookies()
} else if s.server == nil { case s.server == nil:
return errors.New("server not initialized") return errors.New("server not initialized")
} else if s.server.req == nil { case s.server.req == nil:
return errors.New("no webhook request found") return errors.New("no webhook request found")
} else { default:
cookies = s.server.req.Cookies() 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 { func (s *scenario) theBodyShouldMatchString(kind string, expectedDoc *godog.DocString) error {
var actual string var actual string
if kind == "response" { switch {
case kind == "response":
actual = s.resp.Body.String() actual = s.resp.Body.String()
} else if s.server == nil { case s.server == nil:
return errors.New("server not initialized") return errors.New("server not initialized")
} else if s.server.req == nil { case s.server.req == nil:
return errors.New("no webhook request found") return errors.New("no webhook request found")
} else { default:
actual = string(s.server.bodyCopy) 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 { func (s *scenario) theBodyShouldContainString(kind string, expectedDoc *godog.DocString) error {
var actual string var actual string
if kind == "response" { switch {
case kind == "response":
actual = s.resp.Body.String() actual = s.resp.Body.String()
} else if s.server == nil { case s.server == nil:
return errors.New("server not initialized") return errors.New("server not initialized")
} else if s.server.req == nil { case s.server.req == nil:
return errors.New("no webhook request found") return errors.New("no webhook request found")
} else { default:
actual = string(s.server.bodyCopy) 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 { func (s *scenario) theBodyShouldMatchJSON(kind string, expectedDoc *godog.DocString) error {
var body []byte var body []byte
if kind == "response" { switch {
case kind == "response":
body = s.resp.Body.Bytes() body = s.resp.Body.Bytes()
} else if s.server == nil { case s.server == nil:
return errors.New("server not initialized") return errors.New("server not initialized")
} else if s.server.req == nil { case s.server.req == nil:
return errors.New("no webhook request found") return errors.New("no webhook request found")
} else { default:
body = s.server.bodyCopy body = s.server.bodyCopy
} }