Compare commits

...

27 Commits

Author SHA1 Message Date
Julien Neuhart
1ab0117943 fix(chromium): assets not loading 2026-03-30 18:14:15 +02:00
Julien Neuhart
5be574081c ci: bump checkout actions 2026-03-28 21:45:11 +01:00
Julien Neuhart
6430bc6a5c test(integration): add retry mecanism 2026-03-28 21:09:22 +01:00
Julien Neuhart
e0eff40f74 tests(integreation): fix failing scenarios and add screenshots one 2026-03-28 20:39:53 +01:00
Julien Neuhart
385cbe6590 feat(webhook): add events 2026-03-28 19:00:07 +01:00
Julien Neuhart
043b1588de fix(exiftool): arbitrary file write 2026-03-28 14:19:04 +01:00
Julien Neuhart
e5d861def3 refactor(otel): disable telemetry by default for system routes 2026-03-28 14:08:50 +01:00
Julien Neuhart
72bc6f895a feat(otel): flag to disable telemetry for system routes 2026-03-28 14:06:16 +01:00
Julien Neuhart
7549a69f71 feat(otel): switch from software name to binary path 2026-03-28 13:31:28 +01:00
Julien Neuhart
ebf0548d19 refactor: switch from multierr to errors.join 2026-03-27 22:14:01 +01:00
Julien Neuhart
d3a65a587c feat(supervisor): optional idle shutdown 2026-03-27 21:16:49 +01:00
Julien Neuhart
8f7c1c98ad fix(api): handle correctly filename that takes more that 200 bytes 2026-03-27 20:55:50 +01:00
Julien Neuhart
40d75882a2 fix(chromium): better singlePage handling - accounting for margins now 2026-03-27 19:50:20 +01:00
Julien Neuhart
bb91259829 feat(libreoffice): add viewer preference form fields 2026-03-27 19:40:42 +01:00
Julien Neuhart
0b33be17a4 fix(chromium): waitForSelector and waitForExpression poll indefinitely when the main page returns a bad HTTP status (e.g., 500) 2026-03-27 17:32:27 +01:00
Julien Neuhart
3a78b89c97 docs(otel): add pointers 2026-03-27 16:56:29 +01:00
Julien Neuhart
c72be765b0 feat(otel): add more tracing when communicating with internal tools / external APIs 2026-03-27 16:50:24 +01:00
Julien Neuhart
4e9f63004d feat(otel): add OpenTelemetry support 2026-03-27 16:28:45 +01:00
Julien Neuhart
08088c15f4 feat(chromium): skip or waits for networkIdle2 event 2026-03-27 11:01:39 +01:00
Julien Neuhart
8625a4e899 fix(webhook/downloadFrom): better default security and DX for allow / deny lists 2026-03-27 10:52:04 +01:00
Julien Neuhart
06b2b2e10c fix(chromium): better default security and DX for allow / deny lists 2026-03-27 09:47:21 +01:00
Julien Neuhart
bd6d92be9b fix(supervisor): queue slot - second request hitting a busy node now gets a 429 immediately, pushing backpressure to a load balancer, if any 2026-03-26 21:24:51 +01:00
Julien Neuhart
ed22f1e5e6 refactor(supervisor): better orchestration of processes for conversion requests 2026-03-26 21:24:51 +01:00
Julien Neuhart
8e3acc8d0a docs(agents): better (?) organization of context files 2026-03-26 21:24:51 +01:00
dependabot[bot]
51afa5ba85 chore(deps): bump github.com/chromedp/chromedp from 0.14.2 to 0.15.0
Bumps [github.com/chromedp/chromedp](https://github.com/chromedp/chromedp) from 0.14.2 to 0.15.0.
- [Release notes](https://github.com/chromedp/chromedp/releases)
- [Commits](https://github.com/chromedp/chromedp/compare/v0.14.2...v0.15.0)

---
updated-dependencies:
- dependency-name: github.com/chromedp/chromedp
  dependency-version: 0.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-03-23 10:20:37 +01:00
Julien Neuhart
d67500ee05 chore(cmd): new start title 2026-03-21 21:17:35 +01:00
Julien Neuhart
0bec14c6ac docs: better README.md 2026-03-21 20:42:05 +01:00
132 changed files with 7901 additions and 1966 deletions

65
.bruno/AGENTS.md Normal file
View File

@@ -0,0 +1,65 @@
# Bruno API Collection
A [Bruno](https://www.usebruno.com/) collection lives in `.bruno/` and mirrors every Gotenberg route. When adding or updating a route, update the collection to match.
## Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, …)
```
## `.bru` File Format
```bru
meta {
name: <Human-readable name>
type: http
seq: <order within folder>
}
post {
url: {{baseUrl}}/forms/<path>
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/<file>)
<mandatoryField>: <value>
~<optionalField>: <value>
}
headers {
~Gotenberg-Output-Filename: <name>
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}
```
## Conventions
- **Mandatory fields** are listed without prefix; **optional fields** are prefixed with `~` (disabled by default in Bruno).
- **File references** use relative paths to `test/integration/testdata/`.
- **Webhook and output filename headers** are included on every POST route as optional (`~`).
- **One `.bru` file per request**. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
## Checklist When Adding/Updating a Route
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler — check `FormData*` calls in the route function.
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
4. Verify the URL path matches the route's `Path` field exactly.
5. If you add a new module folder, keep the naming consistent (e.g., `PDF Engines/Rotate/`).

View File

@@ -66,6 +66,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -67,6 +67,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -66,6 +66,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -40,6 +40,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -41,6 +41,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -40,6 +40,7 @@ headers {
~Gotenberg-Output-Filename: my-screenshot
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -31,6 +31,21 @@ body:multipart-form {
~skipEmptyPages: false
~addOriginalDocumentAsStream: false
~singlePageSheets: false
~initialView: 0
~initialPage: 1
~magnification: 0
~zoom: 100
~pageLayout: 0
~firstPageOnLeft: false
~resizeWindowToInitialPage: false
~centerWindow: false
~openInFullScreenMode: false
~displayPDFDocumentTitle: true
~hideViewerMenubar: false
~hideViewerToolbar: false
~hideViewerWindowControls: false
~useTransitionEffects: true
~openBookmarkLevels: -1
~losslessImageCompression: false
~quality: 90
~reduceImageResolution: false
@@ -68,6 +83,7 @@ headers {
~Gotenberg-Output-Filename: my-file
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -17,6 +17,7 @@ body:multipart-form {
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -19,6 +19,7 @@ headers {
~Gotenberg-Output-Filename: with-bookmarks
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: converted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: with-embeds
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: encrypted
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -18,6 +18,7 @@ headers {
~Gotenberg-Output-Filename: flattened
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -37,6 +37,7 @@ headers {
~Gotenberg-Output-Filename: merged
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -17,6 +17,7 @@ body:multipart-form {
headers {
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -19,6 +19,7 @@ headers {
~Gotenberg-Output-Filename: with-metadata
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -20,6 +20,7 @@ headers {
~Gotenberg-Output-Filename: rotated
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -37,6 +37,7 @@ headers {
~Gotenberg-Output-Filename: split
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -24,6 +24,7 @@ headers {
~Gotenberg-Output-Filename: stamped
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -24,6 +24,7 @@ headers {
~Gotenberg-Output-Filename: watermarked
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Events-Url: http://localhost:8080/webhook/events
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}

View File

@@ -48,7 +48,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Log in to Docker Hub
if: inputs.docker_hub_username != ''

View File

@@ -31,7 +31,7 @@ runs:
uses: docker/setup-buildx-action@v3
- name: Check out code
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Log in to Docker Hub
uses: docker/login-action@v3

View File

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

207
AGENTS.md
View File

@@ -26,7 +26,7 @@ Implement the approved plan following the coding standards and patterns describe
Write or update tests based on the plan's testing strategy:
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See the [Integration Tests](#integration-tests) section.
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See [`test/integration/AGENTS.md`](test/integration/AGENTS.md) for the full reference.
- **Unit tests** (when applicable): Table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`.
### Step 4 — Review
@@ -41,6 +41,8 @@ Present the review to the user and **wait for explicit approval**. Do NOT commit
<type>(<scope>): <description>
```
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
---
@@ -73,6 +75,7 @@ Key interfaces live in `pkg/gotenberg/` — `Module`, `Provisioner`, `Validator`
- Mocks for all major interfaces are in `pkg/gotenberg/mocks.go` — use them for unit tests rather than creating new ones.
- Import ordering is enforced: standard library, third-party, then `github.com/gotenberg/gotenberg/v8` — separated by blank lines.
- When making changes, run only the relevant integration test tag rather than the full suite (40min timeout).
- Telemetry infrastructure lives in `pkg/gotenberg/telemetry.go` (global Logger, Tracer, Meter) and `pkg/gotenberg/internal/` (log handlers, OTEL SDK init). HTTP semantic conventions are in `pkg/gotenberg/semconv/`.
---
@@ -80,17 +83,19 @@ Key interfaces live in `pkg/gotenberg/` — `Module`, `Provisioner`, `Validator`
All build and verification tasks go through the Makefile. Do not run `go` commands directly unless debugging a specific package.
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------- |
| `make build` | Build the Docker image | Before integration tests, or to verify compilation |
| `make run` | Run a Gotenberg container locally | Manual testing. Flags are configured via `.env` and Makefile variables |
| `make fmt` | Format Go code (`go fix`, `golangci-lint fmt`, `go mod tidy`) | Before every commit |
| `make lint` | Lint Go code (strict `.golangci.yml` config) | Before every commit. Zero errors permitted |
| `make lint-prettier` | Lint non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make prettify` | Format non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make test-unit` | Run unit tests (`go test -race ./...`) | After code changes to `pkg/` |
| `make test-integration` | Run integration tests (Gherkin/Godog, 40min timeout) | After any feature or route change |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
| Command | Purpose | When to use |
| ----------------------- | ------------------------------------------------------------- | ---------------------------------------------------------------------------- |
| `make build` | Build the Docker image | Before integration tests, or to verify compilation |
| `make run` | Run Gotenberg container via `docker compose` | Manual testing. Flags are configured via Makefile variables and compose.yaml |
| `make telemetry` | Start OpenTelemetry collector and OpenObserve | When testing telemetry locally |
| `make down` | Stop all compose containers | After manual testing |
| `make fmt` | Format Go code (`go fix`, `golangci-lint fmt`, `go mod tidy`) | Before every commit |
| `make lint` | Lint Go code (strict `.golangci.yml` config) | Before every commit. Zero errors permitted |
| `make lint-prettier` | Lint non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make prettify` | Format non-Go files (Markdown, YAML, etc.) with Prettier | Before every commit |
| `make test-unit` | Run unit tests (`go test -race ./...`) | After code changes to `pkg/` |
| `make test-integration` | Run integration tests (Gherkin/Godog, 40min timeout) | After any feature or route change |
| `make godoc` | Serve GoDoc at `localhost:6060` | To verify documentation |
## Module System
@@ -103,118 +108,15 @@ Gotenberg uses a self-registering module architecture inspired by CaddyServer. E
When adding a feature, first determine if it belongs in an existing module. Only create a new module if the feature represents a genuinely separate concern.
## Commit Convention
Commits must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification:
```
<type>(<scope>): <description>
```
Common types: `feat`, `fix`, `refactor`, `test`, `docs`, `chore`, `ci`, `build`. The scope should match the module or area of the change (e.g., `chromium`, `pdfengines`, `api`).
## Coding Patterns
- **Error handling:** Always wrap errors with context using `fmt.Errorf("description: %w", err)`. Never swallow errors silently.
- **Import ordering:** Enforced by `gci` — standard library, then third-party, then `github.com/gotenberg/gotenberg/v8`. Three groups separated by blank lines.
- **Mocks:** Comprehensive mock implementations for all major interfaces live in `pkg/gotenberg/mocks.go`. Use these for unit tests.
- **Logging:** Use `gotenberg.Logger(mod)` to get the module's slog logger during `Provision()`. All log calls must be context-aware: `logger.DebugContext(ctx, msg)`, `logger.InfoContext(ctx, msg)`, `logger.ErrorContext(ctx, msg)`. This propagates trace/span IDs into structured logs when OpenTelemetry is active.
- **Telemetry:** External tool calls (Chromium, LibreOffice, PDF engines, webhooks, downloads) must create OTEL spans with `trace.SpanKindClient` and `semconv.ServerAddress("toolname")`. Use `gotenberg.Tracer()` and `gotenberg.Meter()` for traces and metrics respectively.
- **No business logic in `cmd/`:** The `cmd/gotenberg/` package is strictly for wiring and startup.
## Adding PDF Engine Features
When adding a new PDF engine capability (e.g., bookmarks, watermark, stamp, embed), you must update the Makefile to include the corresponding engine list variable and flag. Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a matching entry in the Makefile:
1. **Add a variable** in the Makefile's variable block (around line 60-70):
```makefile
PDFENGINES_<FEATURE>_ENGINES=<default engines>
```
2. **Add the flag** in the Makefile's command args block (around line 140-155):
```makefile
--pdfengines-<feature>-engines=$(PDFENGINES_<FEATURE>_ENGINES) \
```
The default value should match what is defined in `pdfengines.go`'s `fs.StringSlice(...)` call for that flag.
---
## Integration Tests
- **Framework:** Gherkin (BDD) via [Godog](https://github.com/cucumber/godog), with `testcontainers-go` for Docker orchestration.
- **Feature files:** `test/integration/features/*.feature` — one file per endpoint or capability.
- **Test infrastructure:** `test/integration/scenario/` — Go step definitions, container management, HTTP helpers, PDF validation.
- **Entry point:** `test/integration/main_test.go` (build tag: `integration`).
- **Test data:** `test/integration/testdata/`
### How It Works
Each scenario spins up a fresh Gotenberg Docker container via testcontainers. The step definitions in `scenario/scenario.go` map Gherkin steps to Go functions. An additional `gotenberg/integration-tools` container provides PDF validation tools (`verapdf`, `pdfinfo`, `pdftotext`).
**Important:** Integration tests require a Docker image. Run `make build` before `make test-integration`.
### Selective Test Runs
Use the `TAGS` variable to run only relevant scenarios:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
Available tags: `chromium`, `chromium-concurrent`, `chromium-convert-html`, `chromium-convert-markdown`, `chromium-convert-url`, `debug`, `health`, `libreoffice`, `libreoffice-convert`, `output-filename`, `pdfengines`, `pdfengines-convert`, `pdfengines-embed`, `embed`, `pdfengines-encrypt`, `encrypt`, `pdfengines-flatten`, `flatten`, `pdfengines-merge`, `merge`, `pdfengines-metadata`, `metadata`, `pdfengines-split`, `split`, `pdfengines-watermark`, `watermark`, `pdfengines-stamp`, `stamp`, `pdfengines-bookmarks`, `bookmarks`, `pdfengines-rotate`, `rotate`, `prometheus-metrics`, `root`, `version`, `webhook`, `download-from`.
Other useful flags:
```bash
make test-integration NO_CONCURRENCY=true # Disable parallel scenarios
make test-integration PLATFORM=linux/arm64 # Force a specific platform
```
### Writing a New Integration Test
1. Create or update a `.feature` file in `test/integration/features/`.
2. Tag it appropriately (e.g., `@chromium @chromium-convert-html`).
3. If the feature requires new tag(s), add them to both the `TAGS` comment block in the `Makefile` and the "Available tags" list in this file.
4. If you create a new step definition, add it to `scenario/scenario.go`, register it in `InitializeScenario`, and update the "Available Gherkin Steps" list below.
5. Test data goes in `test/integration/testdata/`.
### Available Gherkin Steps
**Given (setup):**
- `I have a default Gotenberg container`
- `I have a Gotenberg container with the following environment variable(s):` (table: key | value)
- `I have a (webhook|static) server`
**When (action):**
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint`
- `I make a "(GET|HEAD)" request to Gotenberg at the "<endpoint>" endpoint with the following header(s):` (table: name | value)
- `I make a "(POST)" request to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (table: name | value | kind — where kind is `file`, `field`, or `header`)
- `I make <N> concurrent "(POST)" requests to Gotenberg at the "<endpoint>" endpoint with the following form data and header(s):` (same table format)
- `I wait for the asynchronous request to the webhook`
**Then (assertions):**
- `the response status code should be <code>`
- `the (response|webhook request) header "<name>" should be "<value>"`
- `the (response|webhook request) cookie "<name>" should be "<value>"`
- `the (response|webhook request) body should match string:` (docstring)
- `the (response|webhook request) body should contain string:` (docstring)
- `the (response|webhook request) body should match JSON:` (docstring — use `"ignore"` for dynamic values like timestamps)
- `there should be <N> PDF(s) in the (response|webhook request)`
- `there should be the following file(s) in the (response|webhook request):` (table of filenames)
- `the "<name>" PDF should have <N> page(s)`
- `the "<name>" PDF (should|should NOT) be set to landscape orientation`
- `the "<name>" PDF (should|should NOT) have the following content at page <N>:` (docstring)
- `the (response|webhook request) PDF(s) should be valid "<standard>" with a tolerance of <N> failed rule(s)` (standards: `PDF/A-1b`, `PDF/A-2b`, `PDF/A-3b`, `PDF/UA-1`, `PDF/UA-2`)
- `the (response|webhook request) PDF(s) (should|should NOT) be flatten`
- `the (response|webhook request) PDF(s) (should|should NOT) be encrypted`
- `the (response|webhook request) PDF(s) (should|should NOT) have the "<filename>" file embedded`
- `the Gotenberg container (should|should NOT) log the following entries:` (table of log substrings)
- `all concurrent response status codes should be <code>`
- `all concurrent responses should have <N> PDF(s)`
---
## Review Checklist
@@ -226,12 +128,13 @@ make test-integration PLATFORM=linux/arm64 # Force a specific platform
- [ ] No existing API form fields renamed or removed
- [ ] No existing HTTP endpoints changed or removed
- [ ] No changes to default values that alter existing behavior
- [ ] Deprecated flags have both old and new names registered, with `fs.MarkDeprecated()`
If any of these are violated, the change **must** be flagged as a breaking change.
### Linting Standards
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, and more. Zero linting errors are permitted.
The `.golangci.yml` enforces strict rules including: `gosec`, `govet`, `errcheck`, `staticcheck`, `dupl`, `bodyclose`, `exhaustive`, `errname`, `sloglint`, `gocritic`, and more. Zero linting errors are permitted.
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` with import ordering:
@@ -269,68 +172,10 @@ A change is ready to merge only when:
---
## Bruno API Collection
## Scoped Guidelines
A [Bruno](https://www.usebruno.com/) collection lives in `.bruno/` and mirrors every Gotenberg route. When adding or updating a route, update the collection to match.
Detailed guidelines for specific areas of the codebase live in their own `AGENTS.md` files:
### Structure
```
.bruno/
├── bruno.json # Collection config
├── collection.bru # Collection-level defaults (Gotenberg-Trace header)
├── environments/
│ ├── Local.bru # baseUrl: http://localhost:3000
│ └── Demo.bru # baseUrl: https://demo.gotenberg.dev
├── Health & Info/ # GET routes
├── Chromium/Convert/ # POST routes grouped by module
├── Chromium/Screenshot/
├── LibreOffice/
└── PDF Engines/<Feature>/ # One folder per feature (Merge, Split, Rotate, …)
```
### `.bru` file format
```bru
meta {
name: <Human-readable name>
type: http
seq: <order within folder>
}
post {
url: {{baseUrl}}/forms/<path>
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/<file>)
<mandatoryField>: <value>
~<optionalField>: <value>
}
headers {
~Gotenberg-Output-Filename: <name>
~Gotenberg-Webhook-Url: http://localhost:8080/webhook
~Gotenberg-Webhook-Error-Url: http://localhost:8080/webhook/error
~Gotenberg-Webhook-Method: POST
~Gotenberg-Webhook-Error-Method: POST
~Gotenberg-Webhook-Extra-Http-Headers: {"X-Custom":"value"}
}
```
### Conventions
- **Mandatory fields** are listed without prefix; **optional fields** are prefixed with `~` (disabled by default in Bruno).
- **File references** use relative paths to `test/integration/testdata/`.
- **Webhook and output filename headers** are included on every POST route as optional (`~`).
- **One `.bru` file per request**. For routes with read/write variants (e.g., bookmarks, metadata), create separate files in the same folder.
### Checklist when adding/updating a route
1. Create or update the `.bru` file in the matching folder under `.bruno/`.
2. Include all form fields from the route handler — check `FormData*` calls in the route function.
3. For file upload fields (`files`, `watermark`, `stamp`, `embeds`), use `@file(...)` with a suitable test file.
4. Verify the URL path matches the route's `Path` field exactly.
5. If you add a new module folder, keep the naming consistent (e.g., `PDF Engines/Rotate/`).
- [`test/integration/AGENTS.md`](test/integration/AGENTS.md) — Integration test framework, Gherkin step reference, available tags, and how to write new tests.
- [`.bruno/AGENTS.md`](.bruno/AGENTS.md) — Bruno API collection structure, `.bru` file format, conventions, and route update checklist.
- [`pkg/modules/pdfengines/AGENTS.md`](pkg/modules/pdfengines/AGENTS.md) — How to add new PDF engine features (Makefile variable and flag).

View File

@@ -2,10 +2,6 @@
Thank you for your interest in contributing to Gotenberg! This guide will help you get started.
## Before You Start
Please read the [AGENTS.md](AGENTS.md) file — it describes the core principles, project layout, development standards, integration test reference, review checklist, and Bruno collection guidelines that all contributions must follow.
## Getting Started
### Prerequisites
@@ -33,6 +29,8 @@ make lint-prettier # Lint non-Go files
make test-unit # Run unit tests
make build # Build the Docker image (required before integration tests)
make test-integration # Run all integration tests
make telemetry # Start OpenTelemetry collector and OpenObserve
make down # Stop all compose containers
```
To run only the integration tests relevant to your change:
@@ -60,4 +58,17 @@ Before opening a PR, verify:
- **One thing per PR.** Keep features, bug fixes, and refactoring in separate PRs.
- **Backward compatibility matters.** Do not rename or remove existing CLI flags, environment variables, or API form fields without discussion.
- **Integration tests first.** When adding a feature or route, start by writing the Gherkin scenario in `test/integration/features/`.
- **Logging uses `gotenberg.Logger(mod)`** with context-aware calls (`*Context` variants).
- **External operations have OTEL traces** with appropriate SpanKind and semconv attributes.
- **No business logic in `cmd/`.** All logic belongs in `pkg/`.
## Detailed Guidelines
The [`AGENTS.md`](AGENTS.md) files contain comprehensive guidelines used by both human contributors and AI-assisted tools:
| File | What it covers |
| ---------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------- |
| [`AGENTS.md`](AGENTS.md) | Core principles, mandatory workflow, project layout, coding patterns, module system, Makefile reference, review checklist |
| [`test/integration/AGENTS.md`](test/integration/AGENTS.md) | Integration test framework (Godog/Gherkin), available tags, step reference, how to write new tests |
| [`.bruno/AGENTS.md`](.bruno/AGENTS.md) | Bruno API collection structure, `.bru` file format, conventions, route update checklist |
| [`pkg/modules/pdfengines/AGENTS.md`](pkg/modules/pdfengines/AGENTS.md) | How to add new PDF engine features (Makefile variable and flag) |

130
Makefile
View File

@@ -20,19 +20,23 @@ API_BIND_IP=
API_START_TIMEOUT=30s
API_TIMEOUT=30s
API_BODY_LIMIT=
API_ROOT_PATH="/"
API_TRACE_HEADER=Gotenberg-Trace
API_ROOT_PATH=/
API_CORRELATION_ID_HEADER=Gotenberg-Trace
API_ENABLE_BASIC_AUTH=false
GOTENBERG_API_BASIC_AUTH_USERNAME=
GOTENBERG_API_BASIC_AUTH_PASSWORD=
API-DOWNLOAD-FROM-ALLOW-LIST=
API-DOWNLOAD-FROM-DENY-LIST=
API-DOWNLOAD-FROM-FROM-MAX-RETRY=4
API-DISABLE-DOWNLOAD-FROM=false
API_DISABLE_HEALTH_CHECK_LOGGING=false
API_DOWNLOAD_FROM_ALLOW_LIST=
API_DOWNLOAD_FROM_DENY_LIST=
API_DOWNLOAD_FROM_MAX_RETRY=4
API_DISABLE_DOWNLOAD_FROM=false
API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY=true
API_DISABLE_ROOT_ROUTE_TELEMETRY=true
API_DISABLE_DEBUG_ROUTE_TELEMETRY=true
API_DISABLE_VERSION_ROUTE_TELEMETRY=true
API_ENABLE_DEBUG_ROUTE=false
CHROMIUM_RESTART_AFTER=100
CHROMIUM_MAX_QUEUE_SIZE=0
CHROMIUM_IDLE_SHUTDOWN_TIMEOUT=0
CHROMIUM_MAX_CONCURRENCY=6
CHROMIUM_AUTO_START=false
CHROMIUM_START_TIMEOUT=20s
@@ -50,13 +54,14 @@ CHROMIUM_DISABLE_JAVASCRIPT=false
CHROMIUM_DISABLE_ROUTES=false
LIBREOFFICE_RESTART_AFTER=10
LIBREOFFICE_MAX_QUEUE_SIZE=0
LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT=0
LIBREOFFICE_AUTO_START=false
LIBREOFFICE_START_TIMEOUT=20s
LIBREOFFICE_DISABLE_ROUTES=false
LOG_LEVEL=info
LOG_FORMAT=auto
LOG_FIELDS_PREFIX=
LOG_ENABLE_GCP_FIELDS=false
LOG_STD_FORMAT=auto
LOG_STD_ENABLE_GCP_FIELDS=false
PDFENGINES_DISABLE_ROUTES=false
PDFENGINES_MERGE_ENGINES=qpdf,pdfcpu,pdftk
PDFENGINES_SPLIT_ENGINES=pdfcpu,qpdf,pdftk
@@ -74,8 +79,16 @@ PDFENGINES_EMBED_ENGINES=pdfcpu
PROMETHEUS_NAMESPACE=gotenberg
PROMETHEUS_COLLECT_INTERVAL=1s
PROMETHEUS_DISABLE_ROUTE_LOGGING=false
PROMETHEUS_DISABLE_ROUTE_TELEMETRY=true
PROMETHEUS_DISABLE_COLLECT=false
PROMETHEUS_METRICS_PATH=/prometheus/metrics
OTEL_SERVICE_NAME=gotenberg
OTEL_TRACES_EXPORTER=none
OTEL_METRICS_EXPORTER=none
OTEL_LOGS_EXPORTER=none
OTEL_EXPORTER_OTLP_PROTOCOL=grpc
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4317
OTEL_EXPORTER_OTLP_INSECURE=true
WEBHOOK_ENABLE_SYNC_MODE=false
WEBHOOK_ALLOW_LIST=
WEBHOOK_DENY_LIST=
@@ -87,88 +100,20 @@ WEBHOOK_RETRY_MAX_WAIT=30s
WEBHOOK_CLIENT_TIMEOUT=30s
WEBHOOK_DISABLE=false
# Export all variables so they are available to Compose
export
.PHONY: run
run: ## Start a Gotenberg container
docker run --rm -it \
-p $(API_PORT):$(API_PORT) \
-e GOTENBERG_API_BASIC_AUTH_USERNAME=$(GOTENBERG_API_BASIC_AUTH_USERNAME) \
-e GOTENBERG_API_BASIC_AUTH_PASSWORD=$(GOTENBERG_API_BASIC_AUTH_PASSWORD) \
-e TZ=$(TZ) \
$(DOCKER_REGISTRY)/$(DOCKER_REPOSITORY):$(GOTENBERG_VERSION) \
gotenberg \
--gotenberg-hide-banner=$(GOTENBERG_HIDE_BANNER) \
--gotenberg-graceful-shutdown-duration=$(GOTENBERG_GRACEFUL_SHUTDOWN_DURATION) \
--gotenberg-build-debug-data="$(GOTENBERG_BUILD_DEBUG_DATA)" \
--api-port=$(API_PORT) \
--api-port-from-env=$(API_PORT_FROM_ENV) \
--api-bind-ip=$(API_BIND_IP) \
--api-start-timeout=$(API_START_TIMEOUT) \
--api-timeout=$(API_TIMEOUT) \
--api-body-limit="$(API_BODY_LIMIT)" \
--api-root-path=$(API_ROOT_PATH) \
--api-trace-header=$(API_TRACE_HEADER) \
--api-enable-basic-auth=$(API_ENABLE_BASIC_AUTH) \
--api-download-from-allow-list=$(API-DOWNLOAD-FROM-ALLOW-LIST) \
--api-download-from-deny-list=$(API-DOWNLOAD-FROM-DENY-LIST) \
--api-download-from-max-retry=$(API-DOWNLOAD-FROM-FROM-MAX-RETRY) \
--api-disable-download-from=$(API-DISABLE-DOWNLOAD-FROM) \
--api-disable-health-check-logging=$(API_DISABLE_HEALTH_CHECK_LOGGING) \
--api-enable-debug-route=$(API_ENABLE_DEBUG_ROUTE) \
--chromium-restart-after=$(CHROMIUM_RESTART_AFTER) \
--chromium-auto-start=$(CHROMIUM_AUTO_START) \
--chromium-max-queue-size=$(CHROMIUM_MAX_QUEUE_SIZE) \
--chromium-max-concurrency=$(CHROMIUM_MAX_CONCURRENCY) \
--chromium-start-timeout=$(CHROMIUM_START_TIMEOUT) \
--chromium-allow-insecure-localhost=$(CHROMIUM_ALLOW_INSECURE_LOCALHOST) \
--chromium-ignore-certificate-errors=$(CHROMIUM_IGNORE_CERTIFICATE_ERRORS) \
--chromium-disable-web-security=$(CHROMIUM_DISABLE_WEB_SECURITY) \
--chromium-allow-file-access-from-files=$(CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES) \
--chromium-host-resolver-rules=$(CHROMIUM_HOST_RESOLVER_RULES) \
--chromium-proxy-server=$(CHROMIUM_PROXY_SERVER) \
--chromium-allow-list="$(CHROMIUM_ALLOW_LIST)" \
--chromium-deny-list="$(CHROMIUM_DENY_LIST)" \
--chromium-clear-cache=$(CHROMIUM_CLEAR_CACHE) \
--chromium-clear-cookies=$(CHROMIUM_CLEAR_COOKIES) \
--chromium-disable-javascript=$(CHROMIUM_DISABLE_JAVASCRIPT) \
--chromium-disable-routes=$(CHROMIUM_DISABLE_ROUTES) \
--libreoffice-restart-after=$(LIBREOFFICE_RESTART_AFTER) \
--libreoffice-max-queue-size=$(LIBREOFFICE_MAX_QUEUE_SIZE) \
--libreoffice-auto-start=$(LIBREOFFICE_AUTO_START) \
--libreoffice-start-timeout=$(LIBREOFFICE_START_TIMEOUT) \
--libreoffice-disable-routes=$(LIBREOFFICE_DISABLE_ROUTES) \
--log-level=$(LOG_LEVEL) \
--log-format=$(LOG_FORMAT) \
--log-fields-prefix=$(LOG_FIELDS_PREFIX) \
--log-enable-gcp-fields=$(LOG_ENABLE_GCP_FIELDS) \
--pdfengines-disable-routes=$(PDFENGINES_DISABLE_ROUTES) \
--pdfengines-merge-engines=$(PDFENGINES_MERGE_ENGINES) \
--pdfengines-split-engines=$(PDFENGINES_SPLIT_ENGINES) \
--pdfengines-flatten-engines=$(PDFENGINES_FLATTEN_ENGINES) \
--pdfengines-convert-engines=$(PDFENGINES_CONVERT_ENGINES) \
--pdfengines-read-metadata-engines=$(PDFENGINES_READ_METADATA_ENGINES) \
--pdfengines-write-metadata-engines=$(PDFENGINES_WRITE_METADATA_ENGINES) \
--pdfengines-read-bookmarks-engines=$(PDFENGINES_READ_BOOKMARKS_ENGINES) \
--pdfengines-write-bookmarks-engines=$(PDFENGINES_WRITE_BOOKMARKS_ENGINES) \
--pdfengines-watermark-engines=$(PDFENGINES_WATERMARK_ENGINES) \
--pdfengines-stamp-engines=$(PDFENGINES_STAMP_ENGINES) \
--pdfengines-encrypt-engines=$(PDFENGINES_ENCRYPT_ENGINES) \
--pdfengines-rotate-engines=$(PDFENGINES_ROTATE_ENGINES) \
--pdfengines-embed-engines=$(PDFENGINES_EMBED_ENGINES) \
--prometheus-namespace=$(PROMETHEUS_NAMESPACE) \
--prometheus-collect-interval=$(PROMETHEUS_COLLECT_INTERVAL) \
--prometheus-disable-route-logging=$(PROMETHEUS_DISABLE_ROUTE_LOGGING) \
--prometheus-disable-collect=$(PROMETHEUS_DISABLE_COLLECT) \
--prometheus-metrics-path=$(PROMETHEUS_METRICS_PATH) \
--webhook-enable-sync-mode="$(WEBHOOK_ENABLE_SYNC_MODE)" \
--webhook-allow-list="$(WEBHOOK_ALLOW_LIST)" \
--webhook-deny-list="$(WEBHOOK_DENY_LIST)" \
--webhook-error-allow-list=$(WEBHOOK_ERROR_ALLOW_LIST) \
--webhook-error-deny-list=$(WEBHOOK_ERROR_DENY_LIST) \
--webhook-max-retry=$(WEBHOOK_MAX_RETRY) \
--webhook-retry-min-wait=$(WEBHOOK_RETRY_MIN_WAIT) \
--webhook-retry-max-wait=$(WEBHOOK_RETRY_MAX_WAIT) \
--webhook-client-timeout=$(WEBHOOK_CLIENT_TIMEOUT) \
--webhook-disable=$(WEBHOOK_DISABLE)
run: ## Start a Gotenberg container via Compose
docker compose up gotenberg
.PHONY: telemetry
telemetry: ## Start an OpenTelemetry collector and OpenObserve containers via Compose
docker compose up otel-collector openobserve
.PHONY: down
down: ## Stop all containers
docker compose down -v
.PHONY: test-unit
test-unit: ## Run unit tests
@@ -182,6 +127,9 @@ NO_CONCURRENCY=false
# chromium-convert-html
# chromium-convert-markdown
# chromium-convert-url
# chromium-screenshot-html
# chromium-screenshot-markdown
# chromium-screenshot-url
# debug
# health
# libreoffice
@@ -217,7 +165,7 @@ NO_CONCURRENCY=false
TAGS=
.PHONY: test-integration
test-integration: ## Run integration tests
test-integration: ## Run integration tests (automatically retries failed scenarios up to 3 times)
go test -timeout 40m -tags=integration -v github.com/gotenberg/gotenberg/v8/test/integration -args \
--gotenberg-docker-repository=$(DOCKER_REPOSITORY) \
--gotenberg-version=$(GOTENBERG_VERSION) \

View File

@@ -1,7 +1,7 @@
<p align="center">
<img src="https://user-images.githubusercontent.com/8983173/130322857-185831e2-f041-46eb-a17f-0a69d066c4e5.png" alt="Gotenberg Logo" width="150" height="150" />
<h3 align="center">Gotenberg</h3>
<p align="center">A containerized API for seamless PDF conversion</p>
<p align="center">A Docker-based API for converting documents to PDF</p>
<p align="center">
<a href="https://hub.docker.com/r/gotenberg/gotenberg"><img alt="Total downloads (gotenberg/gotenberg)" src="https://img.shields.io/docker/pulls/gotenberg/gotenberg"></a>
<a href="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml"><img alt="Continuous Integration" src="https://github.com/gotenberg/gotenberg/actions/workflows/continuous-integration.yml/badge.svg"></a>
@@ -10,33 +10,54 @@
<p align="center">
<a href="https://trendshift.io/repositories/2996"><img src="https://trendshift.io/api/badge/repositories/2996" alt="gotenberg%2Fgotenberg | Trendshift" style="width: 250px; height: 55px;" width="250" height="55"/></a>
</p>
<p align="center"><a href="https://gotenberg.dev/docs/getting-started/introduction">Read the Documentation</a> &#183; <a href="https://gotenberg.dev/docs/getting-started/installation#live-demo-">Try the Live Demo</a> 🔥</p>
<p align="center">
<a href="https://gotenberg.dev/docs/getting-started/introduction"><strong>Documentation</strong></a> &#183;
<a href="https://gotenberg.dev/docs/getting-started/installation#live-demo"><strong>Live Demo</strong></a> &#183;
<a href="https://github.com/gotenberg/gotenberg/discussions"><strong>Community</strong></a>
</p>
</p>
---
**Gotenberg** is a containerized API that abstracts the complexity of PDF conversion.
**Gotenberg** is a Docker-based API for converting documents to PDF. Trusted in production by thousands of companies. Also adopted by notable open-source projects.
It provides a `multipart/form-data` interface for interacting with powerful engines like Chromium and LibreOffice.
Instead of managing heavy dependencies, browser versions, or fonts in your own backend, simply send your files to
Gotenberg and get a PDF in return.
Send your files via `multipart/form-data`, get a PDF back. No need to manage Chromium, LibreOffice, or fonts yourself.
## Quick Start
Open a terminal and run the following command:
```bash
docker run --rm -p 3000:3000 gotenberg/gotenberg:8
```
With the API running at `http://localhost:3000`, you are now ready to head
to the **[Full Documentation](https://gotenberg.dev/docs/getting-started/introduction)** to discover how to convert URLs,
local files, inject custom CSS, merge PDFs, and more.
Convert a URL to PDF:
```bash
curl \
--request POST http://localhost:3000/forms/chromium/convert/url \
--form url=https://sparksuite.github.io/simple-html-invoice-template/ \
-o invoice.pdf
```
## Features
- **HTML, URL, Markdown to PDF** via Headless Chromium
- **Office documents to PDF** via LibreOffice (100+ formats)
- **Merge, split, rotate, flatten** PDFs
- **Watermark, stamp, encrypt** PDFs
- **PDF/A and PDF/UA** compliance
- **Screenshots** of URLs and HTML
- **Read/write metadata and bookmarks**
See the [full documentation](https://gotenberg.dev/docs/getting-started/introduction).
## Contributing
Questions and feedback: [GitHub Discussions](https://github.com/gotenberg/gotenberg/discussions).
Bug reports: [GitHub Issues](https://github.com/gotenberg/gotenberg/issues).
## Sponsors
Open-source development takes a significant amount of time, energy, and dedication. If Gotenberg helps streamline your
workflow or powers your business, please consider supporting its continuous improvement by [**becoming a sponsor**](https://github.com/sponsors/gulien)! ❤️
If Gotenberg powers your workflow or your business, consider [**becoming a sponsor**](https://github.com/sponsors/gulien).
**Historic & GitHub Sponsors**

View File

@@ -277,6 +277,11 @@ ENV QPDF_BIN_PATH=/usr/bin/qpdf
ENV EXIFTOOL_BIN_PATH=/usr/bin/exiftool
ENV PDFCPU_BIN_PATH=/usr/bin/pdfcpu
# OpenTelemetry defaults (noop - no telemetry overhead unless explicitly enabled).
ENV OTEL_TRACES_EXPORTER=none
ENV OTEL_METRICS_EXPORTER=none
ENV OTEL_LOGS_EXPORTER=none
USER gotenberg
WORKDIR /home/gotenberg

View File

@@ -25,7 +25,7 @@ const banner = `
\___/\___/\__/\__/_//_/_.__/\__/_/ \_, /
/___/
A containerized API for seamless PDF conversion.
A Docker-based API for converting documents to PDF.
Version: %s
-------------------------------------------------------
`
@@ -44,6 +44,24 @@ func Run() {
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
// Logging & telemetry flags.
fs.String("log-level", gotenberg.InfoLoggingLevel, "Set the log level")
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each log field key")
fs.String("log-std-format", gotenberg.AutoLoggingFormat, "Set the log format for standard output")
fs.Bool("log-std-enable-gcp-fields", false, "Use GCP-compatible field names in log output")
// Deprecated logging flags.
fs.String("log-format", gotenberg.AutoLoggingFormat, "Set the log format")
fs.Bool("log-enable-gcp-fields", false, "Use GCP-compatible field names")
if err := errors.Join(
fs.MarkDeprecated("log-format", "use --log-std-format instead"),
fs.MarkDeprecated("log-enable-gcp-fields", "use --log-std-enable-gcp-fields instead"),
); err != nil {
fmt.Printf("[FATAL] mark deprecated flags: %s\n", err)
os.Exit(1)
}
descriptors := gotenberg.GetModuleDescriptors()
var modsInfo strings.Builder
for _, desc := range descriptors {
@@ -76,10 +94,11 @@ func Run() {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
}
f.Changed = true
return
}
err = f.Value.Set(val)
err = fs.Set(f.Name, val)
if err != nil {
fmt.Printf("[FATAL] invalid overriding value '%s' from %s: %v\n", val, envName, err)
os.Exit(1)
@@ -91,6 +110,35 @@ func Run() {
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
// Initialize telemetry (logging + OTEL).
serviceName := os.Getenv("OTEL_SERVICE_NAME")
if serviceName == "" {
serviceName = "gotenberg"
}
telemetryCfg := gotenberg.TelemetryConfig{
ServiceName: serviceName,
ServiceVersion: Version,
LogLevel: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
}
// LogLevel uses its own flag, not the format flag.
telemetryCfg.LogLevel = parsedFlags.MustString("log-level")
err = telemetryCfg.Validate()
if err != nil {
fmt.Printf("[FATAL] invalid telemetry config: %s\n", err)
os.Exit(1)
}
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryCfg)
if err != nil {
fmt.Printf("[FATAL] start telemetry: %s\n", err)
os.Exit(1)
}
if !hideBanner {
fmt.Printf(banner, Version)
}
@@ -190,8 +238,17 @@ func Run() {
err = eg.Wait()
if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1)
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
}
// Shutdown telemetry (flush spans, metrics, logs).
err = shutdownTelemetry(gracefulShutdownCtx)
if err != nil {
cancel()
fmt.Printf("[FATAL] %v\n", err)
os.Exit(1) //nolint:gocritic // defers are already called explicitly above
}
os.Exit(0)

122
compose.yaml Normal file
View File

@@ -0,0 +1,122 @@
services:
gotenberg:
image: ${DOCKER_REGISTRY}/${DOCKER_REPOSITORY}:${GOTENBERG_VERSION}
ports:
- "${API_PORT}:${API_PORT}"
environment:
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_API_BASIC_AUTH_USERNAME}
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_API_BASIC_AUTH_PASSWORD}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME}
OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER}
OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER}
OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE}
command:
- "gotenberg"
- "--gotenberg-hide-banner=${GOTENBERG_HIDE_BANNER}"
- "--gotenberg-graceful-shutdown-duration=${GOTENBERG_GRACEFUL_SHUTDOWN_DURATION}"
- "--gotenberg-build-debug-data=${GOTENBERG_BUILD_DEBUG_DATA}"
- "--api-port=${API_PORT}"
- "--api-port-from-env=${API_PORT_FROM_ENV}"
- "--api-bind-ip=${API_BIND_IP}"
- "--api-start-timeout=${API_START_TIMEOUT}"
- "--api-timeout=${API_TIMEOUT}"
- "--api-body-limit=${API_BODY_LIMIT}"
- "--api-root-path=${API_ROOT_PATH}"
- "--api-correlation-id-header=${API_CORRELATION_ID_HEADER}"
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_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-idle-shutdown-timeout=${CHROMIUM_IDLE_SHUTDOWN_TIMEOUT}"
- "--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-idle-shutdown-timeout=${LIBREOFFICE_IDLE_SHUTDOWN_TIMEOUT}"
- "--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-route-telemetry=${PROMETHEUS_DISABLE_ROUTE_TELEMETRY}"
- "--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

46
go.mod
View File

@@ -5,8 +5,8 @@ go 1.26.0
require (
github.com/alexliesenfeld/health v0.8.1
github.com/barasher/go-exiftool v1.10.0
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d
github.com/chromedp/chromedp v0.14.2
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc
github.com/chromedp/chromedp v0.15.1
github.com/cucumber/godog v0.15.1
github.com/dlclark/regexp2 v1.11.5
github.com/docker/docker v28.5.2+incompatible
@@ -21,9 +21,17 @@ require (
github.com/prometheus/client_golang v1.23.2
github.com/shirou/gopsutil/v4 v4.26.2
github.com/spf13/pflag v1.0.10
github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.41.0
go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1
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
golang.org/x/net v0.52.0
golang.org/x/sync v0.20.0
golang.org/x/term v0.41.0
@@ -67,14 +75,15 @@ require (
github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect
github.com/hashicorp/golang-lru v1.0.2 // indirect
github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect
github.com/klauspost/compress v1.18.4 // indirect
github.com/klauspost/compress v1.18.5 // indirect
github.com/klauspost/pgzip v1.2.6 // indirect
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 // indirect
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 // indirect
github.com/magiconair/properties v1.8.10 // indirect
github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
@@ -82,7 +91,7 @@ require (
github.com/minio/minlz v1.1.0 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect
github.com/moby/patternmatcher v0.6.1 // indirect
github.com/moby/sys/sequential v0.6.0 // indirect
github.com/moby/sys/user v0.4.0 // indirect
github.com/moby/sys/userns v0.1.0 // indirect
@@ -98,11 +107,11 @@ require (
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect
@@ -110,17 +119,28 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.10.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect
golang.org/x/sys v0.42.0 // indirect
golang.org/x/time v0.15.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 // indirect
google.golang.org/grpc v1.79.3 // indirect
google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)

89
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/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d h1:ZtA1sedVbEW7EW80Iz2GR3Ye6PwbJAJXjv7D74xG6HU=
github.com/chromedp/cdproto v0.0.0-20250803210736-d308e07a266d/go.mod h1:NItd7aLkcfOA/dcMXvl8p1u+lQqioRMq/SqDp71Pb/k=
github.com/chromedp/chromedp v0.14.2 h1:r3b/WtwM50RsBZHMUm9fsNhhzRStTHrKdr2zmwbZSzM=
github.com/chromedp/chromedp v0.14.2/go.mod h1:rHzAv60xDE7VNy/MYtTUrYreSc0ujt2O1/C3bzctYBo=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc h1:wkN/LMi5vc60pBRWx6qpbk/aEvq3/ZVNpnMvsw8PVVU=
github.com/chromedp/cdproto v0.0.0-20260321001828-e3e3800016bc/go.mod h1:cbyjALe67vDvlvdiG9369P8w5U2w6IshwtyD2f2Tvag=
github.com/chromedp/chromedp v0.15.1 h1:EJWiPm7BNqDqjYy6U0lTSL5wNH+iNt9GjC3a4gfjNyQ=
github.com/chromedp/chromedp v0.15.1/go.mod h1:CdTHtUqD/dqaFw/cvFWtTydoEQS44wLBuwbMR9EkOY4=
github.com/chromedp/sysutil v1.1.0 h1:PUFNv5EcprjqXZD9nJb9b/c9ibAbxiYo4exNWZyipwM=
github.com/chromedp/sysutil v1.1.0/go.mod h1:WiThHUdltqCNKGc4gaU50XgYjwjYIhKWoHGPTUfWTJ8=
github.com/containerd/errdefs v1.0.0 h1:tg5yIfIlQIrxYtu9ajqY42W3lpS19XqdxRQeEwYG8PI=
@@ -98,6 +98,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -107,8 +109,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -132,8 +134,8 @@ github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs
github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
github.com/klauspost/compress v1.4.1/go.mod h1:RyIbtBH6LamlWaDj8nUwkbUhJ87Yi3uG0guNDohfE1A=
github.com/klauspost/compress v1.18.4 h1:RPhnKRAQ4Fh8zU2FY/6ZFDwTVTxgJ/EMydqSTzE9a2c=
github.com/klauspost/compress v1.18.4/go.mod h1:R0h/fSBs8DE4ENlcrlib3PsXS61voFxhIs2DeRhCvJ4=
github.com/klauspost/compress v1.18.5 h1:/h1gH5Ce+VWNLSWqPzOVn6XBO+vJbCNGvjoaGBFW2IE=
github.com/klauspost/compress v1.18.5/go.mod h1:cwPg85FWrGar70rWktvGQj8/hthj3wpl0PGDogxkrSQ=
github.com/klauspost/cpuid v1.2.0/go.mod h1:Pj4uuM528wm8OyEC2QMXAi2YiTZ96dNQPGgoMS4s3ek=
github.com/klauspost/pgzip v1.2.6 h1:8RXeL5crjEUFnR2/Sn6GJNWtSQ3Dk8pq4CL3jvdDyjU=
github.com/klauspost/pgzip v1.2.6/go.mod h1:Ch1tH69qFZu15pkjo5kYi6mth2Zzwzt50oCQKQE9RUs=
@@ -152,8 +154,8 @@ github.com/labstack/gommon v0.4.2 h1:F8qTUNXgG1+6WQmqoUWnz8WiEU60mXVVw0P4ht1WRA0
github.com/labstack/gommon v0.4.2/go.mod h1:QlUFxVM+SNXhDL/Z7YhocGIBYOiwB0mXm1+1bAPHPyU=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80 h1:6Yzfa6GP0rIo/kULo2bwGEkFvCePZ3qHDDTC3/J9Swo=
github.com/ledongthuc/pdf v0.0.0-20220302134840-0c2507a12d80/go.mod h1:imJHygn/1yfhB7XSJJKlFZKl/J+dCPAknuiaGOshXAs=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88 h1:PTw+yKnXcOFCR6+8hHTyWBeQ/P4Nb7dd4/0ohEcWQuM=
github.com/lufia/plan9stats v0.0.0-20260216142805-b3301c5f2a88/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749 h1:Qj3hTcdWH8uMZDI41HNuTuJN525C7NBrbtH5kSO6fPk=
github.com/lufia/plan9stats v0.0.0-20260324052639-156f7da3f749/go.mod h1:autxFIvghDt3jPTLoqZ9OZ7s9qTGNAWmYCjVFWPX/zg=
github.com/magiconair/properties v1.8.10 h1:s31yESBquKXCV9a/ScB3ESkOjUYYv+X0rg8SYxI99mE=
github.com/magiconair/properties v1.8.10/go.mod h1:Dhd985XPs7jluiymwWYZ0G4Z61jb3vdS329zhj2hYo0=
github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE=
@@ -172,8 +174,8 @@ github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3N
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
github.com/moby/go-archive v0.2.0/go.mod h1:mNeivT14o8xU+5q1YnNrkQVpK+dnNe/K6fHqnTg4qPU=
github.com/moby/patternmatcher v0.6.0 h1:GmP9lR19aU5GqSSFko+5pRqHi+Ohk1O69aFiKkVGiPk=
github.com/moby/patternmatcher v0.6.0/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/patternmatcher v0.6.1 h1:qlhtafmr6kgMIJjKJMDmMWq7WLkKIo23hsrpR3x084U=
github.com/moby/patternmatcher v0.6.1/go.mod h1:hDPoyOpDY7OrrMDLaYoY3hf52gNCR/YOUYxkhApJIxc=
github.com/moby/sys/atomicwriter v0.1.0 h1:kw5D/EqkBwsBFi0ss9v1VG3wIkVhzGvLklJ+w3A14Sw=
github.com/moby/sys/atomicwriter v0.1.0/go.mod h1:Ul8oqv2ZMNHOceF643P6FKPXeCmYtlQMvpizfsSoaWs=
github.com/moby/sys/sequential v0.6.0 h1:qrx7XFUd/5DxtqcoH1h438hF5TmOvzC/lspjy7zgvCU=
@@ -210,6 +212,8 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
@@ -259,30 +263,56 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0 h1:NFIS6x7wyObQ7cR84x7bt1sr8nYBx89s3x3GwRjw40k=
go.opentelemetry.io/contrib/bridges/otelslog v0.17.0/go.mod h1:39SaByOyDMRMe872AE7uelMuQZidIw7LLFAnQi0FWTE=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE=
go.opentelemetry.io/proto/otlp v1.10.0 h1:IQRWgT5srOCYfiWnpqUYz9CVmbO8bFmKcwYxpuCSL2g=
go.opentelemetry.io/proto/otlp v1.10.0/go.mod h1:/CV4QoCR/S9yaPj8utp3lvQPoqMtxXdzn7ozvvozVqk=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E=
go.yaml.in/yaml/v2 v2.4.4 h1:tuyd0P+2Ont/d6e2rl3be67goVK4R6deVxCUX5vyPaQ=
go.yaml.in/yaml/v2 v2.4.4/go.mod h1:gMZqIpDtDqOfM0uNfy0SkpRhvUryYH0Z6wdMYcacYXQ=
go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
@@ -307,13 +337,14 @@ golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U=
gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7 h1:41r6JMbpzBMen0R/4TZeeAmGXSJC7DftGINUodzTkPI=
google.golang.org/genproto/googleapis/api v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:EIQZ5bFCfRQDV4MhRle7+OgjNtZ6P1PiZBgAKuxXu/Y=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7 h1:ndE4FoJqsIceKP2oYSnUZqhTdYufCYYkqwtFzfrhI7w=
google.golang.org/genproto/googleapis/rpc v0.0.0-20260319201613-d00831a3d3e7/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.79.3 h1:sybAEdRIEtvcD68Gx7dmnwjZKlyfuc61Dyo9pGXXkKE=
google.golang.org/grpc v1.79.3/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

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

View File

@@ -12,40 +12,57 @@ import (
// ErrFiltered happens if a value is filtered by the [FilterDeadline] function.
var ErrFiltered = errors.New("value filtered")
// FilterDeadline checks if given value is allowed and not denied according to
// regex patterns. It returns a [context.DeadlineExceeded] if it takes too long
// to process.
func FilterDeadline(allowed, denied *regexp2.Regexp, s string, deadline time.Time) error {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
if allowed.String() != "" {
allow := regexp2.MustCompile(allowed.String(), 0)
allow.MatchTimeout = time.Until(deadline)
// FilterDeadline checks if the given value is allowed and not denied according
// to regex patterns. The allowed list uses OR semantics (value must match at
// least one pattern). The denied list uses OR semantics (value is denied if it
// matches any pattern). It returns a [context.DeadlineExceeded] if it takes
// too long to process.
func FilterDeadline(allowed, denied []*regexp2.Regexp, s string, deadline time.Time) error {
if len(allowed) > 0 {
matched := false
ok, err := allow.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
for _, pattern := range allowed {
// FIXME: not ideal to compile everytime, but is there another way to create a clone?
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
matched = true
break
}
return fmt.Errorf("'%s' cannot handle '%s': %w", allow.String(), s, err)
}
if !ok {
return fmt.Errorf("'%s' does not match the expression from the allowed list: %w", s, ErrFiltered)
if !matched {
return fmt.Errorf("'%s' does not match any expression from the allowed list: %w", s, ErrFiltered)
}
}
if denied.String() != "" {
deny := regexp2.MustCompile(denied.String(), 0)
deny.MatchTimeout = time.Until(deadline)
if len(denied) > 0 {
for _, pattern := range denied {
clone := regexp2.MustCompile(pattern.String(), 0)
clone.MatchTimeout = time.Until(deadline)
ok, err := deny.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
ok, err := clone.MatchString(s)
if err != nil {
if time.Now().After(deadline) {
return context.DeadlineExceeded
}
return fmt.Errorf("'%s' cannot handle '%s': %w", clone.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
}
return fmt.Errorf("'%s' cannot handle '%s': %w", deny.String(), s, err)
}
if ok {
return fmt.Errorf("'%s' matches the expression from the denied list: %w", s, ErrFiltered)
}
}

View File

@@ -12,8 +12,8 @@ import (
func TestFilterDeadline(t *testing.T) {
for _, tc := range []struct {
scenario string
allowed *regexp2.Regexp
denied *regexp2.Regexp
allowed []*regexp2.Regexp
denied []*regexp2.Regexp
s string
deadline time.Time
expectError bool
@@ -21,17 +21,17 @@ func TestFilterDeadline(t *testing.T) {
}{
{
scenario: "DeadlineExceeded (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: nil,
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
expectedError: context.DeadlineExceeded,
},
{
scenario: "ErrFiltered (allowed)",
allowed: regexp2.MustCompile("foo", 0),
denied: regexp2.MustCompile("", 0),
scenario: "ErrFiltered (allowed, no match)",
allowed: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
denied: nil,
s: "bar",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
@@ -39,8 +39,8 @@ func TestFilterDeadline(t *testing.T) {
},
{
scenario: "DeadlineExceeded (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
s: "foo",
deadline: time.Now().Add(time.Duration(-1) * time.Hour),
expectError: true,
@@ -48,21 +48,55 @@ func TestFilterDeadline(t *testing.T) {
},
{
scenario: "ErrFiltered (denied)",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("foo", 0),
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("foo", 0)},
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "success",
allowed: regexp2.MustCompile("", 0),
denied: regexp2.MustCompile("", 0),
scenario: "success (empty lists)",
allowed: nil,
denied: nil,
s: "foo",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, second matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^file:///tmp/", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
{
scenario: "multi-pattern allow list, none matches",
allowed: []*regexp2.Regexp{regexp2.MustCompile("^https://", 0), regexp2.MustCompile("^ftp://", 0)},
denied: nil,
s: "file:///tmp/abc/index.html",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "multi-pattern deny list, second matches",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^ftp://", 0), regexp2.MustCompile("^file:.*", 0)},
s: "file:///etc/passwd",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: true,
expectedError: ErrFiltered,
},
{
scenario: "https URL passes deny list targeting file://",
allowed: nil,
denied: []*regexp2.Regexp{regexp2.MustCompile("^file:.*", 0)},
s: "https://example.com",
deadline: time.Now().Add(time.Duration(5) * time.Second),
expectError: false,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := FilterDeadline(tc.allowed, tc.denied, tc.s, tc.deadline)

View File

@@ -222,3 +222,32 @@ func (f *ParsedFlags) MustDeprecatedRegexp(deprecated string, newName string) *r
return f.MustRegexp(newName)
}
// MustRegexpSlice returns a slice of compiled regular expressions from a
// string-slice flag given by name. Empty strings are skipped.
// It panics if an error occurs.
func (f *ParsedFlags) MustRegexpSlice(name string) []*regexp2.Regexp {
vals := f.MustStringSlice(name)
var regexps []*regexp2.Regexp
for _, val := range vals {
if val == "" {
continue
}
regexps = append(regexps, regexp2.MustCompile(val, 0))
}
return regexps
}
// MustDeprecatedRegexpSlice returns the slice of compiled regular expressions
// of a deprecated flag if it was explicitly set or the slice of the new flag.
// It panics if an error occurs.
func (f *ParsedFlags) MustDeprecatedRegexpSlice(deprecated string, newName string) []*regexp2.Regexp {
if f.Changed(deprecated) {
return f.MustRegexpSlice(deprecated)
}
return f.MustRegexpSlice(newName)
}

View File

@@ -6,6 +6,7 @@ import (
"testing"
"time"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag"
)
@@ -833,3 +834,120 @@ func TestParsedFlags_MustDeprecatedRegexp(t *testing.T) {
})
}
}
func TestParsedFlags_MustRegexpSlice(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", []string{}, "")
err := fs.Parse([]string{"--foo=^file:.*", "--foo=^ftp://.*"})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags := ParsedFlags{FlagSet: fs}
for _, tc := range []struct {
scenario string
name string
expectPanic bool
expectLen int
}{
{
scenario: "success with multiple patterns",
name: "foo",
expectPanic: false,
expectLen: 2,
},
{
scenario: "non-existing flag",
name: "bar",
expectPanic: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
if tc.expectPanic {
defer func() {
if r := recover(); r == nil {
t.Fatal("expected panic but got none")
}
}()
}
if !tc.expectPanic {
defer func() {
if r := recover(); r != nil {
t.Fatalf("expected no panic but got: %v", r)
}
}()
}
result := parsedFlags.MustRegexpSlice(tc.name)
if !tc.expectPanic && len(result) != tc.expectLen {
t.Errorf("expected %d regexps but got %d", tc.expectLen, len(result))
}
})
}
// Test empty strings are skipped.
fs2 := flag.NewFlagSet("tests2", flag.ContinueOnError)
fs2.StringSlice("baz", []string{""}, "")
err = fs2.Parse([]string{})
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
parsedFlags2 := ParsedFlags{FlagSet: fs2}
result := parsedFlags2.MustRegexpSlice("baz")
if len(result) != 0 {
t.Errorf("expected 0 regexps for empty strings but got %d", len(result))
}
}
func TestParsedFlags_MustDeprecatedRegexpSlice(t *testing.T) {
for _, tc := range []struct {
scenario string
rawFlags []string
expectPattern string
}{
{
scenario: "deprecated flag value",
rawFlags: []string{"--foo=^file:.*"},
expectPattern: "^file:.*",
},
{
scenario: "non-deprecated flag value",
rawFlags: []string{"--bar=^ftp://.*"},
expectPattern: "^ftp://.*",
},
{
scenario: "deprecated flag value > non-deprecated flag value",
rawFlags: []string{"--foo=^file:.*", "--bar=^ftp://.*"},
expectPattern: "^file:.*",
},
} {
t.Run(tc.scenario, func(t *testing.T) {
fs := flag.NewFlagSet("tests", flag.ContinueOnError)
fs.StringSlice("foo", []string{}, "")
fs.StringSlice("bar", []string{}, "")
parsedFlags := ParsedFlags{FlagSet: fs}
err := parsedFlags.Parse(tc.rawFlags)
if err != nil {
t.Fatalf("expected no error but got: %v", err)
}
actual := parsedFlags.MustDeprecatedRegexpSlice("foo", "bar")
if len(actual) != 1 {
t.Fatalf("expected 1 regexp but got %d", len(actual))
}
if actual[0].String() != tc.expectPattern {
t.Errorf("expected pattern '%s' but got '%s'", tc.expectPattern, actual[0].String())
}
})
}
_ = regexp2.None // Keep import alive.
}

View File

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

View File

@@ -1,14 +1,15 @@
package gotenberg
import (
"context"
"fmt"
"log/slog"
"os"
"path"
"testing"
"time"
"github.com/google/uuid"
"go.uber.org/zap"
)
func TestGarbageCollect(t *testing.T) {
@@ -66,7 +67,7 @@ func TestGarbageCollect(t *testing.T) {
}
}()
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
err := GarbageCollect(context.Background(), slog.New(slog.DiscardHandler), tc.rootPath, tc.includeSubstr, time.Now())
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)

View File

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

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

View File

@@ -4,8 +4,7 @@ import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
"log/slog"
)
var (
@@ -160,57 +159,57 @@ type Bookmark struct {
type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
// Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten
// forms as well as forms share a relationship with annotations. Note that
// this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
// PageCount returns the number of pages in a PDF file.
PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
// WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document.
// The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword.
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
// TODO: attachments instead? Rename the route?
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
// Watermark applies a watermark (behind page content) to a PDF file.
Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Stamp applies a stamp (on top of page content) to a PDF file.
Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
// If pages is empty, all pages are rotated.
Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
}
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].

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,10 +4,10 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"sync/atomic"
"go.uber.org/zap"
"time"
)
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
@@ -27,15 +27,15 @@ var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
type Process interface {
// Start initiates the process and returns an error if the process cannot
// be started.
Start(logger *zap.Logger) error
Start(logger *slog.Logger) error
// Stop terminates the process and returns an error if the process cannot
// be stopped.
Stop(logger *zap.Logger) error
Stop(logger *slog.Logger) error
// Healthy checks the health of the process. It returns true if the process
// is healthy; otherwise, it returns false.
Healthy(logger *zap.Logger) bool
Healthy(logger *slog.Logger) bool
}
// ProcessSupervisor provides methods to manage a [Process], including
@@ -54,9 +54,9 @@ type ProcessSupervisor interface {
// Healthy checks and returns the health status of the managed [Process].
//
// If the process has not been started or is restarting, it is considered
// healthy and true is returned. Otherwise, it returns the health status of
// the actual process.
// A non-started process is considered healthy (startup is deferred until
// the first request). Returns false if the process is currently restarting
// or is reported unhealthy by the underlying [Process].
Healthy() bool
// Run executes a provided task while managing the state of the [Process].
@@ -66,46 +66,58 @@ type ProcessSupervisor interface {
//
// It returns an error if the task cannot be run or if the process state
// cannot be managed properly.
Run(ctx context.Context, logger *zap.Logger, task func() error) error
Run(ctx context.Context, logger *slog.Logger, task func() error) error
// ReqQueueSize returns the current size of the request queue.
ReqQueueSize() int64
// RestartsCount returns the current number of restart.
RestartsCount() int64
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
}
type processSupervisor struct {
logger *zap.Logger
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
firstStartErr error
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
logger *slog.Logger
process Process
maxReqLimit int64
maxQueueSize int64
maxConcurrency int64
semaphore chan struct{}
firstStart atomic.Bool
firstStartOnce sync.Once
// firstStartErr stores the error from the first Launch attempt executed
// via firstStartOnce. Subsequent callers that enter the !firstStart block
// need to observe this value after the Once has completed, without
// re-executing the closure.
firstStartErr error
reqCounter atomic.Int64
reqQueueSize atomic.Int64
restartsCounter atomic.Int64
isRestarting atomic.Bool
activeTasks atomic.Int64
restartMutex sync.Mutex
idleShutdownTimeout time.Duration
lastActivity atomic.Int64 // unix nano timestamp of last completed task
idleMu sync.Mutex // protects idleStopChan
idleStopChan chan struct{} // signal to stop the idle ticker goroutine
}
// 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, idleShutdownTimeout time.Duration) ProcessSupervisor {
if maxConcurrency < 1 {
maxConcurrency = 1
}
b := &processSupervisor{
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
logger: logger,
process: process,
semaphore: make(chan struct{}, maxConcurrency),
maxReqLimit: maxReqLimit,
maxQueueSize: maxQueueSize,
maxConcurrency: maxConcurrency,
idleShutdownTimeout: idleShutdownTimeout,
}
b.reqCounter.Store(0)
b.reqQueueSize.Store(0)
@@ -117,37 +129,47 @@ func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQ
}
func (s *processSupervisor) Launch() error {
s.logger.Debug("start process")
s.logger.DebugContext(context.Background(), "start process")
err := s.process.Start(s.logger)
if err != nil {
return fmt.Errorf("start process: %w", err)
}
s.firstStart.Store(true)
s.logger.Debug("process successfully started")
if s.idleShutdownTimeout > 0 {
s.lastActivity.Store(time.Now().UnixNano())
s.startIdleTicker()
}
s.logger.DebugContext(context.Background(), "process successfully started")
return nil
}
func (s *processSupervisor) Shutdown() error {
s.logger.Debug("shutdown process")
s.logger.DebugContext(context.Background(), "shutdown process")
s.stopIdleTicker()
err := s.process.Stop(s.logger)
if err != nil {
return fmt.Errorf("shutdown process: %w", err)
}
s.logger.Debug("process successfully shutdown")
s.logger.DebugContext(context.Background(), "process successfully shutdown")
return nil
}
func (s *processSupervisor) restart() error {
s.logger.Debug("restart process")
s.logger.DebugContext(context.Background(), "restart process")
err := s.Shutdown()
if err != nil {
// No big deal? Chances are it's already stopped.
s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err))
// Not necessarily critical — chances are the process is already stopped,
// but worth flagging in case it indicates a real issue.
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
}
err = s.Launch()
@@ -157,124 +179,91 @@ func (s *processSupervisor) restart() error {
s.reqCounter.Store(0)
s.restartsCounter.Add(1)
s.logger.Debug("process successfully restarted")
s.logger.DebugContext(context.Background(), "process successfully restarted")
return nil
}
func (s *processSupervisor) Healthy() bool {
if !s.firstStart.Load() {
// A non-started process is always healthy.
// A non-started process is considered healthy: Gotenberg defers
// process startup until the first request to keep resource usage low.
// Reporting unhealthy here would cause container orchestrators to
// restart the pod before any request arrives.
return true
}
if s.isRestarting.Load() {
// A restarting process is always healthy.
return true
// A restarting process is not yet healthy — this gives load balancers
// honest information so they can avoid routing traffic to this node.
return false
}
return s.process.Healthy(s.logger)
}
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
// A user reported a potential issue:
//
// "Although the counting operation is atomic, nothing prevent 2 concurrent
// goroutines to retrieve the same 'currentQueueSize' and to compare its
// value against the max limit. Then, resulting queue size would be 1 above
// the allowed limit."
//
// However, he was unable to actually trigger this issue, even when sending
// a lot of requests.
//
// For now, the best option is to consider this issue to be unlikely to
// happen, and keep the code as it is because it is more readable this way.
//
// See https://github.com/gotenberg/gotenberg/issues/951.
currentQueueSize := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && currentQueueSize >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
// Atomically check and increment the queue size to avoid the TOCTOU race
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
for {
current := s.reqQueueSize.Load()
if s.maxQueueSize > 0 && current >= s.maxQueueSize {
return ErrMaximumQueueSizeExceeded
}
if s.reqQueueSize.CompareAndSwap(current, current+1) {
break
}
}
s.reqQueueSize.Add(1)
// Decrement when Run() returns, regardless of which path is taken
// (context timeout, task completion, error, etc.). This ensures the
// request is counted as "in the queue" for the entire duration of Run(),
// preventing new requests from entering while one is being processed.
// See https://github.com/gotenberg/gotenberg/issues/1502.
defer s.reqQueueSize.Add(-1)
for {
err := func() error {
select {
case s.semaphore <- struct{}{}:
logger.Debug("process lock acquired")
// If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead.
if s.isRestarting.Load() {
<-s.semaphore
return ErrProcessAlreadyRestarting
}
s.reqQueueSize.Add(-1)
s.reqCounter.Add(1)
s.activeTasks.Add(1)
releaseSemaphore := true
defer func() {
s.activeTasks.Add(-1)
if releaseSemaphore {
logger.Debug("process lock released")
<-s.semaphore
}
}()
if !s.firstStart.Load() {
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
}
}
if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
err := s.doRestart(ctx)
if err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
}
err := s.runWithDeadline(ctx, task)
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
// Only one goroutine should trigger the restart.
if s.restartMutex.TryLock() {
s.logger.Debug("max request limit reached, restarting eagerly...")
releaseSemaphore = false
go func() {
restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
}
logger.Debug("process lock released")
<-s.semaphore
}()
}
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
if err := s.acquireSlot(ctx, logger); err != nil {
return err
case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1)
return fmt.Errorf("acquire process lock: %w", ctx.Err())
}
s.reqCounter.Add(1)
s.activeTasks.Add(1)
semaphoreOwned := true
defer func() {
s.activeTasks.Add(-1)
if s.idleShutdownTimeout > 0 {
s.lastActivity.Store(time.Now().UnixNano())
}
if semaphoreOwned {
logger.DebugContext(ctx, "process lock released")
<-s.semaphore
}
}()
if err := s.ensureStarted(ctx); err != nil {
return err
}
if err := s.ensureHealthy(ctx); err != nil {
return err
}
err := s.runWithDeadline(ctx, task)
if s.maybeRestartAfterTask(logger) {
semaphoreOwned = false
}
// Note: no error wrapping because it leaks on Chromium console exceptions output.
return err
}()
if errors.Is(err, ErrProcessAlreadyRestarting) {
logger.Debug("process is already restarting, trying to acquire process lock again...")
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
time.Sleep(10 * time.Millisecond)
continue
}
@@ -283,6 +272,174 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
}
}
// startIdleTicker starts a background goroutine that periodically checks
// whether the process has been idle long enough to shut down.
func (s *processSupervisor) startIdleTicker() {
stopChan := make(chan struct{})
s.idleMu.Lock()
s.idleStopChan = stopChan
s.idleMu.Unlock()
go func() {
ticker := time.NewTicker(s.idleShutdownTimeout)
defer ticker.Stop()
for {
select {
case <-ticker.C:
s.maybeIdleShutdown()
case <-stopChan:
return
}
}
}()
}
// stopIdleTicker signals the idle ticker goroutine to exit, if one is running.
func (s *processSupervisor) stopIdleTicker() {
s.idleMu.Lock()
defer s.idleMu.Unlock()
if s.idleStopChan != nil {
close(s.idleStopChan)
s.idleStopChan = nil
}
}
// maybeIdleShutdown stops the process if it has been idle for longer than
// the configured timeout. It is safe to call concurrently with Run and
// restart.
func (s *processSupervisor) maybeIdleShutdown() {
if !s.firstStart.Load() || s.isRestarting.Load() {
return
}
if s.activeTasks.Load() > 0 || s.reqQueueSize.Load() > 0 {
return
}
lastNano := s.lastActivity.Load()
if lastNano == 0 || time.Since(time.Unix(0, lastNano)) < s.idleShutdownTimeout {
return
}
if !s.restartMutex.TryLock() {
return
}
defer s.restartMutex.Unlock()
// Double-check after acquiring the lock.
if s.activeTasks.Load() > 0 || s.reqQueueSize.Load() > 0 {
return
}
s.logger.InfoContext(context.Background(), "idle shutdown timeout reached, stopping process")
// Stop the ticker — it will be restarted on the next Launch().
s.stopIdleTicker()
err := s.process.Stop(s.logger)
if err != nil {
s.logger.WarnContext(context.Background(), fmt.Sprintf("idle shutdown: %s", err))
return
}
// Reset state so ensureStarted() re-launches on next request.
s.firstStart.Store(false)
s.firstStartOnce = sync.Once{}
s.firstStartErr = nil
s.reqCounter.Store(0)
s.logger.InfoContext(context.Background(), "process stopped due to idle timeout")
}
// acquireSlot attempts to acquire a semaphore slot, yielding it back if a
// restart drain is in progress.
func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger) error {
select {
case s.semaphore <- struct{}{}:
// If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead.
if s.isRestarting.Load() {
<-s.semaphore
return ErrProcessAlreadyRestarting
}
logger.DebugContext(ctx, "process lock acquired")
return nil
case <-ctx.Done():
logger.DebugContext(ctx, "failed to acquire process lock before deadline")
return fmt.Errorf("acquire process lock: %w", ctx.Err())
}
}
// ensureStarted performs a one-time lazy launch of the process on its first
// use. Subsequent calls are no-ops.
func (s *processSupervisor) ensureStarted(ctx context.Context) error {
if s.firstStart.Load() {
return nil
}
s.firstStartOnce.Do(func() {
s.firstStartErr = s.runWithDeadline(ctx, func() error {
return s.Launch()
})
})
if s.firstStartErr != nil {
return fmt.Errorf("process first start: %w", s.firstStartErr)
}
return nil
}
// ensureHealthy checks the underlying process health and triggers a
// synchronous restart if the process is unhealthy. Skips the check if a
// restart is already in progress.
func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
if s.isRestarting.Load() || s.process.Healthy(s.logger) {
return nil
}
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
if err := s.doRestart(ctx); err != nil {
return fmt.Errorf("process restart before task: %w", err)
}
return nil
}
// maybeRestartAfterTask checks if the maximum request limit has been reached
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
// takes ownership of the caller's semaphore slot (the caller must not release
// it). Returns true if ownership was taken.
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
return false
}
if !s.restartMutex.TryLock() {
return false
}
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
go func() {
restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock()
if restartErr != nil {
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
}
logger.DebugContext(context.Background(), "process lock released")
<-s.semaphore
}()
return true
}
// doRestart coordinates a process restart, draining all active concurrent
// tasks before stopping and restarting the process.
func (s *processSupervisor) doRestart(ctx context.Context) error {
@@ -348,6 +505,10 @@ func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load()
}
func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
// Interface guards.
var (
_ ProcessSupervisor = (*processSupervisor)(nil)

View File

@@ -3,12 +3,11 @@ package gotenberg
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
func TestProcessSupervisor_Launch(t *testing.T) {
@@ -38,15 +37,15 @@ func TestProcessSupervisor_Launch(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return tc.startError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
if tc.firstStartSet {
ps.firstStart.Store(true)
}
@@ -86,15 +85,15 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0)
err := ps.Shutdown()
if !tc.expectError && err != nil {
@@ -135,18 +134,18 @@ func TestProcessSupervisor_restart(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
err := ps.restart()
@@ -170,15 +169,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
expectHealthy bool
}{
{
scenario: "non-started process is always healthy",
scenario: "non-started process is healthy",
initiallyStarted: false,
expectHealthy: true,
},
{
scenario: "restarting process is always healthy",
scenario: "restarting process is not healthy",
initiallyStarted: true,
initiallyRestarting: true,
expectHealthy: true,
expectHealthy: false,
},
{
scenario: "process reports as healthy",
@@ -194,15 +193,15 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return tc.processHealthy
},
}
ps := NewProcessSupervisor(logger, process, 5, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 5, 0, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -365,7 +364,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
var startCalls, healthyCalls, stopCalls atomic.Int64
startCalls.Store(0)
@@ -373,21 +372,21 @@ func TestProcessSupervisor_Run(t *testing.T) {
stopCalls.Store(0)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
startCalls.Add(1)
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
healthyCalls.Add(1)
return tc.processHealthy
},
}
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, tc.maxReqLimit, tc.maxQueueSize, 1, 0).(*processSupervisor)
if tc.initiallyStarted {
ps.firstStart.Store(true)
}
@@ -471,7 +470,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone {
@@ -496,16 +495,16 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
}
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
// Simulating a lock.
ps.semaphore <- struct{}{}
@@ -552,6 +551,111 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
}
}
func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
maxQueueSize := int64(50)
// maxConcurrency=1 so all goroutines block on the semaphore, exercising queue logic.
ps := NewProcessSupervisor(logger, process, 0, maxQueueSize, 1, 0).(*processSupervisor)
// Simulating a lock so that all goroutines queue up.
ps.semaphore <- struct{}{}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
goroutines := 100
var wg sync.WaitGroup
var exceeded atomic.Int64
for range goroutines {
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
if errors.Is(err, ErrMaximumQueueSizeExceeded) {
exceeded.Add(1)
}
}
})
}
// Wait a bit for goroutines to queue up.
time.Sleep(50 * time.Millisecond)
currentQueue := ps.ReqQueueSize()
if currentQueue > maxQueueSize {
t.Fatalf("queue size %d exceeded max %d", currentQueue, maxQueueSize)
}
cancel()
wg.Wait()
if exceeded.Load() < int64(goroutines)-maxQueueSize {
t.Errorf("expected at least %d rejections, got %d", goroutines-int(maxQueueSize), exceeded.Load())
}
}
func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
// maxQueueSize=1, maxConcurrency=1: only one request at a time.
ps := NewProcessSupervisor(logger, process, 0, 1, 1, 0)
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
taskStarted := make(chan struct{})
taskDone := make(chan struct{})
// Start a long-running task that holds the slot.
var wg sync.WaitGroup
wg.Go(func() {
err := ps.Run(ctx, logger, func() error {
close(taskStarted)
<-taskDone
return nil
})
if err != nil {
t.Errorf("first task: unexpected error: %v", err)
}
})
// Wait for the first task to be running.
<-taskStarted
// A second request should be rejected immediately because the queue
// slot is still held by the active task.
err := ps.Run(ctx, logger, func() error {
return nil
})
if !errors.Is(err, ErrMaximumQueueSizeExceeded) {
t.Fatalf("expected ErrMaximumQueueSizeExceeded but got: %v", err)
}
close(taskDone)
wg.Wait()
}
func TestProcessSupervisor_RestartsCount(t *testing.T) {
for _, tc := range []struct {
scenario string
@@ -593,18 +697,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
},
} {
t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return tc.startError
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return tc.stopError
},
}
ps := NewProcessSupervisor(logger, process, 0, 0, 1).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, 1, 0).(*processSupervisor)
ps.restartsCounter.Store(tc.initialRestartsCount)
for i := 0; i < tc.restartAttempts; i++ {
@@ -620,24 +724,24 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
}
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
var startCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
startCalls.Add(1)
return nil
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 0, 0, maxConcurrency, 0).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
@@ -684,22 +788,22 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
}
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := zap.NewNop()
logger := slog.New(slog.DiscardHandler)
process := &ProcessMock{
StartMock: func(logger *zap.Logger) error {
StartMock: func(logger *slog.Logger) error {
return nil
},
StopMock: func(logger *zap.Logger) error {
StopMock: func(logger *slog.Logger) error {
return nil
},
HealthyMock: func(logger *zap.Logger) bool {
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
maxConcurrency := int64(3)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency).(*processSupervisor)
ps := NewProcessSupervisor(logger, process, 3, 0, maxConcurrency, 0).(*processSupervisor)
ps.firstStart.Store(true)
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
@@ -737,3 +841,103 @@ func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
t.Fatalf("expected 1 restart, got %d", ps.RestartsCount())
}
}
func TestProcessSupervisor_IdleShutdown(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var stopCalls atomic.Int64
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout).(*processSupervisor)
ctx := context.Background()
err := ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Wait for idle shutdown to fire.
deadline := time.After(2 * time.Second)
for ps.firstStart.Load() {
select {
case <-deadline:
t.Fatal("timed out waiting for idle shutdown")
default:
time.Sleep(10 * time.Millisecond)
}
}
if stopCalls.Load() < 1 {
t.Fatal("expected process to be stopped via idle shutdown")
}
// Verify re-launch on next request.
err = ps.Run(ctx, logger, func() error {
return nil
})
if err != nil {
t.Fatalf("unexpected error on re-launch: %v", err)
}
if !ps.firstStart.Load() {
t.Fatal("expected process to be re-launched after idle shutdown")
}
}
func TestProcessSupervisor_IdleShutdownSkippedWhenActive(t *testing.T) {
logger := slog.New(slog.DiscardHandler)
var stopCalls atomic.Int64
taskRunning := make(chan struct{})
taskDone := make(chan struct{})
process := &ProcessMock{
StartMock: func(logger *slog.Logger) error {
return nil
},
StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1)
return nil
},
HealthyMock: func(logger *slog.Logger) bool {
return true
},
}
idleTimeout := 50 * time.Millisecond
ps := NewProcessSupervisor(logger, process, 0, 0, 1, idleTimeout)
ctx := context.Background()
go func() {
_ = ps.Run(ctx, logger, func() error {
close(taskRunning)
<-taskDone
return nil
})
}()
<-taskRunning
// Wait longer than the idle timeout while a task is active.
time.Sleep(idleTimeout * 3)
if stopCalls.Load() > 0 {
t.Fatal("idle shutdown should not fire while a task is active")
}
close(taskDone)
}

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

@@ -0,0 +1,237 @@
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"
"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 = errors.Join(err,
errors.New("service name must not be empty"),
)
}
if cfg.ServiceVersion == "" {
err = errors.Join(err,
errors.New("service version must not be empty"),
)
}
switch cfg.LogLevel {
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
break
default:
err = errors.Join(
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 = errors.Join(
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"
"errors"
"fmt"
"log/slog"
"net"
"net/http"
"sort"
@@ -14,8 +15,6 @@ import (
"github.com/dlclark/regexp2"
"github.com/labstack/echo/v4"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2"
"golang.org/x/sync/errgroup"
@@ -29,20 +28,23 @@ func init() {
// Api is a module that provides an HTTP server. Other modules may add routes,
// middlewares or health checks.
type Api struct {
port int
bindIp string
tlsCertFile string
tlsKeyFile string
startTimeout time.Duration
bodyLimit int64
timeout time.Duration
rootPath string
traceHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool
enableDebugRoute bool
port int
bindIp string
tlsCertFile string
tlsKeyFile string
startTimeout time.Duration
bodyLimit int64
timeout time.Duration
rootPath string
correlationIdHeader string
basicAuthUsername string
basicAuthPassword string
downloadFromCfg downloadFromConfig
disableHealthCheckRouteTelemetry bool
disableRootRouteTelemetry bool
disableDebugRouteTelemetry bool
disableVersionRouteTelemetry bool
enableDebugRoute bool
routes []Route
externalMiddlewares []Middleware
@@ -50,13 +52,13 @@ type Api struct {
readyFn []func() error
asyncCounters []AsynchronousCounter
fs *gotenberg.FileSystem
logger *zap.Logger
logger *slog.Logger
srv *echo.Echo
}
type downloadFromConfig struct {
allowList *regexp2.Regexp
denyList *regexp2.Regexp
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
maxRetry int
disable bool
}
@@ -80,9 +82,10 @@ type Route struct {
// Optional.
IsMultipart bool
// DisableLogging disables the logging for this route.
// DisableTelemetry disables telemetry (logging, tracing, metrics) for
// this route.
// Optional.
DisableLogging bool
DisableTelemetry bool
// Handler is the function that handles the request.
// Required.
@@ -190,14 +193,29 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.String("api-correlation-id-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression")
fs.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.StringSlice("api-download-from-allow-list", []string{}, "Set the allowed URLs for the download from feature using regular expressions - supports multiple values")
fs.StringSlice("api-download-from-deny-list", []string{}, "Set the denied URLs for the download from feature using regular expressions - supports multiple values")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
fs.Bool("api-disable-health-check-route-telemetry", true, "Disable telemetry for health check route")
fs.Bool("api-disable-root-route-telemetry", true, "Disable telemetry for the root route")
fs.Bool("api-disable-debug-route-telemetry", true, "Disable telemetry for the debug route")
fs.Bool("api-disable-version-route-telemetry", true, "Disable telemetry for the version route")
fs.Bool("api-enable-debug-route", false, "Enable the debug route")
// Deprecated flags.
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging")
err := errors.Join(
fs.MarkDeprecated("api-trace-header", "use --api-correlation-id-header instead"),
fs.MarkDeprecated("api-disable-health-check-logging", "use --api-disable-health-check-route-telemetry instead"),
)
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(Api) },
@@ -215,14 +233,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header")
a.correlationIdHeader = flags.MustDeprecatedString("api-trace-header", "api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"),
allowList: flags.MustRegexpSlice("api-download-from-allow-list"),
denyList: flags.MustRegexpSlice("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"),
}
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging")
a.disableHealthCheckRouteTelemetry = flags.MustDeprecatedBool("api-disable-health-check-logging", "api-disable-health-check-route-telemetry")
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry")
a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// Port from env?
@@ -328,17 +349,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger
a.logger = gotenberg.Logger(a)
// File system.
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
@@ -351,35 +362,35 @@ func (a *Api) Validate() error {
var err error
if a.port < 1 || a.port > 65535 {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("port must be more than 1 and less than 65535"),
)
}
if a.bindIp != "" && net.ParseIP(a.bindIp) == nil {
err = multierr.Append(err, errors.New("IP must be a valid IP address"))
err = errors.Join(err, errors.New("IP must be a valid IP address"))
}
if (a.tlsCertFile != "" && a.tlsKeyFile == "") || (a.tlsCertFile == "" && a.tlsKeyFile != "") {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("both TLS certificate and key files must be set"),
)
}
if !strings.HasPrefix(a.rootPath, "/") {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("root path must start with /"),
)
}
if !strings.HasSuffix(a.rootPath, "/") {
err = multierr.Append(err,
err = errors.Join(err,
errors.New("root path must end with /"),
)
}
if len(strings.TrimSpace(a.traceHeader)) == 0 {
err = multierr.Append(err,
if len(strings.TrimSpace(a.correlationIdHeader)) == 0 {
err = errors.Join(err,
errors.New("trace header must not be empty"),
)
}
@@ -442,28 +453,37 @@ func (a *Api) Start() error {
a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes.
var disableLoggingForPaths []string
var disableTelemetryForPaths []string
for i, route := range a.routes {
a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
if route.DisableLogging {
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/"))
if route.DisableTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
}
}
// Check if the user wishes to add logging entries related to the health
// check route.
if a.disableHealthCheckLogging {
disableLoggingForPaths = append(disableLoggingForPaths, "health")
// Check if the user wishes to disable telemetry for specific routes.
if a.disableHealthCheckRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
}
if a.disableRootRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
}
if a.disableDebugRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
}
if a.disableVersionRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
}
serverName := fmt.Sprintf("%s:%d", a.bindIp, a.port)
// Add the API middlewares.
a.srv.Pre(
latencyMiddleware(),
rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
outputFilenameMiddleware(),
loggerMiddleware(a.logger, disableLoggingForPaths),
telemetryMiddleware(a.logger, serverName, a.correlationIdHeader, disableTelemetryForPaths),
)
// Add the modules' middlewares in their respective stacks.
@@ -535,7 +555,9 @@ func (a *Api) Start() error {
)
// Let's not forget the health check routes...
checks := append(a.healthChecks, health.WithTimeout(a.timeout))
checks := make([]health.CheckerOption, len(a.healthChecks), len(a.healthChecks)+1)
copy(checks, a.healthChecks)
checks = append(checks, health.WithTimeout(a.timeout))
checker := health.NewChecker(checks...)
healthCheckHandler := health.NewHandler(checker)
@@ -600,7 +622,7 @@ func (a *Api) Start() error {
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
}
if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error())
a.logger.ErrorContext(context.Background(), err.Error())
}
}()
@@ -627,12 +649,12 @@ func (a *Api) Stop(ctx context.Context) error {
case <-ctx.Done():
return a.srv.Shutdown(ctx)
default:
a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count))
a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count))
if count > 0 {
time.Sleep(1 * time.Second)
continue
}
a.logger.Debug("no more asynchronous requests, continue with shutdown")
a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown")
err := a.srv.Shutdown(ctx)
if err != nil {
return fmt.Errorf("shutdown: %w", err)

View File

@@ -6,6 +6,7 @@ import (
"errors"
"fmt"
"io"
"log/slog"
"mime"
"mime/multipart"
"net/http"
@@ -19,7 +20,11 @@ import (
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"github.com/mholt/archives"
"go.uber.org/zap"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm"
@@ -36,16 +41,17 @@ var (
ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory")
)
// Context is the request context for a "multipart/form-data" requests.
// Context is the request context for a "multipart/form-data" request.
type Context struct {
dirPath string
values map[string][]string
files map[string]string
filesByField map[string][]string
outputPaths []string
cancelled bool
dirPath string
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
outputPaths []string
cancelled bool
logger *zap.Logger
logger *slog.Logger
echoCtx echo.Context
mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename
@@ -92,7 +98,7 @@ type downloadFrom struct {
}
// newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) {
func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given
@@ -137,12 +143,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
err := os.RemoveAll(ctx.dirPath)
if err != nil {
ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err))
ctx.logger.ErrorContext(context.Background(), fmt.Sprintf("remove context's working directory: %s", err))
return
}
ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
ctx.logger.DebugContext(context.Background(), fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
ctx.cancelled = true
}
}()
@@ -195,6 +201,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
ctx.values = form.Value
ctx.files = make(map[string]string)
ctx.filesByField = make(map[string][]string)
ctx.diskToOriginal = make(map[string]string)
// First, try to download files listed in the "downloadFrom" form field, if
// any.
@@ -230,10 +237,18 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return fmt.Errorf("filter URL: %w", err)
}
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url))
dlCtx, dlSpan := gotenberg.Tracer().Start(ctx, "GET Download From",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(dl.Url)),
)
logger.DebugContext(dlCtx, fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil)
if err != nil {
dlSpan.RecordError(err)
dlSpan.SetStatus(codes.Error, err.Error())
dlSpan.End()
return fmt.Errorf("create request to '%s': %w", dl.Url, err)
}
@@ -241,7 +256,16 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(traceHeader, trace)
// Inject OTEL trace context into outbound request.
otel.GetTextMapPropagator().Inject(dlCtx, propagation.HeaderCarrier(req.Header))
// Propagate correlation ID header.
if correlationIdHeader, ok := echoCtx.Get("correlationIdHeader").(string); ok {
if correlationId, ok := echoCtx.Get("correlationId").(string); ok {
req.Header.Set(correlationIdHeader, correlationId)
}
}
client := &retryablehttp.Client{
HTTPClient: &http.Client{
@@ -257,6 +281,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
resp, err := client.Do(req)
if err != nil {
dlSpan.RecordError(err)
dlSpan.SetStatus(codes.Error, err.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err),
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
@@ -265,21 +292,29 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() {
err := resp.Body.Close()
if err != nil {
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
}
}()
if resp.StatusCode != http.StatusOK {
dlErr := fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("download file from to '%s': got status: '%s'", dl.Url, resp.Status),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': got status: '%s'", dl.Url, resp.Status)),
)
}
contentDisposition := resp.Header.Get("Content-Disposition")
if contentDisposition == "" {
dlErr := fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("no 'Content-Disposition' header from '%s'", dl.Url),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("No 'Content-Disposition' header from '%s'", dl.Url)),
)
}
@@ -289,16 +324,24 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// See: https://github.com/golang/go/issues/69551.
_, params, err := mime.ParseMediaType(contentDisposition)
if err != nil {
dlErr := fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("parse 'Content-Disposition' header '%s' from '%s': %w", contentDisposition, dl.Url, err),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': %s", contentDisposition, dl.Url, err)),
)
}
filename, ok := params["filename"]
if !ok {
dlErr := fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return WrapError(
fmt.Errorf("get filename from 'Content-Disposition' header '%s' from '%s'", contentDisposition, dl.Url),
dlErr,
NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Invalid 'Content-Disposition' header '%s' from '%s': no filename", contentDisposition, dl.Url)),
)
}
@@ -307,16 +350,25 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
out, err := os.Create(path)
if err != nil {
return fmt.Errorf("create local file: %w", err)
dlErr := fmt.Errorf("create local file: %w", err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return dlErr
}
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err))
}
}()
@@ -325,10 +377,18 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
_, err = io.Copy(out, reader)
if err != nil {
return fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
dlErr := fmt.Errorf("copy downloaded file from '%s' to local file: %w", dl.Url, err)
dlSpan.RecordError(dlErr)
dlSpan.SetStatus(codes.Error, dlErr.Error())
dlSpan.End()
return dlErr
}
dlSpan.SetStatus(codes.Ok, "")
dlSpan.End()
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
// Route the downloaded file to the appropriate field bucket.
switch {
@@ -359,7 +419,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() {
err := in.Close()
if err != nil {
logger.Error(fmt.Sprintf("close file header: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("close file header: %s", err))
}
}()
@@ -370,7 +430,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662.
filename := norm.NFC.String(filepath.Base(fh.Filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename)
// Use a UUID-based name on disk to avoid filesystem
// NAME_MAX limits with long filenames.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
out, err := os.Create(path)
if err != nil {
@@ -379,7 +444,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() {
err := out.Close()
if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("close local file: %s", err))
}
}()
@@ -389,6 +454,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
ctx.files[filename] = path
ctx.diskToOriginal[path] = filename
return nil
}
@@ -407,10 +473,27 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
}
}
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
// Create symlinks from original filenames to UUID-based disk names
// so that relative asset references (e.g., <img src="image.png">)
// resolve correctly when Chromium navigates to a file:// URL.
// Symlink creation is best-effort: it may fail for filenames that
// exceed the filesystem NAME_MAX limit (the reason UUIDs were
// introduced in the first place).
for originalName, diskPath := range ctx.files {
symlinkPath := fmt.Sprintf("%s/%s", ctx.dirPath, originalName)
if symlinkPath == diskPath {
continue
}
err = os.Symlink(filepath.Base(diskPath), symlinkPath)
if err != nil {
logger.DebugContext(context.Background(), fmt.Sprintf("skip symlink for '%s': %s", originalName, err))
}
}
ctx.Log().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
ctx.Log().DebugContext(ctx, fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err
}
@@ -423,13 +506,34 @@ func (ctx *Context) Request() *http.Request {
// FormData return a [FormData].
func (ctx *Context) FormData() *FormData {
return &FormData{
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
errors: nil,
values: ctx.values,
files: ctx.files,
filesByField: ctx.filesByField,
diskToOriginal: ctx.diskToOriginal,
errors: nil,
}
}
// OriginalFilename returns the original filename associated with a disk path.
// If no mapping exists, it falls back to [filepath.Base].
func (ctx *Context) OriginalFilename(diskPath string) string {
if original, ok := ctx.diskToOriginal[diskPath]; ok {
return original
}
return filepath.Base(diskPath)
}
// RegisterDiskPath associates a disk path with an original filename so that
// [Context.OriginalFilename] can resolve it later.
func (ctx *Context) RegisterDiskPath(diskPath, originalFilename string) {
ctx.diskToOriginal[diskPath] = originalFilename
}
// DirPath returns the path to the request's working directory.
func (ctx *Context) DirPath() string {
return ctx.dirPath
}
// GeneratePath generates a path within the context's working directory.
// It generates a new UUID-based filename. It does not create a file.
func (ctx *Context) GeneratePath(extension string) string {
@@ -437,10 +541,14 @@ func (ctx *Context) GeneratePath(extension string) string {
}
// GeneratePathFromFilename generates a path within the context's working
// directory, using the given filename (with extension). It does not create
// a file.
// directory. It uses a UUID-based name on disk to avoid filesystem NAME_MAX
// limits but registers the given filename so that [Context.OriginalFilename]
// can resolve it. It does not create a file.
func (ctx *Context) GeneratePathFromFilename(filename string) string {
return fmt.Sprintf("%s/%s", ctx.dirPath, filename)
safeName := uuid.New().String() + filepath.Ext(filename)
path := fmt.Sprintf("%s/%s", ctx.dirPath, safeName)
ctx.diskToOriginal[path] = filename
return path
}
// CreateSubDirectory creates a subdirectory within the context's working
@@ -457,7 +565,7 @@ func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
// Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error {
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath))
ctx.Log().DebugContext(ctx, fmt.Sprintf("rename %s to %s", oldpath, newpath))
err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil {
return fmt.Errorf("rename path: %w", err)
@@ -483,8 +591,8 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil
}
// Log returns the context [zap.Logger].
func (ctx *Context) Log() *zap.Logger {
// Log returns the context [slog.Logger].
func (ctx *Context) Log() *slog.Logger {
return ctx.logger
}
@@ -500,14 +608,14 @@ func (ctx *Context) BuildOutputFile() (string, error) {
}
if len(ctx.outputPaths) == 1 {
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
ctx.logger.DebugContext(ctx, fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
return ctx.outputPaths[0], nil
}
filesInfo, err := archives.FilesFromDisk(ctx.Context, nil, func() map[string]string {
f := make(map[string]string)
for _, outputPath := range ctx.outputPaths {
f[outputPath] = ""
f[outputPath] = ctx.OriginalFilename(outputPath)
}
return f
}())
@@ -523,7 +631,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
defer func(out *os.File) {
err := out.Close()
if err != nil {
ctx.logger.Error(fmt.Sprintf("close zip file: %s", err))
ctx.logger.ErrorContext(ctx, fmt.Sprintf("close zip file: %s", err))
}
}(out)
@@ -532,7 +640,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
return "", fmt.Errorf("archive output files: %w", err)
}
ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath))
ctx.logger.DebugContext(ctx, fmt.Sprintf("archive '%s' created", archivePath))
return archivePath, nil
}
@@ -543,7 +651,7 @@ func (ctx *Context) OutputFilename(outputPath string) string {
filename := ctx.echoCtx.Get("outputFilename").(string)
if filename == "" {
return filepath.Base(outputPath)
return ctx.OriginalFilename(outputPath)
}
return fmt.Sprintf("%s%s", filename, filepath.Ext(outputPath))

View File

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

View File

@@ -1,6 +1,7 @@
package api
import (
"errors"
"fmt"
"math"
"net/http"
@@ -12,8 +13,6 @@ import (
"strings"
"time"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -33,10 +32,11 @@ const (
//
// form := ctx.FormData()
type FormData struct {
values map[string][]string
files map[string]string
filesByField map[string][]string
errors error
values map[string][]string
files map[string]string
filesByField map[string][]string
diskToOriginal map[string]string
errors error
}
// Validate returns nil or an error related to the [FormData] values, with a
@@ -450,6 +450,15 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
// Collect (originalFilename, diskPath) pairs so that we can sort by
// original filename rather than by UUID-based disk name.
// See https://github.com/gotenberg/gotenberg/issues/1500.
type entry struct {
original string
disk string
}
var entries []entry
for filename, path := range form.files {
if ok && slices.Contains(embeds, path) {
continue
@@ -466,20 +475,33 @@ func (form *FormData) paths(extensions []string, target *[]string) *FormData {
for _, ext := range extensions {
// See https://github.com/gotenberg/gotenberg/issues/228.
if strings.ToLower(filepath.Ext(filename)) == ext {
*target = append(*target, path)
entries = append(entries, entry{original: filename, disk: path})
}
}
}
// See https://github.com/gotenberg/gotenberg/issues/139.
sort.Sort(gotenberg.AlphanumericSort(*target))
originals := make(gotenberg.AlphanumericSort, len(entries))
for i, e := range entries {
originals[i] = e.original
}
sort.Sort(originals)
// Build a lookup from original name to disk path.
lookup := make(map[string]string, len(entries))
for _, e := range entries {
lookup[e.original] = e.disk
}
for _, o := range originals {
*target = append(*target, lookup[o])
}
return form
}
// append adds an error to the list of errors.
func (form *FormData) append(err error) {
form.errors = multierr.Append(form.errors, err)
form.errors = errors.Join(form.errors, err)
}
// mustValue binds the target interface with a form field. If the value is

View File

@@ -5,6 +5,7 @@ import (
"crypto/subtle"
"errors"
"fmt"
"log/slog"
"net/http"
"path/filepath"
"strings"
@@ -13,9 +14,13 @@ import (
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/labstack/echo/v4/middleware"
"go.uber.org/zap"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
)
var (
@@ -69,8 +74,7 @@ func ParseError(err error) (int, string) {
return http.StatusBadRequest, "At least one PDF engine cannot process the requested rotation angle, while others may have failed due to different issues"
}
var invalidArgsError *gotenberg.PdfEngineInvalidArgsError
if errors.As(err, &invalidArgsError) {
if invalidArgsError, ok := errors.AsType[*gotenberg.PdfEngineInvalidArgsError](err); ok {
return http.StatusBadRequest, invalidArgsError.Error()
}
@@ -87,14 +91,14 @@ func ParseError(err error) (int, string) {
// returns a response as "text/plain; charset=UTF-8".
func httpErrorHandler() echo.HTTPErrorHandler {
return func(err error, c echo.Context) {
logger := c.Get("logger").(*zap.Logger)
logger := c.Get("logger").(*slog.Logger)
status, message := ParseError(err)
c.Response().Header().Add(echo.HeaderContentType, echo.MIMETextPlainCharsetUTF8)
err = c.String(status, message)
if err != nil {
logger.Error(fmt.Sprintf("send error response: %s", err.Error()))
logger.ErrorContext(c.Request().Context(), fmt.Sprintf("send error response: %s", err.Error()))
}
}
}
@@ -138,32 +142,6 @@ func rootPathMiddleware(rootPath string) echo.MiddlewareFunc {
}
}
// traceMiddleware sets the request identifier in the [echo.Context] under
// "trace". Its value is either retrieved from the trace header or generated if
// the header is not present / its value is empty.
//
// trace := c.Get("trace").(string)
// traceHeader := c.Get("traceHeader").(string).
func traceMiddleware(header string) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
// Get or create the request identifier.
trace := c.Request().Header.Get(header)
if trace == "" {
trace = uuid.New().String()
}
c.Set("trace", trace)
c.Set("traceHeader", header)
c.Response().Header().Add(header, trace)
// Call the next middleware in the chain.
return next(c)
}
}
}
// outputFilenameMiddleware sets the output filename in the [echo.Context]
// under "outputFilename".
//
@@ -183,59 +161,28 @@ func outputFilenameMiddleware() echo.MiddlewareFunc {
}
}
// loggerMiddleware sets the logger in the [echo.Context] under "logger" and
// logs a synchronous request result.
// telemetryMiddleware manages telemetry. It sets the correlation ID in the
// [echo.Context] under "correlationId".
//
// logger := c.Get("logger").(*zap.Logger)
func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.MiddlewareFunc {
// correlationIdHeader := c.Get("correlationIdHeader").(string)
// correlationId := c.Get("correlationId").(string)
func telemetryMiddleware(logger *slog.Logger, serverName, correlationIdHeader string, disableTelemetryForPaths []string) echo.MiddlewareFunc {
meter := gotenberg.Meter()
semconvSrv := semconvutil.NewHTTPServer(meter)
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
startTime := c.Get("startTime").(time.Time)
trace := c.Get("trace").(string)
rootPath := c.Get("rootPath").(string)
// Create the application logger and add it to our locals.
appLogger := logger.
With(zap.String("log_type", "application")).
With(zap.String("trace", trace))
request := c.Request()
savedCtx := request.Context()
defer func() {
request = request.WithContext(savedCtx)
c.SetRequest(request)
}()
c.Set("logger", appLogger.Named(func() string {
return strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
"",
)
}()))
// Call the next middleware in the chain.
err := next(c)
if err != nil {
c.Error(err)
}
// Create the access logger.
accessLogger := logger.
With(zap.String("log_type", "access")).
With(zap.String("trace", trace))
for _, path := range disableLoggingForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
if c.Request().RequestURI == URI {
return nil
}
}
// Last piece for calculating the latency.
finishTime := time.Now()
// Now, let's log!
fields := make([]zap.Field, 12)
fields[0] = zap.String("remote_ip", c.RealIP())
fields[1] = zap.String("host", c.Request().Host)
fields[2] = zap.String("uri", c.Request().RequestURI)
fields[3] = zap.String("method", c.Request().Method)
fields[4] = zap.String("path", func() string {
routePath := func() string {
path := c.Request().URL.Path
if path == "" {
@@ -243,21 +190,127 @@ func loggerMiddleware(logger *zap.Logger, disableLoggingForPaths []string) echo.
}
return path
}())
fields[5] = zap.String("referer", c.Request().Referer())
fields[6] = zap.String("user_agent", c.Request().UserAgent())
fields[7] = zap.Int("status", c.Response().Status)
fields[8] = zap.Int64("latency", int64(finishTime.Sub(startTime)))
fields[9] = zap.String("latency_human", finishTime.Sub(startTime).String())
fields[10] = zap.Int64("bytes_in", c.Request().ContentLength)
fields[11] = zap.Int64("bytes_out", c.Response().Size)
}()
// Evaluate if we should skip telemetry for this path.
skipTelemetry := false
for _, path := range disableTelemetryForPaths {
URI := fmt.Sprintf("%s%s", rootPath, path)
if c.Request().RequestURI == URI {
skipTelemetry = true
break
}
}
if skipTelemetry {
c.Set("logger", slog.New(slog.DiscardHandler))
err := next(c)
if err != nil {
c.Error(err)
}
return nil
}
correlationId := request.Header.Get(correlationIdHeader)
if correlationId == "" {
correlationId = uuid.NewString()
}
c.Set("correlationIdHeader", correlationIdHeader)
c.Set("correlationId", correlationId)
ctx := otel.GetTextMapPropagator().Extract(savedCtx, propagation.HeaderCarrier(request.Header))
rAttr := semconvSrv.Route(routePath)
opts := []trace.SpanStartOption{
trace.WithAttributes(
semconvSrv.RequestTraceAttrs(serverName, request, semconvutil.RequestTraceAttrsOpts{})...,
),
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(rAttr),
}
spanName := strings.ToUpper(c.Request().Method) + " " + routePath
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, spanName, opts...)
defer span.End()
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(c.Response().Header()))
c.Response().Header().Set(correlationIdHeader, correlationId)
c.SetRequest(c.Request().WithContext(ctx))
appLogger := logger.
With(slog.String("log_type", "application")).
With(slog.String("correlation_id", correlationId))
loggerName := strings.ReplaceAll(
strings.ReplaceAll(c.Request().URL.Path, rootPath, ""),
"/",
"",
)
c.Set("logger", appLogger.With(slog.String("logger", loggerName)))
// Call the next middleware in the chain.
err := next(c)
finishTime := time.Now()
status := c.Response().Status
if err != nil {
parsedStatus, _ := ParseError(err)
status = parsedStatus
span.SetAttributes(attribute.String("error", err.Error()))
c.Error(err)
}
span.SetStatus(semconvSrv.Status(status))
span.SetAttributes(semconvSrv.ResponseTraceAttrs(semconvutil.ResponseTelemetry{
StatusCode: status,
WriteBytes: c.Response().Size,
})...)
accessLogger := logger.
With(slog.String("log_type", "access")).
With(slog.String("correlation_id", correlationId)).
With(slog.String("remote_ip", c.RealIP())).
With(slog.String("host", c.Request().Host)).
With(slog.String("uri", c.Request().RequestURI)).
With(slog.String("method", c.Request().Method)).
With(slog.String("path", routePath)).
With(slog.String("referer", c.Request().Referer())).
With(slog.String("user_agent", c.Request().UserAgent())).
With(slog.Int("status", c.Response().Status)).
With(slog.Int64("latency", int64(finishTime.Sub(startTime)))).
With(slog.String("latency_human", finishTime.Sub(startTime).String())).
With(slog.Int64("bytes_in", c.Request().ContentLength)).
With(slog.Int64("bytes_out", c.Response().Size))
if err != nil {
accessLogger.Error(err.Error(), fields...)
accessLogger.ErrorContext(ctx, err.Error())
} else {
accessLogger.Info("request handled", fields...)
accessLogger.InfoContext(ctx, "request handled")
}
additionalAttributes := []attribute.KeyValue{
semconvSrv.Route(routePath),
}
semconvSrv.RecordMetrics(ctx, semconvutil.ServerMetricData{
ServerName: serverName,
ResponseSize: c.Response().Size,
MetricAttributes: semconvutil.MetricAttributes{
Req: request,
StatusCode: status,
AdditionalAttributes: additionalAttributes,
},
MetricData: semconvutil.MetricData{
RequestSize: request.ContentLength,
ElapsedTime: float64(time.Since(startTime)) / float64(time.Millisecond),
},
})
return nil
}
}
@@ -284,13 +337,11 @@ func basicAuthMiddleware(username, password string) echo.MiddlewareFunc {
func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
logger := c.Get("logger").(*slog.Logger)
// We create a context with a timeout so that underlying processes are
// able to stop early and correctly handle a timeout scenario.
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg, traceHeader, trace)
ctx, cancel, err := newContext(c, logger, fs, timeout, bodyLimit, downloadFromCfg)
if err != nil {
cancel()
@@ -344,7 +395,7 @@ func contextMiddleware(fs *gotenberg.FileSystem, timeout time.Duration, bodyLimi
func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
return func(next echo.HandlerFunc) echo.HandlerFunc {
return func(c echo.Context) error {
logger := c.Get("logger").(*zap.Logger)
logger := c.Get("logger").(*slog.Logger)
// Define a hard timeout if the route handler fails to timeout as
// expected.
@@ -361,7 +412,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
// This deferred function allows us to recover from such scenarios.
defer func() {
if r := recover(); r != nil {
logger.Debug(fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r))
logger.DebugContext(hardTimeoutCtx, fmt.Sprintf("recovering from a panic (possible cause being a hard timeout): %s", r))
}
}()
@@ -373,7 +424,7 @@ func hardTimeoutMiddleware(hardTimeout time.Duration) echo.MiddlewareFunc {
case err := <-errChan:
return err
case <-hardTimeoutCtx.Done():
logger.Debug("hard timeout as the route handler did not timeout as expected")
logger.DebugContext(hardTimeoutCtx, "hard timeout as the route handler did not timeout as expected")
return fmt.Errorf("hard timeout: %w", hardTimeoutCtx.Err())
}

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"strings"
"sync"
@@ -18,15 +19,14 @@ import (
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type browser interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
}
type browserArguments struct {
@@ -42,8 +42,8 @@ type browserArguments struct {
hyphenDataDirPath string
// Tasks specific.
allowList *regexp2.Regexp
denyList *regexp2.Regexp
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
clearCache bool
clearCookies bool
disableJavaScript bool
@@ -72,7 +72,7 @@ func newChromiumBrowser(arguments browserArguments) browser {
return b
}
func (b *chromiumBrowser) Start(logger *zap.Logger) error {
func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if b.isStarted.Load() {
return errors.New("browser is already started")
}
@@ -164,7 +164,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
return nil
}
func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
if !b.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
@@ -181,7 +181,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Clean up stuck processes.
ps, err := process.Processes()
if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("list processes: %v", err))
} else {
for _, p := range ps {
func() {
@@ -199,9 +199,9 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = p.KillWithContext(killCtx)
if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("kill process: %v", err))
} else {
logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid))
logger.DebugContext(context.Background(), fmt.Sprintf("Chromium process %d killed", p.Pid))
}
}()
}
@@ -215,15 +215,15 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("remove Chromium's user profile directory: %s", err))
} else {
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
}
// Also, remove Chromium-specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
if err != nil {
logger.Error(err.Error())
logger.ErrorContext(context.Background(), err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
@@ -239,7 +239,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
return nil
}
func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
@@ -266,14 +266,14 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
return err
}))
if err != nil {
logger.Error(fmt.Sprintf("browser health check failed: %s", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("browser health check failed: %s", err))
return false
}
return true
}
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
@@ -285,7 +285,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
@@ -299,7 +299,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
})
}
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
@@ -311,7 +311,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
@@ -326,7 +326,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
})
}
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error {
func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks")
}
@@ -356,9 +356,10 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
// the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
listenForEventRequestPaused(taskCtx, logger, eventRequestPausedOptions{
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
extraHttpHeaders: options.ExtraHttpHeaders,
allowList: b.arguments.allowList,
denyList: b.arguments.denyList,
allowedFilePrefixes: options.AllowedFilePrefixes,
extraHttpHeaders: options.ExtraHttpHeaders,
})
var (
@@ -381,6 +382,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
ignoreResourceHttpStatusDomains: options.IgnoreResourceHttpStatusDomains,
invalidResourceHttpStatusCode: &invalidResourceHttpStatusCode,
invalidResourceHttpStatusCodeMu: &invalidResourceHttpStatusCodeMu,
cancelOnMainPageError: taskCancel,
})
}
@@ -410,11 +412,45 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
loadingFailedMu: &loadingFailedMu,
resourceLoadingFailed: &resourceLoadingFailed,
resourceLoadingFailedMu: &resourceLoadingFailedMu,
cancelOnMainPageError: taskCancel,
})
err = chromedp.Run(taskCtx, tasks...)
if err != nil {
errMessage := err.Error()
runErr := chromedp.Run(taskCtx, tasks...)
// Check event-driven errors first — they take priority over chromedp.Run
// errors because they carry the actual root cause (e.g., HTTP 500 from
// the main page). When we cancel taskCtx on a main page error,
// chromedp.Run returns a context error that is less informative.
// See https://github.com/gotenberg/gotenberg/issues/1492.
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
if runErr != nil {
errMessage := runErr.Error()
if strings.Contains(errMessage, "Printing failed (-32000)") {
return ErrPrintingFailed
@@ -436,23 +472,7 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return ErrRpccMessageTooLarge
}
return fmt.Errorf("handle tasks: %w", err)
}
// See https://github.com/gotenberg/gotenberg/issues/613.
invalidHttpStatusCodeMu.RLock()
defer invalidHttpStatusCodeMu.RUnlock()
if invalidHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidHttpStatusCode, ErrInvalidHttpStatusCode)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
invalidResourceHttpStatusCodeMu.RLock()
defer invalidResourceHttpStatusCodeMu.RUnlock()
if invalidResourceHttpStatusCode != nil {
return fmt.Errorf("%v: %w", invalidResourceHttpStatusCode, ErrInvalidResourceHttpStatusCode)
return fmt.Errorf("handle tasks: %w", runErr)
}
// See https://github.com/gotenberg/gotenberg/issues/262.
@@ -463,16 +483,6 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
return fmt.Errorf("%v: %w", consoleExceptions, ErrConsoleExceptions)
}
// See:
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
loadingFailedMu.RLock()
defer loadingFailedMu.RUnlock()
if loadingFailed != nil {
return fmt.Errorf("%v: %w", loadingFailed, ErrLoadingFailed)
}
// See https://github.com/gotenberg/gotenberg/issues/1021.
if options.FailOnResourceLoadingFailed {
if resourceLoadingFailed != nil {

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
@@ -14,7 +15,11 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2"
flag "github.com/spf13/pflag"
"go.uber.org/zap"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -91,10 +96,17 @@ type Chromium struct {
maxConcurrency int64
args browserArguments
logger *zap.Logger
logger *slog.Logger
browser browser
supervisor gotenberg.ProcessSupervisor
engine gotenberg.PdfEngine
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
imageOutputSizeCounter metric.Int64Histogram
}
// Options are the common options for all conversions.
@@ -105,6 +117,10 @@ type Options struct {
// rendered until this event is fired.
SkipNetworkIdleEvent bool
// SkipNetworkAlmostIdleEvent set if the conversion should wait for the
// "networkAlmostIdle" event.
SkipNetworkAlmostIdleEvent bool
// FailOnHttpStatusCodes sets if the conversion should fail if the status
// code from the main page matches with one of its entries.
FailOnHttpStatusCodes []int64
@@ -172,6 +188,12 @@ type Options struct {
// OmitBackground hides the default white background and allows generating
// PDFs with transparency.
OmitBackground bool
// AllowedFilePrefixes restricts file:// sub-resource access to only these
// directory prefixes. Applied in listenForEventRequestPaused in addition
// to the global allow/deny lists. Set internally by route handlers, not
// via form data.
AllowedFilePrefixes []string
}
// EmulatedMediaFeature gathers the available entries for emulating a media
@@ -191,6 +213,7 @@ type EmulatedMediaFeature struct {
func DefaultOptions() Options {
return Options{
SkipNetworkIdleEvent: true,
SkipNetworkAlmostIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil,
IgnoreResourceHttpStatusDomains: nil,
@@ -389,8 +412,8 @@ type ExtraHttpHeader struct {
// Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error
Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
}
// Provider is a module interface that exposes a method for creating an [Api]
@@ -412,6 +435,7 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("chromium", flag.ExitOnError)
fs.Int64("chromium-restart-after", 100, "Number of conversions after which Chromium will automatically restart. Set to 0 to disable this feature")
fs.Int64("chromium-max-queue-size", 0, "Maximum request queue size for Chromium. Set to 0 to disable this feature")
fs.Duration("chromium-idle-shutdown-timeout", 0, "Shutdown Chromium after being idle for the given duration. Set to 0 to disable this feature")
fs.Int64("chromium-max-concurrency", 6, "Maximum number of concurrent conversions. Chromium supports up to 6")
fs.Bool("chromium-auto-start", false, "Automatically launch Chromium upon initialization if set to true; otherwise, Chromium will start at the time of the first conversion")
fs.Duration("chromium-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for Chromium to start or restart")
@@ -421,8 +445,8 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("chromium-allow-file-access-from-files", false, "Allow file:// URIs to read other file:// URIs")
fs.String("chromium-host-resolver-rules", "", "Set custom mappings to the host resolver")
fs.String("chromium-proxy-server", "", "Set the outbound proxy server; this switch only affects HTTP and HTTPS requests")
fs.String("chromium-allow-list", "", "Set the allowed URLs for Chromium using a regular expression")
fs.String("chromium-deny-list", `^file:(?!//\/tmp/).*`, "Set the denied URLs for Chromium using a regular expression")
fs.StringSlice("chromium-allow-list", []string{}, "Set the allowed URLs for Chromium using regular expressions - supports multiple values")
fs.StringSlice("chromium-deny-list", []string{`^file:(?!//\/tmp/).*`}, "Set the denied URLs for Chromium using regular expressions - supports multiple values")
fs.Bool("chromium-clear-cache", false, "Clear Chromium cache between each conversion")
fs.Bool("chromium-clear-cookies", false, "Clear Chromium cookies between each conversion")
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
@@ -469,27 +493,19 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
wsUrlReadTimeout: flags.MustDuration("chromium-start-timeout"),
hyphenDataDirPath: hyphenDataDirPath,
allowList: flags.MustRegexp("chromium-allow-list"),
denyList: flags.MustRegexp("chromium-deny-list"),
allowList: flags.MustRegexpSlice("chromium-allow-list"),
denyList: flags.MustRegexpSlice("chromium-deny-list"),
clearCache: flags.MustBool("chromium-clear-cache"),
clearCookies: flags.MustBool("chromium-clear-cookies"),
disableJavaScript: flags.MustBool("chromium-disable-javascript"),
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
mod.logger = logger.Named("browser")
mod.logger = gotenberg.Logger(mod).With(slog.String("logger", "browser"))
// Process.
mod.browser = newChromiumBrowser(mod.args)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency)
mod.supervisor = gotenberg.NewProcessSupervisor(mod.logger, mod.browser, flags.MustInt64("chromium-restart-after"), flags.MustInt64("chromium-max-queue-size"), mod.maxConcurrency, flags.MustDuration("chromium-idle-shutdown-timeout"))
// PDF Engine.
provider, err := ctx.Module(new(gotenberg.PdfEngineProvider))
@@ -502,6 +518,109 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
}
mod.engine = engine
// Metrics.
meter := gotenberg.Meter()
// Observable gauges.
_, err = meter.Int64ObservableGauge(
"chromium.requests.active",
metric.WithDescription("Current number of active Chromium requests"),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.ActiveTasksCount())
return nil
}),
)
if err != nil {
return fmt.Errorf("create chromium.requests.active gauge: %w", err)
}
_, err = meter.Int64ObservableGauge(
"chromium.requests.queue_size",
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated"),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.ReqQueueSize())
return nil
}),
)
if err != nil {
return fmt.Errorf("create chromium.requests.queue_size gauge: %w", err)
}
_, err = meter.Int64ObservableCounter(
"chromium.process.restarts.total",
metric.WithDescription("Current number of Chromium restarts"),
metric.WithUnit("{restart}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
o.Observe(mod.supervisor.RestartsCount())
return nil
}),
)
if err != nil {
return fmt.Errorf("create chromium.process.restarts.total counter: %w", err)
}
// Counters.
mod.reqsCounter, err = meter.Int64Counter(
"chromium.requests.total",
metric.WithDescription("Total number of Chromium conversion requests"),
metric.WithUnit("{request}"),
)
if err != nil {
return fmt.Errorf("create chromium.requests.total counter: %w", err)
}
mod.errsCounter, err = meter.Int64Counter(
"chromium.errors.total",
metric.WithDescription("Total number of Chromium conversion errors"),
metric.WithUnit("{error}"),
)
if err != nil {
return fmt.Errorf("create chromium.errors.total counter: %w", err)
}
// Histograms.
durationBuckets := metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60)
mod.conversionDurationCounter, err = meter.Float64Histogram(
"chromium.conversion.duration",
metric.WithDescription("Duration of Chromium conversions"),
metric.WithUnit("s"),
durationBuckets,
)
if err != nil {
return fmt.Errorf("create chromium.conversion.duration histogram: %w", err)
}
mod.queueWaitDurationCounter, err = meter.Float64Histogram(
"chromium.queue.wait.duration",
metric.WithDescription("Duration of waiting in queue for Chromium conversions"),
metric.WithUnit("s"),
durationBuckets,
)
if err != nil {
return fmt.Errorf("create chromium.queue.wait.duration histogram: %w", err)
}
mod.pdfOutputSizeCounter, err = meter.Int64Histogram(
"chromium.pdf.output.size",
metric.WithDescription("Size of PDF output from Chromium conversions"),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create chromium.pdf.output.size histogram: %w", err)
}
mod.imageOutputSizeCounter, err = meter.Int64Histogram(
"chromium.image.output.size",
metric.WithDescription("Size of image output from Chromium screenshots"),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create chromium.image.output.size histogram: %w", err)
}
return nil
}
@@ -552,7 +671,7 @@ func (mod *Chromium) StartupMessage() string {
func (mod *Chromium) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully
// stop before we do a shutdown.
mod.logger.Debug("wait for the end of grace duration")
mod.logger.DebugContext(ctx, "wait for the end of grace duration")
<-ctx.Done()
@@ -668,20 +787,160 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
}
// Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return mod.supervisor.Run(ctx, logger, func() error {
func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.pdf(ctx, logger, url, outputPath, options)
})
end := time.Now()
status := "success"
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
} else {
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
mod.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
}
span.SetStatus(codes.Ok, "")
return nil
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to
// the end user.
return mod.supervisor.Run(ctx, logger, func() error {
func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
ctx, span := gotenberg.Tracer().Start(ctx, "chromium.Screenshot",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(mod.args.binPath)),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
err := mod.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return mod.browser.screenshot(ctx, logger, url, outputPath, options)
})
end := time.Now()
status := "success"
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
} else {
status = "error"
}
reason := "unknown"
switch {
case errors.Is(err, context.DeadlineExceeded):
reason = "timeout"
case errors.Is(err, context.Canceled):
reason = "context_cancelled"
case errors.Is(err, ErrInvalidHttpStatusCode) || errors.Is(err, ErrInvalidResourceHttpStatusCode) || errors.Is(err, ErrLoadingFailed) || errors.Is(err, ErrResourceLoadingFailed) || errors.Is(err, ErrInvalidEvaluationExpression) || errors.Is(err, ErrInvalidSelectorQuery):
reason = "invalid_input"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded):
reason = "chromium_maximum_queue_size_exceeded"
case errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
reason = "chromium_unavailable"
}
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
mod.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
}
span.SetStatus(codes.Ok, "")
return nil
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Interface guards.

View File

@@ -1,28 +1,28 @@
package chromium
import (
"context"
"fmt"
"io"
"go.uber.org/zap"
"log/slog"
)
// debugLogger is wrapper around a [zap.Logger] which is used for debugging
// debugLogger is wrapper around a [slog.Logger] which is used for debugging
// Chromium.
type debugLogger struct {
logger *zap.Logger
logger *slog.Logger
}
// Write logs the bytes in a debug message.
func (debug *debugLogger) Write(p []byte) (n int, err error) {
debug.logger.Debug(string(p))
debug.logger.DebugContext(context.Background(), string(p))
return len(p), nil
}
// Printf logs a debug message.
func (debug *debugLogger) Printf(format string, v ...any) {
debug.logger.Debug(fmt.Sprintf(format, v...))
debug.logger.DebugContext(context.Background(), fmt.Sprintf(format, v...))
}
// Interface guards.

View File

@@ -2,7 +2,9 @@ package chromium
import (
"context"
"errors"
"fmt"
"log/slog"
"net/http"
"net/url"
"slices"
@@ -16,15 +18,14 @@ import (
"github.com/chromedp/cdproto/runtime"
"github.com/chromedp/chromedp"
"github.com/dlclark/regexp2"
"go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type eventRequestPausedOptions struct {
allowList, denyList *regexp2.Regexp
allowList, denyList []*regexp2.Regexp
allowedFilePrefixes []string
extraHttpHeaders []ExtraHttpHeader
}
@@ -32,32 +33,50 @@ type eventRequestPausedOptions struct {
// allowed or not. It also set the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) {
func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, options eventRequestPausedOptions) {
if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers")
logger.DebugContext(ctx, "no extra HTTP headers")
} else {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
}
chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) {
case *fetch.EventRequestPaused:
if e, ok := ev.(*fetch.EventRequestPaused); ok {
go func() {
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
allow := true
deadline, ok := ctx.Deadline()
if !ok {
logger.Error("context has no deadline, cannot filter URL")
logger.ErrorContext(ctx, "context has no deadline, cannot filter URL")
return
}
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil {
logger.Warn(err.Error())
logger.WarnContext(ctx, err.Error())
allow = false
}
// Additional restriction: if the sub-resource is a file:// URL
// and we have allowed file prefixes, restrict access to only
// those directories. This prevents cross-request file access
// in /tmp.
if allow && strings.HasPrefix(e.Request.URL, "file://") && len(options.allowedFilePrefixes) > 0 {
prefixMatch := false
for _, prefix := range options.allowedFilePrefixes {
if strings.HasPrefix(e.Request.URL, "file://"+prefix) {
prefixMatch = true
break
}
}
if !prefixMatch {
logger.WarnContext(ctx, fmt.Sprintf("'%s' is not within any allowed file prefix", e.Request.URL))
allow = false
}
}
cctx := chromedp.FromContext(ctx)
executorCtx := cdp.WithExecutor(ctx, cctx.Target)
@@ -65,7 +84,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx)
if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("fail request: %s", err))
}
return
}
@@ -81,25 +100,26 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
for _, header := range options.extraHttpHeaders {
if header.Scope == nil {
// Non-scoped header.
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue
}
ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
} else if ok {
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
switch {
case err != nil:
logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
case ok:
logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else {
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
default:
logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
}
}
}
if len(extraHttpHeadersToSet) > 0 {
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
logger.DebugContext(ctx, fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
originalHeaders := e.Request.Headers
headers := make(map[string]string)
@@ -109,7 +129,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
if ok {
headers[key] = strValue
} else {
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
logger.ErrorContext(ctx, fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
}
}
@@ -132,7 +152,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
err = req.Do(executorCtx)
if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("continue request: %s", err))
}
}()
}
@@ -148,6 +168,7 @@ type eventResponseReceivedOptions struct {
ignoreResourceHttpStatusDomains []string
invalidResourceHttpStatusCode *error
invalidResourceHttpStatusCodeMu *sync.RWMutex
cancelOnMainPageError context.CancelFunc
}
// listenForEventResponseReceived listens for an invalid HTTP status code
@@ -157,7 +178,7 @@ type eventResponseReceivedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventResponseReceived(
ctx context.Context,
logger *zap.Logger,
logger *slog.Logger,
options eventResponseReceivedOptions,
) {
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
@@ -177,33 +198,40 @@ func listenForEventResponseReceived(
}
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *network.EventResponseReceived:
if ev, ok := ev.(*network.EventResponseReceived); ok {
if ev.Response.URL == options.mainPageUrl {
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
options.invalidHttpStatusCodeMu.Lock()
defer options.invalidHttpStatusCodeMu.Unlock()
*options.invalidHttpStatusCode = fmt.Errorf("%d: %s", ev.Response.Status, ev.Response.StatusText)
// Cancel the task context so that any in-flight wait
// operations (waitForSelector, waitForExpression, etc.)
// abort immediately instead of polling until timeout.
// See https://github.com/gotenberg/gotenberg/issues/1492.
if options.cancelOnMainPageError != nil {
options.cancelOnMainPageError()
}
}
return
}
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
logger.DebugContext(ctx, fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
return
}
options.invalidResourceHttpStatusCodeMu.Lock()
defer options.invalidResourceHttpStatusCodeMu.Unlock()
*options.invalidResourceHttpStatusCode = multierr.Append(
*options.invalidResourceHttpStatusCode = errors.Join(
*options.invalidResourceHttpStatusCode,
fmt.Errorf("%s - %d: %s", ev.Response.URL, ev.Response.Status, http.StatusText(int(ev.Response.Status))),
)
@@ -290,6 +318,7 @@ type eventLoadingFailedOptions struct {
loadingFailedMu *sync.RWMutex
resourceLoadingFailed *error
resourceLoadingFailedMu *sync.RWMutex
cancelOnMainPageError context.CancelFunc
}
// listenForEventLoadingFailed listens for an event indicating that the main
@@ -298,15 +327,14 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) {
func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
if ev, ok := ev.(*network.EventLoadingFailed); ok {
logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
// We are looking for common errors.
// TODO: sufficient?
errors := []string{
knownErrors := []string{
"net::ERR_CONNECTION_CLOSED",
"net::ERR_CONNECTION_RESET",
"net::ERR_CONNECTION_REFUSED",
@@ -320,30 +348,37 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
"net::ERR_FILE_NOT_FOUND",
"net::ERR_HTTP2_PROTOCOL_ERROR",
}
if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
if !slices.Contains(knownErrors, ev.ErrorText) {
logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, knownErrors))
return
}
if ev.Type == network.ResourceTypeDocument {
// Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page.
logger.Debug("event EventLoadingFailed fired for main page")
logger.DebugContext(ctx, "event EventLoadingFailed fired for main page")
options.loadingFailedMu.Lock()
defer options.loadingFailedMu.Unlock()
*options.loadingFailed = fmt.Errorf("%s", ev.ErrorText)
// Cancel the task context so that any in-flight wait
// operations abort immediately.
// See https://github.com/gotenberg/gotenberg/issues/1492.
if options.cancelOnMainPageError != nil {
options.cancelOnMainPageError()
}
return
}
logger.Debug("event EventLoadingFailed fired for a resource")
logger.DebugContext(ctx, "event EventLoadingFailed fired for a resource")
options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock()
*options.resourceLoadingFailed = multierr.Append(
*options.resourceLoadingFailed = errors.Join(
*options.resourceLoadingFailed,
fmt.Errorf("resource %s: %s", ev.Type, ev.ErrorText),
)
@@ -354,29 +389,27 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
// listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262.
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) {
case *runtime.EventExceptionThrown:
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
if ev, ok := ev.(*runtime.EventExceptionThrown); ok {
logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
consoleExceptionsMu.Lock()
defer consoleExceptionsMu.Unlock()
*consoleExceptions = multierr.Append(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
*consoleExceptions = errors.Join(*consoleExceptions, fmt.Errorf("\n%+v", ev.ExceptionDetails))
}
})
}
// waitForEventDomContentEventFired waits until the event DomContentEventFired
// is fired or the context timeout.
func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error {
func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *page.EventDomContentEventFired:
if _, ok := ev.(*page.EventDomContentEventFired); ok {
cancel()
close(ch)
}
@@ -384,7 +417,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
select {
case <-ch:
logger.Debug("event DomContentEventFired fired")
logger.DebugContext(ctx, "event DomContentEventFired fired")
return nil
case <-ctx.Done():
return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err())
@@ -394,13 +427,12 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or
// the context timeout.
func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() error {
func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *page.EventLoadEventFired:
if _, ok := ev.(*page.EventLoadEventFired); ok {
cancel()
close(ch)
}
@@ -408,7 +440,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
select {
case <-ch:
logger.Debug("event LoadEventFired fired")
logger.DebugContext(ctx, "event LoadEventFired fired")
return nil
case <-ctx.Done():
return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err())
@@ -418,39 +450,12 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
// waitForEventNetworkIdle waits until the event networkIdle is fired or the
// context timeout.
func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() error {
func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
switch e := ev.(type) {
case *page.EventLifecycleEvent:
if e.Name == "networkIdle" {
cancel()
close(ch)
}
}
})
select {
case <-ch:
logger.Debug("event networkIdle fired")
return nil
case <-ctx.Done():
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err())
}
}
}
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
// or the context timeout.
func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() error {
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
switch ev.(type) {
case *network.EventLoadingFinished:
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle" {
cancel()
close(ch)
}
@@ -458,7 +463,53 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
select {
case <-ch:
logger.Debug("event LoadingFinished fired")
logger.DebugContext(ctx, "event networkIdle fired")
return nil
case <-ctx.Done():
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err())
}
}
}
// waitForEventNetworkAlmostIdle waits until the event networkIdle2 is fired
// or the context timeout.
func waitForEventNetworkAlmostIdle(ctx context.Context, logger *slog.Logger) func() error {
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if e, ok := ev.(*page.EventLifecycleEvent); ok && e.Name == "networkIdle2" {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event networkAlmostIdle fired")
return nil
case <-ctx.Done():
return fmt.Errorf("wait for event networkAlmostIdle: %w", ctx.Err())
}
}
}
// waitForEventLoadingFinished waits until the event LoadingFinished is fired
// or the context timeout.
func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
return func() error {
ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx)
chromedp.ListenTarget(cctx, func(ev any) {
if _, ok := ev.(*network.EventLoadingFinished); ok {
cancel()
close(ch)
}
})
select {
case <-ch:
logger.DebugContext(ctx, "event LoadingFinished fired")
return nil
case <-ctx.Done():
return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err())

View File

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

View File

@@ -17,7 +17,6 @@ import (
"github.com/gomarkdown/markdown"
"github.com/labstack/echo/v4"
"github.com/microcosm-cc/bluemonday"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -48,6 +47,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var (
skipNetworkIdleEvent bool
skipNetworkAlmostIdleEvent bool
failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64
ignoreResourceHttpStatusDomains []string
@@ -67,6 +67,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
form := ctx.FormData().
Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
Bool("skipNetworkAlmostIdleEvent", &skipNetworkAlmostIdleEvent, defaultOptions.SkipNetworkAlmostIdleEvent).
Custom("failOnHttpStatusCodes", func(value string) error {
if value == "" {
failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
@@ -148,7 +149,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
for i, cookie := range cookies {
if strings.TrimSpace(cookie.Name) == "" || strings.TrimSpace(cookie.Value) == "" || strings.TrimSpace(cookie.Domain) == "" {
err = multierr.Append(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
err = errors.Join(err, fmt.Errorf("cookie %d must have its name, value and domain set", i))
}
}
@@ -181,14 +182,12 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
if len(parts) == 2 && strings.ToLower(parts[0]) == "scope" && parts[1] != "" {
scope = parts[1]
} else {
err = multierr.Append(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
err = errors.Join(err, fmt.Errorf("invalid scope '%s' for header '%s'", scope, k))
invalidScopeToken = true
break
}
} else {
if token != "" {
valueTokens = append(valueTokens, token)
}
} else if token != "" {
valueTokens = append(valueTokens, token)
}
}
@@ -200,7 +199,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
if len(scope) > 0 {
p, errCompile := regexp2.Compile(scope, regexp2.None)
if errCompile != nil {
err = multierr.Append(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
err = errors.Join(err, fmt.Errorf("invalid scope regex pattern for header '%s': %w", k, errCompile))
continue
}
scopeRegexp = p
@@ -254,6 +253,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent,
SkipNetworkAlmostIdleEvent: skipNetworkAlmostIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
@@ -512,6 +512,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
}
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err)
@@ -542,6 +543,7 @@ func screenshotHtmlRoute(chromium Api) api.Route {
}
url := fmt.Sprintf("file://%s", inputPath)
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("HTML screenshot: %w", err)
@@ -598,6 +600,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err)
@@ -637,6 +640,7 @@ func screenshotMarkdownRoute(chromium Api) api.Route {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
}
options.AllowedFilePrefixes = []string{ctx.DirPath()}
err = screenshotUrl(ctx, chromium, url, options)
if err != nil {
return fmt.Errorf("markdown screenshot: %w", err)
@@ -662,7 +666,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
var path string
for _, markdownPath := range markdownPaths {
markdownFilename := filepath.Base(markdownPath)
markdownFilename := ctx.OriginalFilename(markdownPath)
if filename == markdownFilename {
path = markdownPath
@@ -671,7 +675,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
}
if path == "" {
markdownFilesNotFoundErr = multierr.Append(
markdownFilesNotFoundErr = errors.Join(
markdownFilesNotFoundErr,
fmt.Errorf("'%s'", filename),
)

View File

@@ -5,6 +5,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"time"
@@ -13,16 +14,15 @@ import (
"github.com/chromedp/cdproto/network"
"github.com/chromedp/cdproto/page"
"github.com/chromedp/chromedp"
"go.uber.org/zap"
)
func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
func printToPdfActionFunc(logger *slog.Logger, outputPath string, options PdfOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
paperHeight := options.PaperHeight
pageRanges := options.PageRanges
if options.SinglePage {
logger.Debug("single page PDF")
logger.DebugContext(ctx, "single page PDF")
_, _, _, _, _, cssContentSize, err := page.GetLayoutMetrics().Do(ctx)
if err != nil {
@@ -31,7 +31,9 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
// There are 96 CSS pixels per inch.
// See https://issues.chromium.org/issues/40267771#comment14.
paperHeight = cssContentSize.Height / 96
// We add top and bottom margins so that the content area
// is large enough to fit the entire content.
paperHeight = (cssContentSize.Height / 96) + options.MarginTop + options.MarginBottom
pageRanges = "1" // little dirty hack to avoid leftovers.
}
@@ -56,11 +58,11 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
options.FooterTemplate != DefaultPdfOptions().FooterTemplate
if !hasCustomHeaderFooter {
logger.Debug("no custom header nor footer")
logger.DebugContext(ctx, "no custom header nor footer")
printToPdf = printToPdf.WithDisplayHeaderFooter(false)
} else {
logger.Debug("with custom header and/or footer")
logger.DebugContext(ctx, "with custom header and/or footer")
printToPdf = printToPdf.
WithDisplayHeaderFooter(true).
@@ -68,7 +70,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
WithFooterTemplate(options.FooterTemplate)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", printToPdf))
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", printToPdf))
_, stream, err := printToPdf.Do(ctx)
if err != nil {
@@ -86,7 +88,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
defer func() {
err = reader.Close()
if err != nil {
logger.Error(fmt.Sprintf("close reader: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("close reader: %s", err))
}
}()
@@ -98,7 +100,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
@@ -113,7 +115,7 @@ func printToPdfActionFunc(logger *zap.Logger, outputPath string, options PdfOpti
}
}
func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
func captureScreenshotActionFunc(logger *slog.Logger, outputPath string, options ScreenshotOptions) chromedp.ActionFunc {
return func(ctx context.Context) error {
captureScreenshot := page.CaptureScreenshot().
WithCaptureBeyondViewport(true).
@@ -134,7 +136,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
WithQuality(int64(options.Quality))
}
logger.Debug(fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
logger.DebugContext(ctx, fmt.Sprintf("capture screenshot with: %+v", captureScreenshot))
buffer, err := captureScreenshot.Do(ctx)
if err != nil {
@@ -149,7 +151,7 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
defer func() {
err = file.Close()
if err != nil {
logger.Error(fmt.Sprintf("close output path: %s", err))
logger.ErrorContext(ctx, fmt.Sprintf("close output path: %s", err))
}
}()
@@ -162,9 +164,9 @@ func captureScreenshotActionFunc(logger *zap.Logger, outputPath string, options
}
}
func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.ActionFunc {
func setDeviceMetricsOverride(logger *slog.Logger, width, height int) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.Debug("set device metrics override")
logger.DebugContext(ctx, "set device metrics override")
err := emulation.SetDeviceMetricsOverride(int64(width), int64(height), 1.0, false).Do(ctx)
if err == nil {
@@ -175,15 +177,15 @@ func setDeviceMetricsOverride(logger *zap.Logger, width, height int) chromedp.Ac
}
}
func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
func clearCacheActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cache not cleared")
logger.DebugContext(ctx, "cache not cleared")
return nil
}
logger.Debug("clear cache")
logger.DebugContext(ctx, "clear cache")
err := network.ClearBrowserCache().Do(ctx)
if err == nil {
@@ -194,15 +196,15 @@ func clearCacheActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
}
}
func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc {
func clearCookiesActionFunc(logger *slog.Logger, clear bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/753.
if !clear {
logger.Debug("cookies not cleared")
logger.DebugContext(ctx, "cookies not cleared")
return nil
}
logger.Debug("clear cookies")
logger.DebugContext(ctx, "clear cookies")
err := network.ClearBrowserCookies().Do(ctx)
if err == nil {
@@ -213,15 +215,15 @@ func clearCookiesActionFunc(logger *zap.Logger, clear bool) chromedp.ActionFunc
}
}
func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.ActionFunc {
func disableJavaScriptActionFunc(logger *slog.Logger, disable bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/175.
if !disable {
logger.Debug("JavaScript not disabled")
logger.DebugContext(ctx, "JavaScript not disabled")
return nil
}
logger.Debug("disable JavaScript")
logger.DebugContext(ctx, "disable JavaScript")
err := emulation.SetScriptExecutionDisabled(true).Do(ctx)
if err == nil {
@@ -232,10 +234,10 @@ func disableJavaScriptActionFunc(logger *zap.Logger, disable bool) chromedp.Acti
}
}
func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionFunc {
func setCookiesActionFunc(logger *slog.Logger, cookies []Cookie) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(cookies) == 0 {
logger.Debug("no cookies to set")
logger.DebugContext(ctx, "no cookies to set")
return nil
}
@@ -274,21 +276,21 @@ func setCookiesActionFunc(logger *zap.Logger, cookies []Cookie) chromedp.ActionF
return fmt.Errorf("set cookie %s: %w", cookiePretty(cookieParams), err)
}
logger.Debug(fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
logger.DebugContext(ctx, fmt.Sprintf("set cookie %s", cookiePretty(cookieParams)))
}
return nil
}
}
func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc {
func userAgentOverride(logger *slog.Logger, userAgent string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if len(userAgent) == 0 {
logger.Debug("no user agent override")
logger.DebugContext(ctx, "no user agent override")
return nil
}
logger.Debug(fmt.Sprintf("user agent override: %s", userAgent))
logger.DebugContext(ctx, fmt.Sprintf("user agent override: %s", userAgent))
err := emulation.SetUserAgentOverride(userAgent).Do(ctx)
if err == nil {
return nil
@@ -303,32 +305,32 @@ func userAgentOverride(logger *zap.Logger, userAgent string) chromedp.ActionFunc
// network.SetExtraHTTPHeaders set the headers for ALL requests from the page.
// See https://github.com/gotenberg/gotenberg/issues/1011.
//
//func extraHttpHeadersActionFunc(logger *zap.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 {
// logger.Debug("no extra HTTP headers")
// return nil
// }
// func extraHttpHeadersActionFunc(logger *slog.Logger, extraHttpHeaders map[string]string) chromedp.ActionFunc {
// return func(ctx context.Context) error {
// if len(extraHttpHeaders) == 0 {
// logger.DebugContext(ctx,"no extra HTTP headers")
// return nil
// }
//
// logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
// logger.DebugContext(ctx,fmt.Sprintf("extra HTTP headers: %+v", extraHttpHeaders))
//
// headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders {
// headers[key] = value
// }
// headers := make(network.Headers, len(extraHttpHeaders))
// for key, value := range extraHttpHeaders {
// headers[key] = value
// }
//
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil {
// return nil
// }
// err := network.SetExtraHTTPHeaders(headers).Do(ctx)
// if err == nil {
// return nil
// }
//
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
//}
// return fmt.Errorf("set extra HTTP headers: %w", err)
// }
// }
func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent bool) chromedp.ActionFunc {
func navigateActionFunc(logger *slog.Logger, url string, skipNetworkIdleEvent, skipNetworkAlmostIdleEvent bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
logger.Debug(fmt.Sprintf("navigate to '%s'", url))
logger.DebugContext(ctx, fmt.Sprintf("navigate to '%s'", url))
_, _, _, _, err := page.Navigate(url).Do(ctx)
if err != nil {
@@ -344,7 +346,13 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo
if !skipNetworkIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkIdle(ctx, logger))
} else {
logger.Debug("skipping network idle event")
logger.DebugContext(ctx, "skipping network idle event")
}
if !skipNetworkAlmostIdleEvent {
waitFunc = append(waitFunc, waitForEventNetworkAlmostIdle(ctx, logger))
} else {
logger.DebugContext(ctx, "skipping network almost idle event")
}
err = runBatch(
@@ -360,11 +368,11 @@ func navigateActionFunc(logger *zap.Logger, url string, skipNetworkIdleEvent boo
}
}
func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
func hideDefaultWhiteBackgroundActionFunc(logger *slog.Logger, omitBackground, printBackground bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
// See https://github.com/gotenberg/gotenberg/issues/226.
if !omitBackground {
logger.Debug("default white background not hidden")
logger.DebugContext(ctx, "default white background not hidden")
return nil
}
@@ -373,7 +381,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
return fmt.Errorf("validate omit background: %w", ErrOmitBackgroundWithoutPrintBackground)
}
logger.Debug("hide default white background")
logger.DebugContext(ctx, "hide default white background")
err := emulation.SetDefaultBackgroundColorOverride().WithColor(
&cdp.RGBA{
@@ -391,7 +399,7 @@ func hideDefaultWhiteBackgroundActionFunc(logger *zap.Logger, omitBackground, pr
}
}
func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chromedp.ActionFunc {
func forceExactColorsActionFunc(logger *slog.Logger, printBackground bool) chromedp.ActionFunc {
return func(ctx context.Context) error {
css := "html { -webkit-print-color-adjust: exact !important; }"
if !printBackground {
@@ -399,7 +407,7 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
// print of the background, whatever the printToPDF args.
// See https://github.com/gotenberg/gotenberg/issues/1154.
additionalCss := "html, body { background: none !important; }"
logger.Debug(fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground))
logger.DebugContext(ctx, fmt.Sprintf("inject %s as printBackground is %t", additionalCss, printBackground))
css += additionalCss
}
@@ -423,10 +431,10 @@ func forceExactColorsActionFunc(logger *zap.Logger, printBackground bool) chrome
}
}
func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
func emulateMediaTypeActionFunc(logger *slog.Logger, mediaType string, mediaFeatures []EmulatedMediaFeature) chromedp.ActionFunc {
return func(ctx context.Context) error {
if mediaType == "" && len(mediaFeatures) == 0 {
logger.Debug("no emulated media type or features")
logger.DebugContext(ctx, "no emulated media type or features")
return nil
}
@@ -437,12 +445,12 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu
emulatedMedia := emulation.SetEmulatedMedia()
if mediaType != "" {
logger.Debug(fmt.Sprintf("emulate media type '%s'", mediaType))
logger.DebugContext(ctx, fmt.Sprintf("emulate media type '%s'", mediaType))
emulatedMedia = emulatedMedia.WithMedia(mediaType)
}
if len(mediaFeatures) > 0 {
logger.Debug(fmt.Sprintf("emulate media features %+v", mediaFeatures))
logger.DebugContext(ctx, fmt.Sprintf("emulate media features %+v", mediaFeatures))
features := make([]*emulation.MediaFeature, len(mediaFeatures))
for i, f := range mediaFeatures {
@@ -464,21 +472,21 @@ func emulateMediaTypeActionFunc(logger *zap.Logger, mediaType string, mediaFeatu
}
}
func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
func waitDelayBeforePrintActionFunc(logger *slog.Logger, disableJavaScript bool, delay time.Duration) chromedp.ActionFunc {
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait delay")
logger.DebugContext(ctx, "JavaScript disabled, skipping wait delay")
return nil
}
if delay <= 0 {
logger.Debug("no wait delay")
logger.DebugContext(ctx, "no wait delay")
return nil
}
// We wait for a given amount of time so that JavaScript
// scripts have a chance to finish before printing the page.
logger.Debug(fmt.Sprintf("wait '%s' before print", delay))
logger.DebugContext(ctx, fmt.Sprintf("wait '%s' before print", delay))
select {
case <-ctx.Done():
@@ -489,21 +497,21 @@ func waitDelayBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool,
}
}
func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
func waitForExpressionBeforePrintActionFunc(logger *slog.Logger, disableJavaScript bool, expression string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if disableJavaScript {
logger.Debug("JavaScript disabled, skipping wait expression")
logger.DebugContext(ctx, "JavaScript disabled, skipping wait expression")
return nil
}
if expression == "" {
logger.Debug("no wait expression")
logger.DebugContext(ctx, "no wait expression")
return nil
}
// We wait until the evaluation of the expression is true or
// until the context is done.
logger.Debug(fmt.Sprintf("wait until '%s' is true before print", expression))
logger.DebugContext(ctx, fmt.Sprintf("wait until '%s' is true before print", expression))
ticker := time.NewTicker(time.Duration(100) * time.Millisecond)
for {
@@ -531,14 +539,14 @@ func waitForExpressionBeforePrintActionFunc(logger *zap.Logger, disableJavaScrip
}
}
func waitForSelectorVisibleBeforePrintActionFunc(logger *zap.Logger, selector string) chromedp.ActionFunc {
func waitForSelectorVisibleBeforePrintActionFunc(logger *slog.Logger, selector string) chromedp.ActionFunc {
return func(ctx context.Context) error {
if selector == "" {
logger.Debug("no wait selector")
logger.DebugContext(ctx, "no wait selector")
return nil
}
logger.Debug(fmt.Sprintf("wait until '%s' is visible before print", selector))
logger.DebugContext(ctx, fmt.Sprintf("wait until '%s' is visible before print", selector))
err := chromedp.WaitVisible(selector, chromedp.ByQuery, chromedp.RetryInterval(time.Duration(100)*time.Millisecond)).Do(ctx)
if err != nil {
return fmt.Errorf("wait visible: %v: %w", err, ErrInvalidSelectorQuery)

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"reflect"
@@ -11,7 +12,9 @@ import (
"syscall"
"github.com/barasher/go-exiftool"
"go.uber.org/zap"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -74,64 +77,125 @@ func (engine *ExifTool) Debug() map[string]any {
}
// Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Split is not available in this implementation.
func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Flatten is not available in this implementation.
func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata extracts the metadata of a given PDF file.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
return nil, fmt.Errorf("new ExifTool: %w", err)
err = fmt.Errorf("new ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetStatus(codes.Ok, "")
return fileMetadata[0].Fields, nil
}
// WriteMetadata writes the metadata into a given PDF file.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil {
return fmt.Errorf("new ExifTool: %w", err)
err = fmt.Errorf("new ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close()
if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err))
logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
}
}(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil {
return fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Define a list of derived, system, or computed tags that ExifTool
@@ -161,6 +225,17 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
delete(fileMetadata[0].Fields, tag)
}
// Filter user-supplied metadata to prevent ExifTool pseudo-tags from
// triggering dangerous side effects like file renames or moves.
// See https://exiftool.org/TagNames/Extra.html.
dangerousTags := []string{
"FileName", // Writing this triggers a file rename in ExifTool
"Directory", // Writing this triggers a file move in ExifTool
}
for _, tag := range dangerousTags {
delete(metadata, tag)
}
for key, value := range metadata {
switch val := value.(type) {
case string:
@@ -175,7 +250,10 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
strs[i] = str
continue
}
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeFor[[]any](), gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
fileMetadata[0].SetStrings(key, strs)
case bool:
@@ -191,82 +269,174 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
// TODO: support more complex cases, e.g., arrays and nested objects
// (limitations in underlying library).
default:
return fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
err = fmt.Errorf("write PDF metadata with ExifTool: %s %+v %s %w", key, val, reflect.TypeOf(val), gotenberg.ErrPdfEngineMetadataValueNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
}
exifTool.WriteMetadata(fileMetadata)
if fileMetadata[0].Err != nil {
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// 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) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
if err != nil {
return 0, fmt.Errorf("read metadata with ExifTool: %w", err)
err = fmt.Errorf("read metadata with ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
pageCountValue, ok := metadata["PageCount"]
if !ok {
return 0, errors.New("PageCount not found in metadata")
err = errors.New("PageCount not found in metadata")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
switch val := pageCountValue.(type) {
case int:
span.SetStatus(codes.Ok, "")
return val, nil
case int64:
span.SetStatus(codes.Ok, "")
return int(val), nil
case float64:
span.SetStatus(codes.Ok, "")
return int(val), nil
case string:
var res int
_, err := fmt.Sscanf(val, "%d", &res)
if err != nil {
return 0, fmt.Errorf("parse PageCount string '%s': %w", val, err)
err = fmt.Errorf("parse PageCount string '%s': %w", val, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
span.SetStatus(codes.Ok, "")
return res, nil
default:
return 0, fmt.Errorf("unexpected PageCount type '%T'", pageCountValue)
err = fmt.Errorf("unexpected PageCount type '%T'", pageCountValue)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
}
// WriteBookmarks is not available in this implementation.
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadBookmarks is not available in this implementation.
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// EmbedFiles is not available in this implementation.
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Watermark is not available in this implementation.
func (engine *ExifTool) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Stamp is not available in this implementation.
func (engine *ExifTool) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("stamp PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Rotate is not available in this implementation.
func (engine *ExifTool) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
_, span := gotenberg.Tracer().Start(ctx, "exiftool.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("rotate PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Interface guards.

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"strings"
@@ -12,8 +13,11 @@ import (
"github.com/alexliesenfeld/health"
flag "github.com/spf13/pflag"
"go.uber.org/multierr"
"go.uber.org/zap"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -45,9 +49,15 @@ type Api struct {
autoStart bool
args libreOfficeArguments
logger *zap.Logger
logger *slog.Logger
libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
}
// Options gathers available options when converting a document to PDF.
@@ -129,6 +139,66 @@ type Options struct {
// one page.
SinglePageSheets bool
// InitialView specifies how the PDF document should be displayed when
// opened. 0 = neither outlines nor thumbnails, 1 = outline pane open,
// 2 = thumbnail pane open.
InitialView int
// InitialPage specifies the page on which the PDF document should be
// opened in the viewer.
InitialPage int
// Magnification specifies the action to be performed when the PDF document
// is opened. 0 = default, 1 = fit entire page, 2 = fit page width,
// 3 = fit visible, 4 = use zoom value from Zoom property.
Magnification int
// Zoom specifies the zoom level the PDF document is opened with. Only
// used if Magnification is set to 4.
Zoom int
// PageLayout specifies the page layout when the document is opened.
// 0 = default, 1 = single page, 2 = one column, 3 = two columns.
PageLayout int
// FirstPageOnLeft is used with PageLayout value 3. If true, the first
// page is displayed on the left side.
FirstPageOnLeft bool
// ResizeWindowToInitialPage specifies that the PDF viewer window is
// resized to show the whole initial page.
ResizeWindowToInitialPage bool
// CenterWindow specifies that the PDF viewer window is centered on the
// screen.
CenterWindow bool
// OpenInFullScreenMode specifies that the PDF viewer window is opened
// full screen.
OpenInFullScreenMode bool
// DisplayPDFDocumentTitle specifies that the document title is displayed
// in the PDF viewer title bar.
DisplayPDFDocumentTitle bool
// HideViewerMenubar specifies whether to hide the PDF viewer menubar.
HideViewerMenubar bool
// HideViewerToolbar specifies whether to hide the PDF viewer toolbar.
HideViewerToolbar bool
// HideViewerWindowControls specifies whether to hide the PDF viewer
// window controls.
HideViewerWindowControls bool
// UseTransitionEffects specifies that slide transitions are exported to
// PDF. Only active for Impress documents.
UseTransitionEffects bool
// OpenBookmarkLevels specifies how many bookmark levels should be opened
// in the reader. -1 = all levels, 1-10 = specific level.
OpenBookmarkLevels int
// LosslessImageCompression specifies if images are exported to PDF using
// a lossless compression format like PNG or compressed using the JPEG
// format.
@@ -197,6 +267,21 @@ func DefaultOptions() Options {
SkipEmptyPages: false,
AddOriginalDocumentAsStream: false,
SinglePageSheets: false,
InitialView: 0,
InitialPage: 1,
Magnification: 0,
Zoom: 100,
PageLayout: 0,
FirstPageOnLeft: false,
ResizeWindowToInitialPage: false,
CenterWindow: false,
OpenInFullScreenMode: false,
DisplayPDFDocumentTitle: true,
HideViewerMenubar: false,
HideViewerToolbar: false,
HideViewerWindowControls: false,
UseTransitionEffects: true,
OpenBookmarkLevels: -1,
LosslessImageCompression: false,
Quality: 90,
ReduceImageResolution: false,
@@ -216,7 +301,7 @@ func DefaultOptions() Options {
// Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
Extensions() []string
}
@@ -239,6 +324,7 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("api", flag.ExitOnError)
fs.Int64("libreoffice-restart-after", 10, "Number of conversions after which LibreOffice will automatically restart. Set to 0 to disable this feature")
fs.Int64("libreoffice-max-queue-size", 0, "Maximum request queue size for LibreOffice. Set to 0 to disable this feature")
fs.Duration("libreoffice-idle-shutdown-timeout", 0, "Shutdown LibreOffice after being idle for the given duration. Set to 0 to disable this feature")
fs.Bool("libreoffice-auto-start", false, "Automatically launch LibreOffice upon initialization if set to true; otherwise, LibreOffice will start at the time of the first conversion")
fs.Duration("libreoffice-start-timeout", time.Duration(20)*time.Second, "Maximum duration to wait for LibreOffice to start or restart")
@@ -270,19 +356,107 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
}
// Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger.Named("libreoffice")
a.logger = gotenberg.Logger(a).With(slog.String("logger", "libreoffice"))
// Process.
a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1, flags.MustDuration("libreoffice-idle-shutdown-timeout"))
// 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
}
@@ -293,12 +467,12 @@ func (a *Api) Validate() error {
_, statErr := os.Stat(a.args.binPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("LibreOffice binary path does not exist: %w", statErr))
}
_, statErr = os.Stat(a.args.unoBinPath)
if os.IsNotExist(statErr) {
err = multierr.Append(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
err = errors.Join(err, fmt.Errorf("unoconverter binary path does not exist: %w", statErr))
}
return err
@@ -332,7 +506,7 @@ func (a *Api) StartupMessage() string {
func (a *Api) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully
// stop before we do a shutdown.
a.logger.Debug("wait for the end of grace duration")
a.logger.DebugContext(ctx, "wait for the end of grace duration")
<-ctx.Done()
@@ -431,21 +605,76 @@ func (a *Api) LibreOffice() (Uno, error) {
}
// Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
ctx, span := gotenberg.Tracer().Start(ctx, "libreoffice.Pdf",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(a.args.binPath)),
)
defer span.End()
start := time.Now()
var conversionStart time.Time
err := a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
})
// Determine status and error reason.
status := "success"
reason := ""
if err != nil {
switch {
case errors.Is(err, context.DeadlineExceeded):
status = "timeout"
reason = "timeout"
case errors.Is(err, context.Canceled):
status = "error"
reason = "context_cancelled"
case errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting):
status = "error"
reason = "libreoffice_unavailable"
default:
status = "error"
reason = "unknown"
}
}
// Record metrics.
attrs := metric.WithAttributes(attribute.String("status", status))
a.reqsCounter.Add(ctx, 1, attrs)
if reason != "" {
a.errsCounter.Add(ctx, 1, metric.WithAttributes(attribute.String("reason", reason)))
}
if !conversionStart.IsZero() {
queueWait := conversionStart.Sub(start).Seconds()
a.queueWaitDurationCounter.Record(ctx, queueWait, attrs)
conversionDuration := time.Since(conversionStart).Seconds()
a.conversionDurationCounter.Record(ctx, conversionDuration, attrs)
}
if err == nil {
stat, statErr := os.Stat(outputPath)
if statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, stat.Size(), attrs)
}
span.SetStatus(codes.Ok, "")
return nil
}
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err))
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
return a.Pdf(ctx, logger, inputPath, outputPath, options)
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("supervisor run task: %w", err)
}

View File

@@ -1,14 +1,14 @@
package api
import (
"context"
"fmt"
"log/slog"
"net"
"strconv"
"go.uber.org/zap"
)
func freePort(logger *zap.Logger) (int, error) {
func freePort(logger *slog.Logger) (int, error) {
netListener, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
return 0, fmt.Errorf("listen on the local network address: %w", err)
@@ -16,7 +16,7 @@ func freePort(logger *zap.Logger) (int, error) {
defer func() {
err := netListener.Close()
if err != nil {
logger.Error(fmt.Sprintf("close network listener: %s", err.Error()))
logger.ErrorContext(context.Background(), fmt.Sprintf("close network listener: %s", err.Error()))
}
}()

View File

@@ -4,6 +4,7 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"net"
"os"
"strings"
@@ -11,14 +12,12 @@ import (
"sync/atomic"
"time"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
type libreOffice interface {
gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
}
type libreOfficeArguments struct {
@@ -48,7 +47,7 @@ func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
return p
}
func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
if p.isStarted.Load() {
return errors.New("LibreOffice is already started")
}
@@ -86,7 +85,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("execute LibreOffice: %w", err)
}
logger.Debug("got exit code 81, e.g., LibreOffice first start")
logger.DebugContext(context.Background(), "got exit code 81, e.g., LibreOffice first start")
// Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
@@ -123,7 +122,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
connChan <- nil
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
logger.DebugContext(context.Background(), fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
}
break
@@ -148,19 +147,19 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
// Let's make sure the process is killed.
err = cmd.Kill()
if err != nil {
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err))
logger.DebugContext(context.Background(), fmt.Sprintf("kill LibreOffice process: %v", err))
}
// And the user profile directory is deleted.
err = os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
}
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}()
logger.Debug("waiting for the LibreOffice socket to be available...")
logger.DebugContext(context.Background(), "waiting for the LibreOffice socket to be available...")
for {
select {
@@ -169,7 +168,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("LibreOffice socket not available: %w", err)
}
logger.Debug("LibreOffice socket available")
logger.DebugContext(context.Background(), "LibreOffice socket available")
success = true
return nil
@@ -179,7 +178,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
}
}
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
func (p *libreOfficeProcess) Stop(logger *slog.Logger) error {
if !p.isStarted.Load() {
// No big deal? Like calling cancel twice.
return nil
@@ -192,15 +191,15 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
go func() {
err := os.RemoveAll(userProfileDirPath)
if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} else {
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}
// Also, remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil {
logger.Error(err.Error())
logger.ErrorContext(context.Background(), err.Error())
}
}()
}(copyUserProfileDirPath, expirationTime)
@@ -221,7 +220,7 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
return nil
}
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
func (p *libreOfficeProcess) Healthy(logger *slog.Logger) bool {
// Good to know: the supervisor does not call this method if no first start
// or if the process is restarting.
@@ -237,7 +236,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
if err == nil {
err = conn.Close()
if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
logger.DebugContext(context.Background(), fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
}
return true
@@ -246,7 +245,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
return false
}
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
if !p.isStarted.Load() {
return errors.New("LibreOffice not started, cannot handle PDF conversion")
}
@@ -259,8 +258,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity")
if checkedEntry != nil {
if logger.Enabled(ctx, slog.LevelDebug) {
args = append(args, "-vvv")
}
@@ -297,6 +295,21 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--export", fmt.Sprintf("IsSkipEmptyPages=%t", options.SkipEmptyPages))
args = append(args, "--export", fmt.Sprintf("IsAddStream=%t", options.AddOriginalDocumentAsStream))
args = append(args, "--export", fmt.Sprintf("SinglePageSheets=%t", options.SinglePageSheets))
args = append(args, "--export", fmt.Sprintf("InitialView=%d", options.InitialView))
args = append(args, "--export", fmt.Sprintf("InitialPage=%d", options.InitialPage))
args = append(args, "--export", fmt.Sprintf("Magnification=%d", options.Magnification))
args = append(args, "--export", fmt.Sprintf("Zoom=%d", options.Zoom))
args = append(args, "--export", fmt.Sprintf("PageLayout=%d", options.PageLayout))
args = append(args, "--export", fmt.Sprintf("FirstPageOnLeft=%t", options.FirstPageOnLeft))
args = append(args, "--export", fmt.Sprintf("ResizeWindowToInitialPage=%t", options.ResizeWindowToInitialPage))
args = append(args, "--export", fmt.Sprintf("CenterWindow=%t", options.CenterWindow))
args = append(args, "--export", fmt.Sprintf("OpenInFullScreenMode=%t", options.OpenInFullScreenMode))
args = append(args, "--export", fmt.Sprintf("DisplayPDFDocumentTitle=%t", options.DisplayPDFDocumentTitle))
args = append(args, "--export", fmt.Sprintf("HideViewerMenubar=%t", options.HideViewerMenubar))
args = append(args, "--export", fmt.Sprintf("HideViewerToolbar=%t", options.HideViewerToolbar))
args = append(args, "--export", fmt.Sprintf("HideViewerWindowControls=%t", options.HideViewerWindowControls))
args = append(args, "--export", fmt.Sprintf("UseTransitionEffects=%t", options.UseTransitionEffects))
args = append(args, "--export", fmt.Sprintf("OpenBookmarkLevels=%d", options.OpenBookmarkLevels))
args = append(args, "--export", fmt.Sprintf("UseLosslessCompression=%t", options.LosslessImageCompression))
args = append(args, "--export", fmt.Sprintf("Quality=%d", options.Quality))
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
@@ -362,7 +375,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
return fmt.Errorf("create uno command: %w", err)
}
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options))
logger.DebugContext(ctx, fmt.Sprintf("print to PDF with: %+v", options))
exitCode, err := cmd.Exec()
if err == nil {

View File

@@ -3,19 +3,18 @@ package api
import (
"context"
"errors"
"go.uber.org/zap"
"log/slog"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
// ApiMock is a mock for the [Uno] interface.
type ApiMock struct {
PdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
PdfMock func(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
ExtensionsMock func() []string
}
func (api *ApiMock) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (api *ApiMock) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
return api.PdfMock(ctx, logger, inputPath, outputPath, options)
}
@@ -37,10 +36,10 @@ type libreOfficeMock struct {
errCoreDumpedCount int
gotenberg.ProcessMock
pdfMock func(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error
pdfMock func(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
}
func (b *libreOfficeMock) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error {
func (b *libreOfficeMock) pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
err := b.pdfMock(ctx, logger, inputPath, outputPath, options)
if errors.Is(err, ErrCoreDumped) {
b.errCoreDumpedCount += 1

View File

@@ -4,8 +4,7 @@ import (
"context"
"errors"
"fmt"
"go.uber.org/zap"
"log/slog"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
@@ -47,17 +46,17 @@ func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
}
// Merge is not available in this implementation.
func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (engine *LibreOfficePdfEngine) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Split is not available in this implementation.
func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (engine *LibreOfficePdfEngine) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Flatten is not available in this implementation.
func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
@@ -65,7 +64,7 @@ func (engine *LibreOfficePdfEngine) Flatten(ctx context.Context, logger *zap.Log
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
// error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
opts := api.DefaultOptions()
opts.PdfFormats = formats
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
@@ -82,52 +81,52 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log
}
// ReadMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
func (engine *LibreOfficePdfEngine) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteMetadata is not available in this implementation.
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
func (engine *LibreOfficePdfEngine) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// PageCount is not available in this implementation.
func (engine *LibreOfficePdfEngine) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
func (engine *LibreOfficePdfEngine) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// WriteBookmarks is not available in this implementation.
func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Encrypt is not available in this implementation.
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// EmbedFiles is not available in this implementation.
func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Watermark is not available in this implementation.
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Stamp is not available in this implementation.
func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("stamp PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Rotate is not available in this implementation.
func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
func (engine *LibreOfficePdfEngine) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}

View File

@@ -67,6 +67,21 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution int
nativeWatermarkText string
nativeWatermarkColor int
initialView int
initialPage int
magnification int
zoom int
pageLayout int
firstPageOnLeft bool
resizeWindowToInitialPage bool
centerWindow bool
openInFullScreenMode bool
displayPDFDocumentTitle bool
hideViewerMenubar bool
hideViewerToolbar bool
hideViewerWindowControls bool
useTransitionEffects bool
openBookmarkLevels int
nativeWatermarkFontHeight int
nativeWatermarkRotateAngle int
nativeWatermarkFontName string
@@ -97,6 +112,105 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Bool("skipEmptyPages", &skipEmptyPages, defaultOptions.SkipEmptyPages).
Bool("addOriginalDocumentAsStream", &addOriginalDocumentAsStream, defaultOptions.AddOriginalDocumentAsStream).
Bool("singlePageSheets", &singlePageSheets, defaultOptions.SinglePageSheets).
Custom("initialView", func(value string) error {
if value == "" {
initialView = defaultOptions.InitialView
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if !slices.Contains([]int{0, 1, 2}, intValue) {
return errors.New("value is not 0, 1 or 2")
}
initialView = intValue
return nil
}).
Custom("initialPage", func(value string) error {
if value == "" {
initialPage = defaultOptions.InitialPage
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
initialPage = intValue
return nil
}).
Custom("magnification", func(value string) error {
if value == "" {
magnification = defaultOptions.Magnification
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if !slices.Contains([]int{0, 1, 2, 3, 4}, intValue) {
return errors.New("value is not 0, 1, 2, 3 or 4")
}
magnification = intValue
return nil
}).
Custom("zoom", func(value string) error {
if value == "" {
zoom = defaultOptions.Zoom
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 1 {
return errors.New("value is inferior to 1")
}
zoom = intValue
return nil
}).
Custom("pageLayout", func(value string) error {
if value == "" {
pageLayout = defaultOptions.PageLayout
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if !slices.Contains([]int{0, 1, 2, 3}, intValue) {
return errors.New("value is not 0, 1, 2 or 3")
}
pageLayout = intValue
return nil
}).
Bool("firstPageOnLeft", &firstPageOnLeft, defaultOptions.FirstPageOnLeft).
Bool("resizeWindowToInitialPage", &resizeWindowToInitialPage, defaultOptions.ResizeWindowToInitialPage).
Bool("centerWindow", &centerWindow, defaultOptions.CenterWindow).
Bool("openInFullScreenMode", &openInFullScreenMode, defaultOptions.OpenInFullScreenMode).
Bool("displayPDFDocumentTitle", &displayPDFDocumentTitle, defaultOptions.DisplayPDFDocumentTitle).
Bool("hideViewerMenubar", &hideViewerMenubar, defaultOptions.HideViewerMenubar).
Bool("hideViewerToolbar", &hideViewerToolbar, defaultOptions.HideViewerToolbar).
Bool("hideViewerWindowControls", &hideViewerWindowControls, defaultOptions.HideViewerWindowControls).
Bool("useTransitionEffects", &useTransitionEffects, defaultOptions.UseTransitionEffects).
Custom("openBookmarkLevels", func(value string) error {
if value == "" {
openBookmarkLevels = defaultOptions.OpenBookmarkLevels
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue != -1 && (intValue < 1 || intValue > 10) {
return errors.New("value is not -1 or between 1 and 10")
}
openBookmarkLevels = intValue
return nil
}).
Bool("losslessImageCompression", &losslessImageCompression, defaultOptions.LosslessImageCompression).
Custom("quality", func(value string) error {
if value == "" {
@@ -227,6 +341,21 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
SkipEmptyPages: skipEmptyPages,
AddOriginalDocumentAsStream: addOriginalDocumentAsStream,
SinglePageSheets: singlePageSheets,
InitialView: initialView,
InitialPage: initialPage,
Magnification: magnification,
Zoom: zoom,
PageLayout: pageLayout,
FirstPageOnLeft: firstPageOnLeft,
ResizeWindowToInitialPage: resizeWindowToInitialPage,
CenterWindow: centerWindow,
OpenInFullScreenMode: openInFullScreenMode,
DisplayPDFDocumentTitle: displayPDFDocumentTitle,
HideViewerMenubar: hideViewerMenubar,
HideViewerToolbar: hideViewerToolbar,
HideViewerWindowControls: hideViewerWindowControls,
UseTransitionEffects: useTransitionEffects,
OpenBookmarkLevels: openBookmarkLevels,
LosslessImageCompression: losslessImageCompression,
Quality: quality,
ReduceImageResolution: reduceImageResolution,
@@ -291,7 +420,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
// document.docx -> document.docx.pdf, so that split naming
// document.docx_0.pdf, etc.
for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
originalName := ctx.OriginalFilename(inputPath)
outputPath := ctx.GeneratePathFromFilename(originalName + ".pdf")
err = ctx.Rename(outputPaths[i], outputPath)
if err != nil {
@@ -373,7 +503,8 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
if len(outputPaths) > 1 && splitMode == zeroValuedSplitMode {
// If .zip archive, document.docx -> document.docx.pdf.
for i, inputPath := range inputPaths {
outputPath := fmt.Sprintf("%s.pdf", inputPath)
originalName := ctx.OriginalFilename(inputPath)
outputPath := ctx.GeneratePathFromFilename(originalName + ".pdf")
err = ctx.Rename(outputPaths[i], outputPath)
if err != nil {

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"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
@@ -14,7 +15,9 @@ import (
"strings"
"syscall"
"go.uber.org/zap"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -96,26 +99,45 @@ func (engine *PdfCpu) Debug() map[string]any {
}
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
args := make([]string, 0, 2+len(inputPaths))
args = append(args, "merge", outputPath)
args = append(args, inputPaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err == nil {
span.SetStatus(codes.Ok, "")
return nil
}
return fmt.Errorf("merge PDFs with pdfcpu: %w", err)
err = fmt.Errorf("merge PDFs with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Split splits a given PDF file.
func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
var args []string
switch mode.Mode {
@@ -129,17 +151,26 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
}
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
err := fmt.Errorf("split PDFs using mode '%s' with pdfcpu: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with pdfcpu: %w", err)
err = fmt.Errorf("split PDFs with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
var outputPaths []string
@@ -156,52 +187,110 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
return nil
})
if err != nil {
return nil, fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
err = fmt.Errorf("walk directory to find resulting PDFs from split with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
sort.Sort(digitSuffixSort(outputPaths))
span.SetStatus(codes.Ok, "")
return outputPaths, nil
}
// Flatten is not available in this implementation.
func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// WriteMetadata is not available in this implementation.
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// PageCount is not available in this implementation.
func (engine *PdfCpu) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfCpu) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdfcpu.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("page count with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
// ReadBookmarks reads the document outline (bookmarks) of a PDF file using pdfcpu.
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
args := []string{"bookmarks", "export", inputPath, tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
defer func() {
err := os.Remove(tmpPath)
if err != nil && !os.IsNotExist(err) {
logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
logger.ErrorContext(ctx, fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
}
}()
@@ -214,41 +303,55 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inp
// If the file wasn't created, or it was created but is 0 bytes,
// it means pdfcpu had no bookmarks to write.
if os.IsNotExist(statErr) || (statErr == nil && info.Size() == 0) {
span.SetStatus(codes.Ok, "")
return make([]gotenberg.Bookmark, 0), nil
}
// Fallback: Check the error string just in case pdfcpu failed without
// touching the file.
if strings.Contains(strings.ToLower(cmdErr.Error()), "no bookmarks") {
span.SetStatus(codes.Ok, "")
return make([]gotenberg.Bookmark, 0), nil
}
return nil, fmt.Errorf("read bookmarks with pdfcpu: %w", cmdErr)
err = fmt.Errorf("read bookmarks with pdfcpu: %w", cmdErr)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// If cmd succeeded, but output a 0-byte file anyway.
if info != nil && info.Size() == 0 {
span.SetStatus(codes.Ok, "")
return make([]gotenberg.Bookmark, 0), nil
}
// Read the file content.
jsonBytes, err := os.ReadFile(tmpPath)
if err != nil {
return nil, fmt.Errorf("read temporary bookmarks JSON file: %w", err)
err = fmt.Errorf("read temporary bookmarks JSON file: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Check if the content is just empty whitespace.
if len(bytes.TrimSpace(jsonBytes)) == 0 {
span.SetStatus(codes.Ok, "")
return make([]gotenberg.Bookmark, 0), nil
}
var data pdfcpuBookmarks
err = json.Unmarshal(jsonBytes, &data)
if err != nil {
return nil, fmt.Errorf("unmarshal bookmarks: %w", err)
err = fmt.Errorf("unmarshal bookmarks: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Safety check: Does the parsed JSON actually contain bookmarks?
if len(data.Bookmarks) == 0 {
span.SetStatus(codes.Ok, "")
return make([]gotenberg.Bookmark, 0), nil
}
@@ -265,12 +368,20 @@ func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inp
return res
}
span.SetStatus(codes.Ok, "")
return mapBookmarks(data.Bookmarks), nil
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using pdfcpu.
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if len(bookmarks) == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
@@ -293,44 +404,64 @@ func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, in
jsonBytes, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal bookmarks: %w", err)
err = fmt.Errorf("marshal bookmarks: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
tmpPath := fmt.Sprintf("%s.json", inputPath)
err = os.WriteFile(tmpPath, jsonBytes, 0o600)
if err != nil {
return fmt.Errorf("write temporary bookmarks JSON file: %w", err)
err = fmt.Errorf("write temporary bookmarks JSON file: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer func() {
err := os.Remove(tmpPath)
if err != nil {
logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
logger.ErrorContext(ctx, fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
}
}()
args := []string{"bookmarks", "import", "-replace", inputPath, tmpPath, inputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("write bookmarks with pdfcpu: %w", err)
err = fmt.Errorf("write bookmarks with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if len(filePaths) == 0 {
span.SetStatus(codes.Ok, "")
return nil
}
logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
logger.DebugContext(ctx, fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
args := make([]string, 0, 3+len(filePaths))
args = append(args, "attachments", "add", inputPath)
@@ -338,21 +469,37 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePa
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command for attaching files: %w", err)
err = fmt.Errorf("create command for attaching files: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("attach files with pdfcpu: %w", err)
err = fmt.Errorf("attach files with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// Encrypt adds password protection to a PDF file using pdfcpu.
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if userPassword == "" {
return errors.New("user password cannot be empty")
err := errors.New("user password cannot be empty")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if ownerPassword == "" {
@@ -369,29 +516,70 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("encrypt PDF with pdfcpu: %w", err)
err = fmt.Errorf("encrypt PDF with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// 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 {
return engine.applyStampOrWatermark(ctx, logger, "watermark", inputPath, stamp)
func (engine *PdfCpu) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := engine.applyStampOrWatermark(ctx, logger, "watermark", inputPath, stamp)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// 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 {
return engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp)
func (engine *PdfCpu) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := engine.applyStampOrWatermark(ctx, logger, "stamp", inputPath, stamp)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// 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 {
ctx, span := gotenberg.Tracer().Start(ctx, "pdfcpu.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
args := []string{"rotate"}
if pages != "" {
args = append(args, "-pages", pages)
@@ -400,18 +588,25 @@ func (engine *PdfCpu) Rotate(ctx context.Context, logger *zap.Logger, inputPath
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("rotate PDF with pdfcpu: %w", err)
err = fmt.Errorf("rotate PDF with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error {
func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *slog.Logger, command string, inputPath string, stamp gotenberg.Stamp) error {
var mode string
switch stamp.Source {
case gotenberg.StampSourceText:

View File

@@ -0,0 +1,14 @@
# Adding PDF Engine Features
When adding a new PDF engine capability (e.g., bookmarks, watermark, stamp, embed), you must update the Makefile to include the corresponding engine list variable and flag. Every `--pdfengines-*-engines` flag registered in `pkg/modules/pdfengines/pdfengines.go` must have a matching entry in the Makefile:
1. **Add a variable** in the Makefile's variable block (around line 60-70):
```makefile
PDFENGINES_<FEATURE>_ENGINES=<default engines>
```
2. **Add the flag** in the Makefile's command args block (around line 140-155):
```makefile
--pdfengines-<feature>-engines=$(PDFENGINES_<FEATURE>_ENGINES) \
```
The default value should match what is defined in `pdfengines.go`'s `fs.StringSlice(...)` call for that flag.

View File

@@ -2,11 +2,13 @@ package pdfengines
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"go.uber.org/multierr"
"go.uber.org/zap"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -61,7 +63,13 @@ func newMultiPdfEngines(
// Merge combines multiple PDF files into a single document using the first
// available engine that supports PDF merging.
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
//
//nolint:dupl
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -72,8 +80,10 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
select {
case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr)
if !errored {
if mergeErr != nil {
err = errors.Join(err, mergeErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -81,7 +91,11 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
}
}
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type splitResult struct {
@@ -91,7 +105,11 @@ type splitResult struct {
// Split divides the PDF into separate pages using the first available engine
// that supports PDF splitting.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
@@ -107,9 +125,10 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.outputPaths, nil
}
case <-ctx.Done():
@@ -117,12 +136,20 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
}
}
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err)
err = fmt.Errorf("split PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Flatten merges existing annotation appearances with page content using the
// first available engine that supports flattening.
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -133,8 +160,10 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i
select {
case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr)
if !errored {
if mergeErr != nil {
err = errors.Join(err, mergeErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -142,12 +171,20 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i
}
}
return fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -158,8 +195,10 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
select {
case mergeErr := <-errChan:
errored := multierr.AppendInto(&err, mergeErr)
if !errored {
if mergeErr != nil {
err = errors.Join(err, mergeErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -167,7 +206,11 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
}
}
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type readMetadataResult struct {
@@ -177,7 +220,13 @@ type readMetadataResult struct {
// ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
//
//nolint:dupl
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
@@ -193,9 +242,10 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.metadata, nil
}
case <-ctx.Done():
@@ -203,12 +253,20 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
}
}
return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// WriteMetadata embeds metadata into a PDF file using the first available
// engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -219,8 +277,10 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
select {
case writeMetadataErr := <-errChan:
errored := multierr.AppendInto(&err, writeMetadataErr)
if !errored {
if writeMetadataErr != nil {
err = errors.Join(err, writeMetadataErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -228,7 +288,11 @@ func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Log
}
}
return fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
err = fmt.Errorf("write PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
type pageCountResult struct {
@@ -238,7 +302,11 @@ type pageCountResult struct {
// PageCount returns the number of pages in a PDF file using the first available
// engine that supports metadata reading.
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.PageCount", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
@@ -254,9 +322,10 @@ func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger,
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.pageCount, nil
}
case <-ctx.Done():
@@ -264,7 +333,11 @@ func (multi *multiPdfEngines) PageCount(ctx context.Context, logger *zap.Logger,
}
}
return 0, fmt.Errorf("page count with multi PDF engines: %w", err)
err = fmt.Errorf("page count with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
type readBookmarksResult struct {
@@ -274,7 +347,13 @@ type readBookmarksResult struct {
// ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading.
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
//
//nolint:dupl
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.ReadBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
var mu sync.Mutex // to safely append errors.
@@ -290,9 +369,10 @@ func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Log
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
err = errors.Join(err, result.err)
mu.Unlock()
} else {
span.SetStatus(codes.Ok, "")
return result.bookmarks, nil
}
case <-ctx.Done():
@@ -300,12 +380,20 @@ func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Log
}
}
return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
err = fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
// first available engine that supports bookmarks writing.
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.WriteBookmarks", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -316,8 +404,10 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Lo
select {
case writeBookmarksErr := <-errChan:
errored := multierr.AppendInto(&err, writeBookmarksErr)
if !errored {
if writeBookmarksErr != nil {
err = errors.Join(err, writeBookmarksErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -325,12 +415,20 @@ func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Lo
}
}
return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
err = fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -341,8 +439,10 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
select {
case protectErr := <-errChan:
errored := multierr.AppendInto(&err, protectErr)
if !errored {
if protectErr != nil {
err = errors.Join(err, protectErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -350,12 +450,22 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
}
}
return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// EmbedFiles embeds files into a PDF using the first available
// engine that supports file embedding.
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
//
//nolint:dupl
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.EmbedFiles", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -366,8 +476,10 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger
select {
case embedErr := <-errChan:
errored := multierr.AppendInto(&err, embedErr)
if !errored {
if embedErr != nil {
err = errors.Join(err, embedErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -375,12 +487,22 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger
}
}
return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
err = fmt.Errorf("embed files into PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Watermark applies a watermark (behind page content) to a PDF file using the
// first available engine that supports watermarking.
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
//
//nolint:dupl
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Watermark", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -391,8 +513,10 @@ func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger,
select {
case watermarkErr := <-errChan:
errored := multierr.AppendInto(&err, watermarkErr)
if !errored {
if watermarkErr != nil {
err = errors.Join(err, watermarkErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -400,12 +524,22 @@ func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger,
}
}
return fmt.Errorf("watermark PDF with multi PDF engines: %w", err)
err = fmt.Errorf("watermark PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Stamp applies a stamp (on top of page content) to a PDF file using the
// first available engine that supports stamping.
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
//
//nolint:dupl
func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Stamp", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -416,8 +550,10 @@ func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inp
select {
case stampErr := <-errChan:
errored := multierr.AppendInto(&err, stampErr)
if !errored {
if stampErr != nil {
err = errors.Join(err, stampErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -425,12 +561,20 @@ func (multi *multiPdfEngines) Stamp(ctx context.Context, logger *zap.Logger, inp
}
}
return fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
err = fmt.Errorf("stamp PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Rotate rotates pages of a PDF file using the first available engine that
// supports rotation.
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "pdfengines.Rotate", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error
errChan := make(chan error, 1)
@@ -441,8 +585,10 @@ func (multi *multiPdfEngines) Rotate(ctx context.Context, logger *zap.Logger, in
select {
case rotateErr := <-errChan:
errored := multierr.AppendInto(&err, rotateErr)
if !errored {
if rotateErr != nil {
err = errors.Join(err, rotateErr)
} else {
span.SetStatus(codes.Ok, "")
return nil
}
case <-ctx.Done():
@@ -450,7 +596,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.

View File

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

View File

@@ -5,10 +5,12 @@ import (
"errors"
"fmt"
"net/http"
"os"
"path/filepath"
"strconv"
"strings"
"github.com/google/uuid"
"github.com/labstack/echo/v4"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -282,9 +284,9 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
var outputPaths []string
for _, inputPath := range inputPaths {
inputPathNoExt := inputPath[:len(inputPath)-len(filepath.Ext(inputPath))]
filenameNoExt := filepath.Base(inputPathNoExt)
outputDirPath, err := ctx.CreateSubDirectory(strings.ReplaceAll(filepath.Base(filenameNoExt), ".", "_"))
originalName := ctx.OriginalFilename(inputPath)
originalNameNoExt := strings.TrimSuffix(originalName, filepath.Ext(originalName))
outputDirPath, err := ctx.CreateSubDirectory(uuid.New().String())
if err != nil {
return nil, fmt.Errorf("create subdirectory from input path: %w", err)
}
@@ -297,15 +299,18 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
// Keep the original filename.
for i, path := range paths {
var newPath string
var newOriginal string
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
newOriginal = fmt.Sprintf("%s.pdf", originalNameNoExt)
newPath = fmt.Sprintf(
"%s/%s.pdf",
outputDirPath, filenameNoExt,
"%s/%s",
outputDirPath, uuid.New().String()+".pdf",
)
} else {
newOriginal = fmt.Sprintf("%s_%d.pdf", originalNameNoExt, i)
newPath = fmt.Sprintf(
"%s/%s_%d.pdf",
outputDirPath, filenameNoExt, i,
"%s/%s",
outputDirPath, uuid.New().String()+".pdf",
)
}
@@ -314,6 +319,7 @@ func SplitPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, mode gotenberg.S
return nil, fmt.Errorf("rename path: %w", err)
}
ctx.RegisterDiskPath(newPath, newOriginal)
outputPaths = append(outputPaths, newPath)
if mode.Unify && mode.Mode == gotenberg.SplitModePages {
@@ -413,7 +419,7 @@ func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks
}
case map[string][]gotenberg.Bookmark:
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
filename := ctx.OriginalFilename(inputPath)
if specificBookmarks, ok := b[filename]; ok {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks)
if err != nil {
@@ -466,8 +472,28 @@ func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []s
return nil
}
// Engines like pdfcpu use filepath.Base(path) as the attachment name
// inside the PDF. Since disk filenames are now UUID-based, we create
// symlinks with the original names so that embeds are named correctly.
// See: https://github.com/gotenberg/gotenberg/issues/1500.
embedDir, err := ctx.CreateSubDirectory(uuid.New().String())
if err != nil {
return fmt.Errorf("create embed subdirectory: %w", err)
}
resolvedPaths := make([]string, len(embedPaths))
for i, embedPath := range embedPaths {
originalName := ctx.OriginalFilename(embedPath)
resolvedPath := fmt.Sprintf("%s/%s", embedDir, originalName)
err := os.Symlink(embedPath, resolvedPath)
if err != nil {
return fmt.Errorf("symlink embed file '%s': %w", originalName, err)
}
resolvedPaths[i] = resolvedPath
}
for _, inputPath := range inputPaths {
err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath)
err := engine.EmbedFiles(ctx, ctx.Log(), resolvedPaths, inputPath)
if err != nil {
return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err)
}
@@ -683,7 +709,7 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
if bMap != nil || autoIndexBookmarks {
offset := 0
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
filename := ctx.OriginalFilename(inputPath)
var fileBookmarks []gotenberg.Bookmark
if bMap != nil {
@@ -973,7 +999,7 @@ func readMetadataRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("read metadata: %w", err)
}
res[filepath.Base(inputPath)] = metadata
res[ctx.OriginalFilename(inputPath)] = metadata
}
err = c.JSON(http.StatusOK, res)
@@ -1051,7 +1077,7 @@ func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("read bookmarks: %w", err)
}
res[filepath.Base(inputPath)] = bookmarks
res[ctx.OriginalFilename(inputPath)] = bookmarks
}
err = c.JSON(http.StatusOK, res)

View File

@@ -5,12 +5,15 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"syscall"
"go.uber.org/zap"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -79,95 +82,202 @@ func (engine *PdfTk) Debug() map[string]any {
}
// Split splits a given PDF file.
func (engine *PdfTk) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (engine *PdfTk) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
err := fmt.Errorf("split PDFs using mode '%s' without unify with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
args = append(args, inputPath, "cat", mode.Span, "output", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
err := fmt.Errorf("split PDFs using mode '%s' with PDFtk: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with PDFtk: %w", err)
err = fmt.Errorf("split PDFs with PDFtk: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetStatus(codes.Ok, "")
return []string{outputPath}, nil
}
// Merge combines multiple PDFs into a single PDF.
func (engine *PdfTk) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (engine *PdfTk) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
args := make([]string, 0, 3+len(inputPaths))
args = append(args, inputPaths...)
args = append(args, "cat", "output", outputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err == nil {
span.SetStatus(codes.Ok, "")
return nil
}
return fmt.Errorf("merge PDFs with PDFtk: %w", err)
err = fmt.Errorf("merge PDFs with PDFtk: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Flatten is not available in this implementation.
func (engine *PdfTk) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("flatten PDF with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Convert is not available in this implementation.
func (engine *PdfTk) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with PDFtk: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("read PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// WriteMetadata is not available in this implementation.
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("write PDF metadata with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// PageCount is not available in this implementation.
func (engine *PdfTk) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("page count with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
// WriteBookmarks is not available in this implementation.
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("write PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadBookmarks is not available in this implementation.
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("read PDF bookmarks with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Encrypt adds password protection to a PDF file using PDFtk.
func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *PdfTk) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if userPassword == "" {
return errors.New("user password cannot be empty")
err := errors.New("user password cannot be empty")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if ownerPassword == userPassword || ownerPassword == "" {
return gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
err := gotenberg.NewPdfEngineInvalidArgs("pdftk", "both 'userPassword' and 'ownerPassword' must be provided and different. Consider switching to another PDF engine if this behavior does not work with your workflow")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Create a temp output file in the same directory.
@@ -182,32 +292,62 @@ func (engine *PdfTk) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("encrypt PDF with PDFtk: %w", err)
err = fmt.Errorf("encrypt PDF with PDFtk: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
err = fmt.Errorf("rename temporary output file with input file: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// EmbedFiles is not available in this implementation.
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *PdfTk) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "pdftk.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("embed files with PDFtk: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Watermark applies a watermark (behind page content) to a PDF file using PDFtk.
// Only PDF source is supported.
func (engine *PdfTk) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
//
//nolint:dupl
func (engine *PdfTk) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if stamp.Source != gotenberg.StampSourcePDF {
return fmt.Errorf("watermark PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported)
err := fmt.Errorf("watermark PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
tmpPath := inputPath + ".tmp"
@@ -216,27 +356,48 @@ func (engine *PdfTk) Watermark(ctx context.Context, logger *zap.Logger, inputPat
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("watermark PDF with PDFtk: %w", err)
err = fmt.Errorf("watermark PDF with PDFtk: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
err = fmt.Errorf("rename temporary output file with input file: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// Stamp applies a stamp (on top of page content) to a PDF file using PDFtk.
// Only PDF source is supported.
func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
//
//nolint:dupl
func (engine *PdfTk) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if stamp.Source != gotenberg.StampSourcePDF {
return fmt.Errorf("stamp PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported)
err := fmt.Errorf("stamp PDF with PDFtk: %w", gotenberg.ErrPdfStampSourceNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
tmpPath := inputPath + ".tmp"
@@ -245,28 +406,47 @@ func (engine *PdfTk) Stamp(ctx context.Context, logger *zap.Logger, inputPath st
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("stamp PDF with PDFtk: %w", err)
err = fmt.Errorf("stamp PDF with PDFtk: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
err = fmt.Errorf("rename temporary output file with input file: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// Rotate rotates all pages of a PDF file by the given angle using PDFtk.
// Page-specific rotation is not supported; if pages is non-empty,
// ErrPdfEngineMethodNotSupported is returned.
func (engine *PdfTk) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
func (engine *PdfTk) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "pdftk.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if pages != "" {
return fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported)
err := fmt.Errorf("rotate PDF with PDFtk (page-specific rotation): %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
var direction string
@@ -278,7 +458,10 @@ func (engine *PdfTk) Rotate(ctx context.Context, logger *zap.Logger, inputPath s
case 270:
direction = "west"
default:
return fmt.Errorf("rotate PDF with PDFtk: %w", gotenberg.ErrPdfRotateAngleNotSupported)
err := fmt.Errorf("rotate PDF with PDFtk: %w", gotenberg.ErrPdfRotateAngleNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
tmpPath := inputPath + ".tmp"
@@ -286,19 +469,29 @@ func (engine *PdfTk) Rotate(ctx context.Context, logger *zap.Logger, inputPath s
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("rotate PDF with PDFtk: %w", err)
err = fmt.Errorf("rotate PDF with PDFtk: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
err = os.Rename(tmpPath, inputPath)
if err != nil {
return fmt.Errorf("rename temporary output file with input file: %w", err)
err = fmt.Errorf("rename temporary output file with input file: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}

View File

@@ -23,11 +23,11 @@ func init() {
// Prometheus is a module that collects metrics and exposes them via an HTTP
// route.
type Prometheus struct {
namespace string
interval time.Duration
disableRouteLogging bool
disableCollect bool
metricsPath string
namespace string
interval time.Duration
disableRouteTelemetry bool
disableCollect bool
metricsPath string
metrics []gotenberg.Metric
registry *prometheus.Registry
@@ -41,10 +41,18 @@ func (mod *Prometheus) Descriptor() gotenberg.ModuleDescriptor {
fs := flag.NewFlagSet("prometheus", flag.ExitOnError)
fs.String("prometheus-namespace", "gotenberg", "Set the namespace of modules' metrics")
fs.Duration("prometheus-collect-interval", time.Duration(1)*time.Second, "Set the interval for collecting modules' metrics")
fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging")
fs.Bool("prometheus-disable-route-telemetry", true, "Disable telemetry for the Prometheus metrics route")
fs.Bool("prometheus-disable-collect", false, "Disable the collect of metrics")
fs.String("prometheus-metrics-path", "/prometheus/metrics", "Path for Prometheus metrics endpoint")
// Deprecated flags.
fs.Bool("prometheus-disable-route-logging", false, "Disable the route logging")
err := fs.MarkDeprecated("prometheus-disable-route-logging", "use --prometheus-disable-route-telemetry instead")
if err != nil {
panic(err)
}
return fs
}(),
New: func() gotenberg.Module { return new(Prometheus) },
@@ -56,7 +64,7 @@ func (mod *Prometheus) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
mod.namespace = flags.MustString("prometheus-namespace")
mod.interval = flags.MustDuration("prometheus-collect-interval")
mod.disableRouteLogging = flags.MustBool("prometheus-disable-route-logging")
mod.disableRouteTelemetry = flags.MustDeprecatedBool("prometheus-disable-route-logging", "prometheus-disable-route-telemetry")
mod.disableCollect = flags.MustBool("prometheus-disable-collect")
mod.metricsPath = flags.MustString("prometheus-metrics-path")
@@ -177,9 +185,9 @@ func (mod *Prometheus) Routes() ([]api.Route, error) {
return []api.Route{
{
Method: http.MethodGet,
Path: mod.metricsPath,
DisableLogging: mod.disableRouteLogging,
Method: http.MethodGet,
Path: mod.metricsPath,
DisableTelemetry: mod.disableRouteTelemetry,
Handler: echo.WrapHandler(
promhttp.HandlerFor(mod.registry, promhttp.HandlerOpts{}),
),

View File

@@ -5,12 +5,15 @@ import (
"context"
"errors"
"fmt"
"log/slog"
"os"
"os/exec"
"path/filepath"
"syscall"
"go.uber.org/zap"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
@@ -82,38 +85,63 @@ func (engine *QPdf) Debug() map[string]any {
}
// Split splits a given PDF file.
func (engine *QPdf) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
func (engine *QPdf) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Split",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
var args []string
outputPath := fmt.Sprintf("%s/%s", outputDirPath, filepath.Base(inputPath))
switch mode.Mode {
case gotenberg.SplitModePages:
if !mode.Unify {
return nil, fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
err := fmt.Errorf("split PDFs using mode '%s' without unify with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
args = append(args, inputPath)
args = append(args, engine.globalArgs...)
args = append(args, "--pages", ".", mode.Span)
args = append(args, "--", outputPath)
default:
return nil, fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
err := fmt.Errorf("split PDFs using mode '%s' with QPDF: %w", mode.Mode, gotenberg.ErrPdfSplitModeNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
_, err = cmd.Exec()
if err != nil {
return nil, fmt.Errorf("split PDFs with QPDF: %w", err)
err = fmt.Errorf("split PDFs with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
span.SetStatus(codes.Ok, "")
return []string{outputPath}, nil
}
// Merge combines multiple PDFs into a single PDF.
func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
func (engine *QPdf) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Merge",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
args := make([]string, 0, 4+len(engine.globalArgs)+len(inputPaths))
args = append(args, "--empty")
args = append(args, engine.globalArgs...)
@@ -123,20 +151,33 @@ func (engine *QPdf) Merge(ctx context.Context, logger *zap.Logger, inputPaths []
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err == nil {
span.SetStatus(codes.Ok, "")
return nil
}
return fmt.Errorf("merge PDFs with QPDF: %w", err)
err = fmt.Errorf("merge PDFs with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Flatten merges annotation appearances with page content, deleting the
// original annotations.
func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
func (engine *QPdf) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Flatten",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
args := make([]string, 0, 4+len(engine.globalArgs))
args = append(args, inputPath)
args = append(args, "--generate-appearances")
@@ -146,51 +187,121 @@ func (engine *QPdf) Flatten(ctx context.Context, logger *zap.Logger, inputPath s
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err == nil {
span.SetStatus(codes.Ok, "")
return nil
}
return fmt.Errorf("flatten PDFs with QPDF: %w", err)
err = fmt.Errorf("flatten PDFs with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Convert is not available in this implementation.
func (engine *QPdf) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Convert",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with QPDF: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadMetadata is not available in this implementation.
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("read PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// WriteMetadata is not available in this implementation.
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteMetadata",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("write PDF metadata with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// PageCount is not available in this implementation.
func (engine *QPdf) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
return 0, fmt.Errorf("page count with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.PageCount",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("page count with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return 0, err
}
// WriteBookmarks is not available in this implementation.
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.WriteBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("write PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// ReadBookmarks is not available in this implementation.
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.ReadBookmarks",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("read PDF bookmarks with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
}
// Encrypt adds password protection to a PDF file using QPDF.
func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
func (engine *QPdf) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "qpdf.Encrypt",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
if userPassword == "" {
return errors.New("user password cannot be empty")
err := errors.New("user password cannot be empty")
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
if ownerPassword == "" {
@@ -205,35 +316,78 @@ func (engine *QPdf) Encrypt(ctx context.Context, logger *zap.Logger, inputPath,
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
err = fmt.Errorf("create command: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("encrypt PDF with QPDF: %w", err)
err = fmt.Errorf("encrypt PDF with QPDF: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
span.SetStatus(codes.Ok, "")
return nil
}
// EmbedFiles is not available in this implementation.
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.EmbedFiles",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("embed files with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Watermark is not available in this implementation.
func (engine *QPdf) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("watermark PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Watermark",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("watermark PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Stamp is not available in this implementation.
func (engine *QPdf) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
return fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp gotenberg.Stamp) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Stamp",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("stamp PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
// Rotate is not available in this implementation.
func (engine *QPdf) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
return fmt.Errorf("rotate PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *QPdf) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
_, span := gotenberg.Tracer().Start(ctx, "qpdf.Rotate",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(engine.binPath)),
)
defer span.End()
err := fmt.Errorf("rotate PDF with QPDF: %w", gotenberg.ErrPdfEngineMethodNotSupported)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
var (

View File

@@ -1,15 +1,24 @@
package webhook
import (
"context"
"encoding/json"
"fmt"
"io"
"log/slog"
"net/http"
"strconv"
"time"
"github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4"
"go.uber.org/zap"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/propagation"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
"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.
@@ -18,15 +27,16 @@ type client struct {
method string
errorUrl string
errorMethod string
eventsUrl string
extraHttpHeaders map[string]string
startTime time.Time
client *retryablehttp.Client
logger *zap.Logger
logger *slog.Logger
}
// send call the webhook either to send the success response or the error response.
func (c client) send(body io.Reader, headers map[string]string, errored bool) error {
func (c client) send(ctx context.Context, body io.Reader, headers map[string]string, errored bool) error {
url := c.url
if errored {
url = c.errorUrl
@@ -37,11 +47,28 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
method = c.errorMethod
}
spanName := fmt.Sprintf("%s Webhook", method)
if errored {
spanName = fmt.Sprintf("%s Webhook Error", method)
}
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, spanName,
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(url)),
)
defer span.End()
req, err := retryablehttp.NewRequest(method, url, body)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("create '%s' request to '%s': %w", method, url, err)
}
// Inject trace context into outbound request headers.
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
req.Header.Set("User-Agent", "Gotenberg")
// Extra HTTP headers are the custom headers from the user.
@@ -63,6 +90,8 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
bodySize, err := strconv.ParseInt(contentLength, 10, 64)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("parse content length entry: %w", err)
}
@@ -75,17 +104,22 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
resp, err := c.client.Do(req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return fmt.Errorf("send '%s' request to '%s': %w", method, url, err)
}
if resp.StatusCode >= http.StatusBadRequest {
return fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
err := fmt.Errorf("send '%s' request to '%s': got status: '%s'", method, url, resp.Status)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
}
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.Error(fmt.Sprintf("close response body from '%s': %s", url, err))
c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", url, err))
}
}()
@@ -93,20 +127,85 @@ func (c client) send(body io.Reader, headers map[string]string, errored bool) er
finishTime := time.Now()
// Now let's log!
fields := make([]zap.Field, 5)
fields[0] = zap.String("webhook_url", url)
fields[1] = zap.String("method", method)
fields[2] = zap.Int64("latency", int64(finishTime.Sub(c.startTime)))
fields[3] = zap.String("latency_human", finishTime.Sub(c.startTime).String())
fields[4] = zap.Int64("bytes_out", req.ContentLength)
attrs := []any{
slog.String("webhook_url", url),
slog.String("method", method),
slog.Int64("latency", int64(finishTime.Sub(c.startTime))),
slog.String("latency_human", finishTime.Sub(c.startTime).String()),
slog.Int64("bytes_out", req.ContentLength),
}
if errored {
c.logger.Warn("request to webhook with error details handled", fields...)
c.logger.WarnContext(ctx, "request to webhook with error details handled", attrs...)
span.SetStatus(codes.Ok, "")
return nil
}
c.logger.Info("request to webhook handled", fields...)
c.logger.InfoContext(ctx, "request to webhook handled", attrs...)
span.SetStatus(codes.Ok, "")
return nil
}
// sendEvent sends a structured JSON event to the events URL. It is
// fire-and-forget: failures are logged but do not propagate.
func (c client) sendEvent(ctx context.Context, correlationIdHeader, correlationId string, event map[string]any) {
if c.eventsUrl == "" {
return
}
b, err := json.Marshal(event)
if err != nil {
c.logger.ErrorContext(ctx, fmt.Sprintf("marshal webhook event: %s", err))
return
}
tracer := gotenberg.Tracer()
ctx, span := tracer.Start(ctx, "POST Webhook Event",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconv.ServerAddress(c.eventsUrl)),
)
defer span.End()
req, err := retryablehttp.NewRequest(http.MethodPost, c.eventsUrl, b)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
c.logger.ErrorContext(ctx, fmt.Sprintf("create webhook event request: %s", err))
return
}
otel.GetTextMapPropagator().Inject(ctx, propagation.HeaderCarrier(req.Header))
req.Header.Set("User-Agent", "Gotenberg")
for key, value := range c.extraHttpHeaders {
req.Header.Set(key, value)
}
req.Header.Set(echo.HeaderContentType, echo.MIMEApplicationJSON)
req.Header.Set(correlationIdHeader, correlationId)
resp, err := c.client.Do(req)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': %s", c.eventsUrl, err))
return
}
defer func() {
err := resp.Body.Close()
if err != nil {
c.logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", c.eventsUrl, err))
}
}()
if resp.StatusCode >= http.StatusBadRequest {
span.RecordError(fmt.Errorf("webhook event: got status '%s'", resp.Status))
span.SetStatus(codes.Error, resp.Status)
c.logger.ErrorContext(ctx, fmt.Sprintf("send webhook event to '%s': got status '%s'", c.eventsUrl, resp.Status))
return
}
span.SetStatus(codes.Ok, "")
c.logger.InfoContext(ctx, fmt.Sprintf("webhook event sent to '%s'", c.eventsUrl))
}

View File

@@ -21,13 +21,13 @@ import (
)
type sendOutputFileParams struct {
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
traceHeader string
trace string
client *client
handleError func(error)
ctx *api.Context
outputPath string
extraHttpHeaders map[string]string
correlationIdHeader string
correlationId string
client *client
handleError func(error)
}
func webhookMiddleware(w *Webhook) api.Middleware {
@@ -72,20 +72,27 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
headers := map[string]string{
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.traceHeader: params.trace,
echo.HeaderContentType: http.DetectContentType(fileHeader),
echo.HeaderContentLength: strconv.FormatInt(fileStat.Size(), 10),
params.correlationIdHeader: params.correlationId,
}
_, ok := params.extraHttpHeaders[echo.HeaderContentDisposition]
if !ok {
headers[echo.HeaderContentDisposition] = fmt.Sprintf("attachment; filename=%q", params.ctx.OutputFilename(params.outputPath))
}
err = params.client.send(bufio.NewReader(outputFile), headers, false)
err = params.client.send(params.ctx, bufio.NewReader(outputFile), headers, false)
if err != nil {
params.ctx.Log().Error(fmt.Sprintf("send output file to webhook: %s", err))
params.handleError(err)
return
}
params.client.sendEvent(params.ctx, params.correlationIdHeader, params.correlationId, map[string]any{
"event": "webhook.success",
"correlationId": params.correlationId,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
})
}
return func(c echo.Context) error {
@@ -176,17 +183,27 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
}
// What about the events URL?
webhookEventsUrl := c.Request().Header.Get("Gotenberg-Webhook-Events-Url")
if webhookEventsUrl != "" {
err = gotenberg.FilterDeadline(w.allowList, w.denyList, webhookEventsUrl, deadline)
if err != nil {
return fmt.Errorf("filter webhook events URL: %w", err)
}
}
// Retrieve values from echo.Context before it gets recycled.
// See https://github.com/gotenberg/gotenberg/issues/1000.
startTime := c.Get("startTime").(time.Time)
traceHeader := c.Get("traceHeader").(string)
trace := c.Get("trace").(string)
correlationIdHeader := c.Get("correlationIdHeader").(string)
correlationId := c.Get("correlationId").(string)
client := &client{
url: webhookUrl,
method: webhookMethod,
errorUrl: webhookErrorUrl,
errorMethod: webhookErrorMethod,
eventsUrl: webhookEventsUrl,
extraHttpHeaders: extraHttpHeaders,
startTime: startTime,
@@ -226,13 +243,23 @@ func webhookMiddleware(w *Webhook) api.Middleware {
headers := map[string]string{
echo.HeaderContentType: echo.MIMEApplicationJSON,
traceHeader: trace,
correlationIdHeader: correlationId,
}
err = client.send(bytes.NewReader(b), headers, true)
err = client.send(ctx, bytes.NewReader(b), headers, true)
if err != nil {
ctx.Log().Error(fmt.Sprintf("send error response to webhook: %s", err.Error()))
}
client.sendEvent(ctx, correlationIdHeader, correlationId, map[string]any{
"event": "webhook.error",
"correlationId": correlationId,
"timestamp": time.Now().UTC().Format(time.RFC3339Nano),
"error": map[string]any{
"status": status,
"message": message,
},
})
}
if w.enableSyncMode {
@@ -262,13 +289,13 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
// No error, let's send the output file to the webhook URL.
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
correlationIdHeader: correlationIdHeader,
correlationId: correlationId,
client: client,
handleError: handleError,
})
return c.NoContent(http.StatusNoContent)
}
@@ -332,13 +359,13 @@ func webhookMiddleware(w *Webhook) api.Middleware {
}
sendOutputFile(sendOutputFileParams{
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
traceHeader: traceHeader,
trace: trace,
client: client,
handleError: handleError,
ctx: ctx,
outputPath: outputPath,
extraHttpHeaders: extraHttpHeaders,
correlationIdHeader: correlationIdHeader,
correlationId: correlationId,
client: client,
handleError: handleError,
})
}()

View File

@@ -19,10 +19,10 @@ func init() {
// to any destinations in an asynchronous fashion.
type Webhook struct {
enableSyncMode bool
allowList *regexp2.Regexp
denyList *regexp2.Regexp
errorAllowList *regexp2.Regexp
errorDenyList *regexp2.Regexp
allowList []*regexp2.Regexp
denyList []*regexp2.Regexp
errorAllowList []*regexp2.Regexp
errorDenyList []*regexp2.Regexp
maxRetry int
retryMinWait time.Duration
retryMaxWait time.Duration
@@ -38,10 +38,10 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
FlagSet: func() *flag.FlagSet {
fs := flag.NewFlagSet("webhook", flag.ExitOnError)
fs.Bool("webhook-enable-sync-mode", false, "Enable synchronous mode for the webhook feature")
fs.String("webhook-allow-list", "", "Set the allowed URLs for the webhook feature using a regular expression")
fs.String("webhook-deny-list", "", "Set the denied URLs for the webhook feature using a regular expression")
fs.String("webhook-error-allow-list", "", "Set the allowed URLs in case of an error for the webhook feature using a regular expression")
fs.String("webhook-error-deny-list", "", "Set the denied URLs in case of an error for the webhook feature using a regular expression")
fs.StringSlice("webhook-allow-list", []string{}, "Set the allowed URLs for the webhook feature using regular expressions - supports multiple values")
fs.StringSlice("webhook-deny-list", []string{}, "Set the denied URLs for the webhook feature using regular expressions - supports multiple values")
fs.StringSlice("webhook-error-allow-list", []string{}, "Set the allowed URLs in case of an error for the webhook feature using regular expressions - supports multiple values")
fs.StringSlice("webhook-error-deny-list", []string{}, "Set the denied URLs in case of an error for the webhook feature using regular expressions - supports multiple values")
fs.Int("webhook-max-retry", 4, "Set the maximum number of retries for the webhook feature")
fs.Duration("webhook-retry-min-wait", time.Duration(1)*time.Second, "Set the minimum duration to wait before trying to call the webhook again")
fs.Duration("webhook-retry-max-wait", time.Duration(30)*time.Second, "Set the maximum duration to wait before trying to call the webhook again")
@@ -58,10 +58,10 @@ func (w *Webhook) Descriptor() gotenberg.ModuleDescriptor {
func (w *Webhook) Provision(ctx *gotenberg.Context) error {
flags := ctx.ParsedFlags()
w.enableSyncMode = flags.MustBool("webhook-enable-sync-mode")
w.allowList = flags.MustRegexp("webhook-allow-list")
w.denyList = flags.MustRegexp("webhook-deny-list")
w.errorAllowList = flags.MustRegexp("webhook-error-allow-list")
w.errorDenyList = flags.MustRegexp("webhook-error-deny-list")
w.allowList = flags.MustRegexpSlice("webhook-allow-list")
w.denyList = flags.MustRegexpSlice("webhook-deny-list")
w.errorAllowList = flags.MustRegexpSlice("webhook-error-allow-list")
w.errorDenyList = flags.MustRegexpSlice("webhook-error-deny-list")
w.maxRetry = flags.MustInt("webhook-max-retry")
w.retryMinWait = flags.MustDuration("webhook-retry-min-wait")
w.retryMaxWait = flags.MustDuration("webhook-retry-max-wait")

Some files were not shown because too many files have changed in this diff Show More