Compare commits

..

12 Commits

Author SHA1 Message Date
Julien Neuhart
fb472124c4 feat(chromium): skip or waits for networkIdle2 event 2026-03-11 21:17:09 +01:00
Julien Neuhart
f584fa2dc0 feat(otel): add better metrics for chromium and libreoffice 2026-03-11 20:55:42 +01:00
Julien Neuhart
81d43cfbad fix(Dockerfile): use uid/gid instead of gotenberg in chown 2026-03-11 18:45:50 +01:00
Julien Neuhart
fe72902cdb feat(Dockerfile): reducing size even more 2026-03-11 18:26:43 +01:00
Julien Neuhart
8898bd2b4a refactor(Dockerfile): better Java modules 2026-03-08 16:16:14 +01:00
Julien Neuhart
a62abef215 refactor(Dockerfile): remove fonts 2026-03-08 16:08:33 +01:00
Julien Neuhart
cf9fd7eedc refactor(pdfengines): embeds => attachments 2026-03-08 15:49:09 +01:00
Julien Neuhart
7af3cd1ff5 test(integration): add bookmarks routes 2026-03-04 21:34:16 +01:00
Julien Neuhart
4296474889 feat(pdfengines): add read bookmarks pdfcpu implementation 2026-03-04 20:52:56 +01:00
Julien Neuhart
bfde451e4e feat(pdfengines): add bookmarks read route 2026-03-04 20:48:33 +01:00
Julien Neuhart
fd8c5441d8 feat(pdfengines): add bookmarks write pdfcpu implementation 2026-03-04 20:30:31 +01:00
Julien Neuhart
d7421748d0 feat(pdfengines): add bookmarks write route and update the merge route with this feature 2026-03-04 20:25:06 +01:00
134 changed files with 6378 additions and 6931 deletions

View File

@@ -1,72 +0,0 @@
meta {
name: HTML to PDF
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/chromium/convert/html
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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"}
}

View File

@@ -1,73 +0,0 @@
meta {
name: Markdown to PDF
type: http
seq: 3
}
post {
url: {{baseUrl}}/forms/chromium/convert/markdown
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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"}
}

View File

@@ -1,72 +0,0 @@
meta {
name: URL to PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/chromium/convert/url
body: multipartForm
auth: none
}
body:multipart-form {
url: https://example.com
~landscape: false
~printBackground: false
~scale: 1.0
~singlePage: false
~paperWidth: 8.5
~paperHeight: 11
~marginTop: 0.39
~marginBottom: 0.39
~marginLeft: 0.39
~marginRight: 0.39
~nativePageRanges: 1-2
~preferCssPageSize: false
~generateDocumentOutline: false
~generateTaggedPdf: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: print
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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"}
}

View File

@@ -1,46 +0,0 @@
meta {
name: HTML Screenshot
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/html
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-html/index.html)
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~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"}
}

View File

@@ -1,47 +0,0 @@
meta {
name: Markdown Screenshot
type: http
seq: 3
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/markdown
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page-1-markdown/index.html)
files: @file(../../test/integration/testdata/page-1-markdown/page_1.md)
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~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"}
}

View File

@@ -1,46 +0,0 @@
meta {
name: URL Screenshot
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/chromium/screenshot/url
body: multipartForm
auth: none
}
body:multipart-form {
url: https://example.com
~width: 800
~height: 600
~clip: false
~format: png
~quality: 100
~optimizeForSpeed: false
~skipNetworkIdleEvent: false
~failOnHttpStatusCodes: [499,599]
~failOnResourceHttpStatusCodes: []
~ignoreResourceHttpStatusDomains: []
~failOnResourceLoadingFailed: false
~failOnConsoleExceptions: false
~waitDelay: 0s
~waitWindowStatus:
~waitForExpression:
~waitForSelector:
~cookies: [{"name":"my_cookie","value":"my_value","domain":"example.com"}]
~userAgent:
~extraHttpHeaders: {"X-Custom-Header":"value"}
~emulatedMediaType: screen
~emulatedMediaFeatures: {"prefers-color-scheme":"dark"}
~omitBackground: false
}
headers {
~Gotenberg-Output-Filename: my-screenshot
~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"}
}

View File

@@ -1,11 +0,0 @@
meta {
name: Debug
type: http
seq: 3
}
get {
url: {{baseUrl}}/debug
body: none
auth: none
}

View File

@@ -1,11 +0,0 @@
meta {
name: Health
type: http
seq: 1
}
get {
url: {{baseUrl}}/health
body: none
auth: none
}

View File

@@ -1,11 +0,0 @@
meta {
name: Prometheus Metrics
type: http
seq: 4
}
get {
url: {{baseUrl}}/prometheus/metrics
body: none
auth: none
}

View File

@@ -1,11 +0,0 @@
meta {
name: Version
type: http
seq: 2
}
get {
url: {{baseUrl}}/version
body: none
auth: none
}

View File

@@ -1,74 +0,0 @@
meta {
name: Convert to PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/libreoffice/convert
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.docx)
~password:
~landscape: false
~nativePageRanges:
~updateIndexes: false
~exportFormFields: true
~allowDuplicateFieldNames: false
~exportBookmarks: true
~exportBookmarksToPdfDestination: false
~exportPlaceholders: false
~exportNotes: false
~exportNotesPages: false
~exportOnlyNotesPages: false
~exportNotesInMargin: false
~convertOooTargetToPdfTarget: false
~exportLinksRelativeFsys: false
~exportHiddenSlides: false
~skipEmptyPages: false
~addOriginalDocumentAsStream: false
~singlePageSheets: false
~losslessImageCompression: false
~quality: 90
~reduceImageResolution: false
~maxImageResolution: 300
~nativeWatermarkText:
~nativeWatermarkColor: 0
~nativeWatermarkFontHeight: 0
~nativeWatermarkRotateAngle: 0
~nativeWatermarkFontName:
~nativeTiledWatermarkText:
~nativePdfFormats: true
~merge: false
~flatten: false
~splitMode: intervals
~splitSpan: 1
~splitUnify: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: my-file
~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"}
}

View File

@@ -1,23 +0,0 @@
meta {
name: Read Bookmarks
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/bookmarks/read
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~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"}
}

View File

@@ -1,25 +0,0 @@
meta {
name: Write Bookmarks
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/pdfengines/bookmarks/write
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
bookmarks: [{"title":"Chapter 1","page":1,"children":[{"title":"Section 1.1","page":1}]}]
}
headers {
~Gotenberg-Output-Filename: with-bookmarks
~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"}
}

View File

@@ -1,26 +0,0 @@
meta {
name: Convert PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/convert
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
pdfa: PDF/A-1b
~pdfua: true
}
headers {
~Gotenberg-Output-Filename: converted
~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"}
}

View File

@@ -1,26 +0,0 @@
meta {
name: Embed Files
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/embed
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
embeds: @file(../../test/integration/testdata/page_1.pdf)
~downloadFrom: [{"url":"https://example.com/attachment.xml","embedded":true}]
}
headers {
~Gotenberg-Output-Filename: with-embeds
~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"}
}

View File

@@ -1,26 +0,0 @@
meta {
name: Encrypt PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/encrypt
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
userPassword: secret123
~ownerPassword: owner456
}
headers {
~Gotenberg-Output-Filename: encrypted
~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"}
}

View File

@@ -1,24 +0,0 @@
meta {
name: Flatten PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/flatten
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~Gotenberg-Output-Filename: flattened
~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"}
}

View File

@@ -1,43 +0,0 @@
meta {
name: Merge PDFs
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/merge
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
files: @file(../../test/integration/testdata/page_2.pdf)
~flatten: false
~autoIndexBookmarks: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~bookmarks: [{"title":"Page 1","page":1},{"title":"Page 2","page":2}]
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: merged
~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"}
}

View File

@@ -1,23 +0,0 @@
meta {
name: Read Metadata
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/metadata/read
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
}
headers {
~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"}
}

View File

@@ -1,25 +0,0 @@
meta {
name: Write Metadata
type: http
seq: 2
}
post {
url: {{baseUrl}}/forms/pdfengines/metadata/write
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
metadata: {"Author":"Bruno","Copyright":"Bruno","Creator":"Gotenberg","Keywords":["test"],"Marked":true,"Producer":"Gotenberg","Subject":"Test","Title":"Test Document","Trapped":"Unknown"}
}
headers {
~Gotenberg-Output-Filename: with-metadata
~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"}
}

View File

@@ -1,26 +0,0 @@
meta {
name: Rotate PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/rotate
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
rotateAngle: 90
~rotatePages: 1-2
}
headers {
~Gotenberg-Output-Filename: rotated
~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"}
}

View File

@@ -1,43 +0,0 @@
meta {
name: Split PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/split
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/pages_3.pdf)
splitMode: intervals
splitSpan: 1
~splitUnify: false
~flatten: false
~pdfa: PDF/A-1b
~pdfua: true
~metadata: {"Author":"Bruno","Title":"Test"}
~userPassword:
~ownerPassword:
~watermarkSource: text
~watermarkExpression: CONFIDENTIAL
~watermarkPages:
~watermarkOptions: {"scale":"0.5 abs","rot":"45","fillcolor":"#FF0000"}
~stampSource: text
~stampExpression: DRAFT
~stampPages:
~stampOptions: {"scale":"0.5 abs","rot":"45"}
~rotateAngle: 90
~rotatePages:
}
headers {
~Gotenberg-Output-Filename: split
~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"}
}

View File

@@ -1,30 +0,0 @@
meta {
name: Stamp PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/stamp
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
stampSource: text
stampExpression: APPROVED
~stampPages: 1-2
~stampOptions: {"font":"Helvetica","fontSize":"48","color":"#00FF00","opacity":"0.5","rotation":"0"}
~stamp: @file(../../test/integration/testdata/watermark.png)
~downloadFrom: [{"url":"https://example.com/stamp.png","field":"stamp"}]
}
headers {
~Gotenberg-Output-Filename: stamped
~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"}
}

View File

@@ -1,30 +0,0 @@
meta {
name: Watermark PDF
type: http
seq: 1
}
post {
url: {{baseUrl}}/forms/pdfengines/watermark
body: multipartForm
auth: none
}
body:multipart-form {
files: @file(../../test/integration/testdata/page_1.pdf)
watermarkSource: text
watermarkExpression: CONFIDENTIAL
~watermarkPages: 1-2
~watermarkOptions: {"font":"Helvetica","fontSize":"48","color":"#FF0000","opacity":"0.3","rotation":"45"}
~watermark: @file(../../test/integration/testdata/watermark.png)
~downloadFrom: [{"url":"https://example.com/watermark.png","field":"watermark"}]
}
headers {
~Gotenberg-Output-Filename: watermarked
~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"}
}

View File

@@ -1,6 +0,0 @@
{
"version": "1",
"name": "Gotenberg",
"type": "collection",
"ignore": ["node_modules", ".git"]
}

View File

@@ -1,3 +0,0 @@
headers {
Gotenberg-Trace: {{traceId}}
}

View File

@@ -1,4 +0,0 @@
vars {
baseUrl: https://demo.gotenberg.dev
traceId: bruno-demo
}

View File

@@ -1,4 +0,0 @@
vars {
baseUrl: http://localhost:3000
traceId: bruno-local
}

View File

@@ -60,43 +60,30 @@ runs:
- name: Build ${{ inputs.platform }} - name: Build ${{ inputs.platform }}
id: build id: build
shell: bash shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/build-test-push/build.sh \ .github/actions/build-test-push/build.sh \
--version "$INPUT_VERSION" \ --version "${{ inputs.version }}" \
--platform "$INPUT_PLATFORM" \ --platform "${{ inputs.platform }}" \
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \ --alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "$INPUT_DRY_RUN" --dry-run "${{ inputs.dry_run }}"
- name: Run integration tests - name: Run integration tests
if: inputs.skip_integrations_tests != 'true' if: inputs.skip_integrations_tests != 'true'
shell: bash shell: bash
env:
INPUT_VERSION: ${{ inputs.version }}
INPUT_PLATFORM: ${{ inputs.platform }}
INPUT_ALTERNATE_REPOSITORY: ${{ inputs.alternate_repository }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/build-test-push/test.sh \ .github/actions/build-test-push/test.sh \
--version "$INPUT_VERSION" \ --version "${{ inputs.version }}" \
--platform "$INPUT_PLATFORM" \ --platform "${{ inputs.platform }}" \
--alternate-repository "$INPUT_ALTERNATE_REPOSITORY" \ --alternate-repository "${{ inputs.alternate_repository }}" \
--dry-run "$INPUT_DRY_RUN" --dry-run "${{ inputs.dry_run }}"
- name: Push - name: Push
if: inputs.docker_hub_username != '' if: inputs.docker_hub_username != ''
shell: bash shell: bash
env:
INPUT_TAGS: ${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/build-test-push/push.sh \ .github/actions/build-test-push/push.sh \
--tags "$INPUT_TAGS" \ --tags "${{ steps.build.outputs.tags }},${{ steps.build.outputs.tags_cloud_run }},${{ steps.build.outputs.tags_aws_lambda }}" \
--dry-run "$INPUT_DRY_RUN" --dry-run "${{ inputs.dry_run }}"
- name: Outputs - name: Outputs
shell: bash shell: bash

View File

@@ -23,12 +23,9 @@ runs:
env: env:
DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }} DOCKERHUB_USERNAME: ${{ inputs.docker_hub_username }}
DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }} DOCKERHUB_TOKEN: ${{ inputs.docker_hub_password }}
INPUT_TAGS: ${{ inputs.tags }}
INPUT_SNAPSHOT_VERSION: ${{ inputs.snapshot_version }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
shell: bash shell: bash
run: | run: |
.github/actions/clean/clean.sh \ .github/actions/clean/clean.sh \
--tags "$INPUT_TAGS" \ --tags "${{ inputs.tags }}" \
--snapshot-version "$INPUT_SNAPSHOT_VERSION" \ --snapshot-version "${{ inputs.snapshot_version }}" \
--dry-run "$INPUT_DRY_RUN" --dry-run "${{ inputs.dry_run }}"

View File

@@ -41,12 +41,8 @@ runs:
- name: Merge - name: Merge
shell: bash shell: bash
env:
INPUT_TAGS: ${{ inputs.tags }}
INPUT_ALTERNATE_REGISTRY: ${{ inputs.alternate_registry }}
INPUT_DRY_RUN: ${{ inputs.dry_run }}
run: | run: |
.github/actions/merge/merge.sh \ .github/actions/merge/merge.sh \
--tags "$INPUT_TAGS" \ --tags "${{ inputs.tags }}" \
--alternate-registry "$INPUT_ALTERNATE_REGISTRY" \ --alternate-registry "${{ inputs.alternate_registry }}" \
--dry-run "$INPUT_DRY_RUN" --dry-run "${{ inputs.dry_run }}"

398
AGENTS.md
View File

@@ -1,336 +1,66 @@
# Operational Guidelines for Gotenberg # Operational Guidelines for Gotenberg
You are working on **Gotenberg**, a Docker-based API for converting documents to PDF. It is a widely used production dependency. Stability and backward compatibility are paramount. When in doubt about whether a change is breaking, flag it rather than assuming it's safe. As an AI agent working on the Gotenberg repository, you are expected to act with the diligence and architectural foresight of a Senior Go Engineer. Gotenberg is a widely used production dependency; stability and backward compatibility are paramount.
## Mandatory Workflow ## 1. Core Philosophy & Stability
Every task MUST follow these five steps in order. Do not skip any step. - **Backward Compatibility is Law:** This project creates a public API. Never modify existing flags, configuration environment variables, or API form fields unless explicitly instructed to perform a breaking change. If a change is breaking, it must be flagged immediately in the plan.
- **Defensive Programming:** Assume input data is malformed. Handle errors explicitly. Do not panic.
### Step 1 — Plan - **Atomic Commits:** Isolate refactoring from feature additions. A Pull Request should do one thing well.
Before writing any code, produce a plan that covers: ## 2. Development Workflow & Tooling
- **Problem statement**: What needs to change and why. You must rely strictly on the project's Makefile for build and verification tasks. Do not run `go` commands directly unless debugging a specific package requires it.
- **Proposed solution**: The recommended approach with enough detail to implement (files to modify, interface changes, pipeline positioning, form fields, etc.).
- **Alternatives considered**: At least one alternative approach when pertinent, with a brief explanation of why the proposed solution is preferred. - **Formatting:** Run `make fmt` to format Go code before committing.
- **Scope**: List every file that will be created or modified. - **Linting:**
- **Testing strategy**: Which integration test tags will be affected, what new scenarios are needed, and whether unit tests are required. - Run `make lint` to ensure Go code strictly adheres to the `.golangci.yml` configuration.
- Run `make lint-prettier` to verify formatting for non-Go files (Markdown, YAML, etc.).
Present the plan to the user and wait for approval before proceeding to Step 2. If the user provides a plan, validate it against the codebase and flag any issues before implementing. - Zero linting errors are permitted.
- **Building:** Run `make build` to verify compilation and Docker image construction.
### Step 2 — Implement
## 3. Architecture & Code Structure
Implement the approved plan following the coding standards and patterns described in this document. After implementation, verify the build compiles (`go build ./...`).
- **Idiomatic Go:** Follow "Effective Go" principles.
### Step 3 — Test - **Directory Separation:**
- `cmd/`: Application entry points only. Contains wiring and startup logic. **No business logic is permitted here.**
Write or update tests based on the plan's testing strategy: - `pkg/`: Core library code and modules. All business logic resides here.
- **Module System:** Gotenberg is modular (e.g., Chromium, LibreOffice). When adding features, determine if they belong to an existing module or require a new strict isolation.
- **Integration tests** (primary): Gherkin scenarios in `test/integration/features/`. See the [Integration Tests](#integration-tests) section.
- **Unit tests** (when applicable): Table-driven tests in `*_test.go` files using mocks from `pkg/gotenberg/mocks.go`. ## 4. Testing Standards
### Step 4 — Review Gotenberg utilizes a split testing strategy. **Integration tests are the primary and preferred method for verifying features.**
Self-review the implementation against the [Review Checklist](#review-checklist). Fix any issues found before presenting the result to the user. - **Integration Tests (`make test-integration`):**
- **First Priority:** Always start here when adding features or routes.
### Step 5 — Commit - Gotenberg uses **Gherkin (Godog)** for end-to-end verification.
- You **must** create or update the corresponding `.feature` file in `test/integration`.
Present the review to the user and **wait for explicit approval**. Do NOT commit until the user confirms. Once approved, create a commit following the [Conventional Commits](https://www.conventionalcommits.org/) specification: - These tests run within the Docker context; ensure environment consistency.
- **Unit Tests (`make test-unit`):**
``` - Use table-driven tests for pure logic within `pkg/`.
<type>(<scope>): <description> - Mock external dependencies (filesystem, network) where appropriate.
```
## 5. Documentation Requirements
Stage only the files related to the change. Do not use `git add -A` or `git add .`.
- **No README Updates:** Do not modify the root `README.md` unless explicitly asked.
--- - **GoDoc is Mandatory:**
- **New Packages:** If creating a new package, you must include a `doc.go` file containing the package-level documentation.
## Core Principles - **Exported Symbols:** Every exported function, type, constant, and variable must have a proper GoDoc comment starting with its name.
- **Quality:** Comments must be complete sentences explaining _what_ the symbol does and _how_ to use it.
- **Backward compatibility is law.** Never modify existing CLI flags, environment variables, or API form fields unless explicitly instructed to perform a breaking change. Flag any breaking change immediately. - **Example:**
- **Defensive programming.** Assume input is malformed. Handle errors explicitly. Never panic. ```go
- **Atomic commits.** One feature or fix per PR. Isolate refactoring from feature work. // Convert transforms the input document to PDF using the Chromium engine.
- **Idiomatic Go.** Follow "Effective Go" principles. All exported symbols must have GoDoc comments starting with their name. // It returns an error if the connection to the browser instance fails.
func Convert(...) error
## Project Layout ```
``` ## 6. Definition of Done
cmd/gotenberg/ → Entry point only (wiring/startup). No business logic.
pkg/gotenberg/ → Core module system, interfaces, utilities, mocks. A task is considered complete only when:
pkg/modules/ → Feature modules (api, chromium, libreoffice, pdfengines, etc.).
pkg/standard/ → Wires all standard modules together via imports. 1. The code compiles via `make build`.
test/integration/ → Gherkin feature files + Go test infrastructure. 2. The code is formatted via `make fmt`.
build/ → Dockerfile, fonts, Chromium config. 3. All linters pass via `make lint` and `make lint-prettier`.
.bruno/ → Bruno API collection (mirrors every route). 4. Integration scenarios pass via `make test-integration`.
``` 5. Unit tests pass via `make test-unit`.
6. All exported symbols and new packages have compliant GoDoc.
Key interfaces live in `pkg/gotenberg/``Module`, `Provisioner`, `Validator`, `Debuggable`. Every module implements `Descriptor()` and self-registers. When adding features, determine if they belong in an existing module or require a new one.
## Codebase Navigation
- Start with `pkg/gotenberg/` for core interfaces and `pkg/modules/` for feature implementations.
- The integration test infrastructure in `test/integration/scenario/` is well-structured — read `scenario.go` and `containers.go` to understand the Gherkin step definitions before writing new tests.
- 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).
---
## Makefile — the Only Build Interface
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 |
## Module System
Gotenberg uses a self-registering module architecture inspired by CaddyServer. Each module:
- Lives in `pkg/modules/<name>/`
- Implements the `gotenberg.Module` interface (at minimum `Descriptor()`)
- May also implement `gotenberg.Provisioner`, `gotenberg.Validator`, or `gotenberg.Debuggable`
- Self-registers via `init()` and is wired through `pkg/standard/`
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.
- **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
### Backward Compatibility
- [ ] No existing CLI flags renamed or removed
- [ ] No existing environment variables renamed or removed
- [ ] No existing API form fields renamed or removed
- [ ] No existing HTTP endpoints changed or removed
- [ ] No changes to default values that alter existing behavior
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.
Formatters enforce `gci`, `gofmt`, `gofumpt`, `goimports` with import ordering:
1. Standard library
2. Third-party packages
3. `github.com/gotenberg/gotenberg/v8`
Three groups separated by blank lines.
### Code Quality
- Errors are wrapped with context: `fmt.Errorf("description: %w", err)`. No swallowed errors.
- No business logic in `cmd/`.
- No panics in production code paths.
- Input is validated defensively.
- New features belong in the correct module (or justify a new one).
### Documentation
- Every exported function, type, constant, and variable has a GoDoc comment starting with its name.
- New packages include a `doc.go` file.
- `README.md` is not modified unless explicitly requested.
### Definition of Done
A change is ready to merge only when:
1. Code compiles: `go build ./...`
2. Code is formatted: `make fmt`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant `TAGS`)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have compliant GoDoc
7. Bruno collection is updated (if routes were added or modified)
---
## 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

@@ -1,3 +0,0 @@
# Claude Code — Gotenberg
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.

View File

@@ -1,63 +0,0 @@
# Contributing to Gotenberg
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
- Go (see version in `go.mod`)
- Docker
- Node.js (see version in `.node-version`) — for Prettier linting
- [golangci-lint](https://golangci-lint.run/) v2+
### Build and Run
```bash
make build # Build the Docker image
make run # Run a local Gotenberg container
```
### Development Loop
```bash
# Write your code, then:
make fmt # Format Go code
make prettify # Format non-Go files (Markdown, YAML, etc.)
make lint # Lint Go code (zero errors permitted)
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
```
To run only the integration tests relevant to your change:
```bash
make test-integration TAGS=health
make test-integration TAGS=chromium-convert-html
make test-integration TAGS="merge,split"
```
## Submitting a Pull Request
Before opening a PR, verify:
1. Code compiles: `make build`
2. Code is formatted: `make fmt` and `make prettify`
3. All linters pass: `make lint` and `make lint-prettier`
4. Integration tests pass: `make test-integration` (at minimum, the relevant tags)
5. Unit tests pass: `make test-unit`
6. All exported symbols and new packages have GoDoc comments
### Guidelines
- **Conventional Commits.** Commit messages must follow the [Conventional Commits](https://www.conventionalcommits.org/) specification (e.g., `feat(chromium): add screenshot endpoint`, `fix(api): handle empty body`).
- **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/`.
- **No business logic in `cmd/`.** All logic belongs in `pkg/`.

View File

@@ -1,3 +0,0 @@
# Gemini — Gotenberg
Read [AGENTS.md](AGENTS.md) first. It contains everything: core principles, project layout, coding standards, the mandatory 4-step workflow (Plan → Implement → Test → Review), integration test reference, review checklist, and Bruno collection guidelines.

150
Makefile
View File

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

View File

@@ -64,7 +64,7 @@ RUN \
# Note: jdeps helps finding which modules a JAR requires. # Note: jdeps helps finding which modules a JAR requires.
# Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK. # Currently only for PDFtk, as we don't rely on LibreOffice UNO Java SDK.
ENV JAVA_MODULES=java.base,java.desktop,java.naming,java.sql ENV JAVA_MODULES=java.base,java.desktop,java.xml
RUN jlink \ RUN jlink \
--add-modules $JAVA_MODULES \ --add-modules $JAVA_MODULES \
@@ -74,6 +74,25 @@ RUN jlink \
--compress=2 \ --compress=2 \
--output /custom-jre --output /custom-jre
# ----------------------------------------------
# Downloader stage
# ----------------------------------------------
FROM debian:13-slim AS downloader-stage
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
ARG PDFTK_VERSION=v3.3.3
RUN apt-get update -qq && \
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl ca-certificates
WORKDIR /downloads
RUN curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o unoconverter &&\
chmod +x unoconverter
RUN curl -o pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x pdftk-all.jar
# ---------------------------------------------- # ----------------------------------------------
# Base image stage # Base image stage
# ---------------------------------------------- # ----------------------------------------------
@@ -81,8 +100,14 @@ FROM debian:13-slim AS base-image-stage
ARG TIMEZONE=UTC ARG TIMEZONE=UTC
ENV TZ=$TIMEZONE ENV TZ=$TIMEZONE
# Set default characterset encoding to UTF-8.
# See:
# https://github.com/gotenberg/gotenberg/issues/104
# https://github.com/gotenberg/gotenberg/issues/730
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
COPY --from=custom-jre-stage /custom-jre /opt/java COPY --link --from=custom-jre-stage /custom-jre /opt/java
ENV PATH="/opt/java/bin:${PATH}" ENV PATH="/opt/java/bin:${PATH}"
@@ -94,10 +119,6 @@ FROM base-image-stage
ARG GOTENBERG_VERSION=snapshot ARG GOTENBERG_VERSION=snapshot
ARG GOTENBERG_USER_GID=1001 ARG GOTENBERG_USER_GID=1001
ARG GOTENBERG_USER_UID=1001 ARG GOTENBERG_USER_UID=1001
# See https://github.com/googlefonts/noto-emoji/releases.
ARG NOTO_COLOR_EMOJI_VERSION=v2.051
# See https://gitlab.com/pdftk-java/pdftk/-/releases - Binary package.
ARG PDFTK_VERSION=v3.3.3
LABEL org.opencontainers.image.title="Gotenberg" \ LABEL org.opencontainers.image.title="Gotenberg" \
org.opencontainers.image.description="A containerized API for seamless PDF conversion." \ org.opencontainers.image.description="A containerized API for seamless PDF conversion." \
@@ -115,134 +136,55 @@ RUN \
chown gotenberg: /home/gotenberg chown gotenberg: /home/gotenberg
RUN \ RUN \
# Install system dependencies required for the next instructions or debugging. echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
# Note: tini is a helper for reaping zombie processes. # Note: tini is a helper for reaping zombie processes.
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends curl gnupg tini python3 python3-distutils-extra &&\
# Cleanup.
# Note: the Debian image does automatically a clean after each install thanks to a hook.
# Therefore, there is no need for apt-get clean.
# See https://stackoverflow.com/a/24417119/3248473.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install fonts.
# Credits:
# https://github.com/arachnys/athenapdf/blob/master/cli/Dockerfile.
# https://help.accusoft.com/PrizmDoc/v12.1/HTML/Installing_Asian_Fonts_on_Ubuntu_and_Debian.html.
curl -o ./ttf-mscorefonts-installer_3.8.1_all.deb http://httpredir.debian.org/debian/pool/contrib/m/msttcorefonts/ttf-mscorefonts-installer_3.8.1_all.deb &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
./ttf-mscorefonts-installer_3.8.1_all.deb \
culmus \
fonts-beng \
fonts-hosny-amiri \
fonts-lklug-sinhala \
fonts-lohit-guru \
fonts-lohit-knda \
fonts-samyak-gujr \
fonts-samyak-mlym \
fonts-samyak-taml \
fonts-sarai \
fonts-sil-abyssinica \
fonts-sil-padauk \
fonts-telu \
fonts-thai-tlwg \
ttf-wqy-zenhei \
fonts-arphic-ukai \
fonts-arphic-uming \
fonts-ipafont-mincho \
fonts-ipafont-gothic \
fonts-unfonts-core \
# LibreOffice recommends.
fonts-crosextra-caladea \
fonts-crosextra-carlito \
fonts-dejavu \
fonts-liberation \
fonts-liberation2 \
fonts-linuxlibertine \
fonts-noto-cjk \
fonts-noto-core \
fonts-noto-mono \
fonts-noto-ui-core \
fonts-sil-gentium \
fonts-sil-gentium-basic &&\
rm -f ./ttf-mscorefonts-installer_3.8.1_all.deb &&\
# Add Color and Black-and-White Noto emoji font.
# Credits:
# https://github.com/gotenberg/gotenberg/pull/325.
# https://github.com/googlefonts/noto-emoji.
curl -Ls "https://github.com/googlefonts/noto-emoji/raw/$NOTO_COLOR_EMOJI_VERSION/fonts/NotoColorEmoji.ttf" -o /usr/local/share/fonts/NotoColorEmoji.ttf &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends \
tini python3 python3-distutils-extra \
# Install fonts.
# Essential metric-compatible fonts for LibreOffice layout fidelity (replaces MS Fonts).
fonts-crosextra-carlito \
fonts-crosextra-caladea \
fonts-liberation \
fonts-liberation2 \
# Reliable general-purpose fallback for Chromium.
fonts-dejavu \
# Unified CJK (Chinese, Japanese, Korean) support.
fonts-noto-cjk \
# Standard Emoji support.
fonts-noto-color-emoji \
# Install Hyphenation for LibreOffice.
# Credits: https://wiki.archlinux.org/title/LibreOffice.
hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \ hyphen-af hyphen-as hyphen-be hyphen-bg hyphen-bn hyphen-ca hyphen-cs hyphen-da hyphen-de hyphen-el \
hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \ hyphen-en-gb hyphen-en-us hyphen-eo hyphen-es hyphen-fr hyphen-gl hyphen-gu hyphen-hi hyphen-hr hyphen-hu \
hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \ hyphen-id hyphen-is hyphen-it hyphen-kn hyphen-lt hyphen-lv hyphen-ml hyphen-mn hyphen-mr hyphen-nl \
hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \ hyphen-no hyphen-or hyphen-pa hyphen-pl hyphen-pt-br hyphen-pt-pt hyphen-ro hyphen-ru hyphen-sk hyphen-sl \
hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu &&\ hyphen-sr hyphen-sv hyphen-ta hyphen-te hyphen-th hyphen-uk hyphen-zu \
# Cleanup. # Install Chromium.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* chromium \
# Install QPDF & ExifTool (PDF engines).
RUN \ qpdf exiftool &&\
# Install Chromium. # Install LibreOffice & unoconverter.
apt-get update -qq &&\ DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports \
apt-get upgrade -yqq &&\ libreoffice-writer libreoffice-calc libreoffice-impress libreoffice-draw python3-uno &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends chromium &&\
# Verify installation.
chromium --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
# Set default characterset encoding to UTF-8.
# See:
# https://github.com/gotenberg/gotenberg/issues/104
# https://github.com/gotenberg/gotenberg/issues/730
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
RUN \
# Install LibreOffice & unoconverter. \
echo "deb http://deb.debian.org/debian trixie-backports main" >> /etc/apt/sources.list &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends -t trixie-backports libreoffice &&\
curl -Ls https://raw.githubusercontent.com/gotenberg/unoconverter/v0.2.0/unoconv -o /usr/bin/unoconverter &&\
chmod +x /usr/bin/unoconverter &&\
# unoconverter will look for the Python binary, which has to be at version 3. # unoconverter will look for the Python binary, which has to be at version 3.
ln -s /usr/bin/python3 /usr/bin/python &&\ ln -s /usr/bin/python3 /usr/bin/python &&\
# Verify installations.
libreoffice --version &&\
unoconverter --version &&\
# Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/*
RUN \
# Install PDFtk, QPDF & ExifTool (PDF engines).
# See https://github.com/gotenberg/gotenberg/pull/273.
curl -o /usr/bin/pdftk-all.jar "https://gitlab.com/api/v4/projects/5024297/packages/generic/pdftk-java/$PDFTK_VERSION/pdftk-all.jar" &&\
chmod a+x /usr/bin/pdftk-all.jar &&\
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk &&\
apt-get update -qq &&\
apt-get upgrade -yqq &&\
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq --no-install-recommends qpdf exiftool &&\
# See https://github.com/nextcloud/docker/issues/380. # See https://github.com/nextcloud/docker/issues/380.
mkdir -p /usr/share/man/man1 &&\ mkdir -p /usr/share/man/man1 &&\
# Verify installations.
pdftk --version &&\
qpdf --version &&\
exiftool --version &&\
# Cleanup. # Cleanup.
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* apt-get autoremove -yqq &&\
rm -rf /var/lib/apt/lists/* /tmp/* /var/tmp/* /usr/share/doc/* /usr/share/man/* /usr/share/info/*
# Copy downloaded artifacts (unoconverter & PDFtk).
COPY --link --from=downloader-stage /downloads/unoconverter /usr/bin/unoconverter
COPY --link --from=downloader-stage /downloads/pdftk-all.jar /usr/bin/pdftk-all.jar
RUN \
# Install PDFtk bash wrapper.
# See https://github.com/gotenberg/gotenberg/pull/273.
printf '#!/bin/bash\n\nexec java -jar /usr/bin/pdftk-all.jar "$@"' > /usr/bin/pdftk && \
chmod +x /usr/bin/pdftk
# Support for arbitrary user IDs (OpenShift). # Support for arbitrary user IDs (OpenShift).
# See: # See:
@@ -257,15 +199,15 @@ RUN \
# Credits: # Credits:
# https://github.com/arachnys/athenapdf/issues/69. # https://github.com/arachnys/athenapdf/issues/69.
# https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d. # https://github.com/arachnys/athenapdf/commit/ba25a8d80a25d08d58865519c4cd8756dc9a336d.
COPY build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf COPY --link build/fonts.conf /etc/fonts/conf.d/100-gotenberg.conf
# Copy dictionnaries so that hypens work on Chromium. # Copy dictionnaries so that hypens work on Chromium.
# See https://github.com/gotenberg/gotenberg/issues/1293. # See https://github.com/gotenberg/gotenberg/issues/1293.
COPY --chown=gotenberg:gotenberg build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data COPY --link --chown="$GOTENBERG_USER_UID:$GOTENBERG_USER_GID" build/chromium-hyphen-data /opt/gotenberg/chromium-hyphen-data
# Copy the Golang binaries. # Copy the Golang binaries.
COPY --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/ COPY --link --from=pdfcpu-binary-stage /home/pdfcpu /usr/bin/
COPY --from=gotenberg-binary-stage /home/gotenberg /usr/bin/ COPY --link --from=gotenberg-binary-stage /home/gotenberg /usr/bin/
# Environment variables required by modules or else. # Environment variables required by modules or else.
ENV CHROMIUM_BIN_PATH=/usr/bin/chromium ENV CHROMIUM_BIN_PATH=/usr/bin/chromium
@@ -284,4 +226,4 @@ WORKDIR /home/gotenberg
EXPOSE 3000 EXPOSE 3000
ENTRYPOINT [ "/usr/bin/tini", "--" ] ENTRYPOINT [ "/usr/bin/tini", "--" ]
CMD [ "gotenberg" ] CMD [ "gotenberg" ]

View File

@@ -43,6 +43,10 @@ func Run() {
fs.Bool("gotenberg-hide-banner", false, "Hide the banner") fs.Bool("gotenberg-hide-banner", false, "Hide the banner")
fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration") fs.Duration("gotenberg-graceful-shutdown-duration", time.Duration(30)*time.Second, "Set the graceful shutdown duration")
fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed") fs.Bool("gotenberg-build-debug-data", true, "Set if build data is needed")
fs.String("log-level", gotenberg.InfoLoggingLevel, fmt.Sprintf("Choose the level of logging detail. Options include %s, %s, %s, or %s", gotenberg.ErrorLoggingLevel, gotenberg.WarnLoggingLevel, gotenberg.InfoLoggingLevel, gotenberg.DebugLoggingLevel))
fs.String("log-fields-prefix", "", "Prepend a specified prefix to each field in the logs")
fs.String("log-std-format", gotenberg.AutoLoggingFormat, fmt.Sprintf("Specify the format of standard logging. Options include %s, %s, or %s", gotenberg.AutoLoggingFormat, gotenberg.JsonLoggingFormat, gotenberg.TextLoggingFormat))
fs.Bool("log-std-enable-gcp-fields", false, "Enable Google Cloud Platform fields for standard logging - namely: time, message, severity")
descriptors := gotenberg.GetModuleDescriptors() descriptors := gotenberg.GetModuleDescriptors()
var modsInfo strings.Builder var modsInfo strings.Builder
@@ -91,14 +95,43 @@ func Run() {
hideBanner := parsedFlags.MustBool("gotenberg-hide-banner") hideBanner := parsedFlags.MustBool("gotenberg-hide-banner")
gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration") gracefulShutdownDuration := parsedFlags.MustDuration("gotenberg-graceful-shutdown-duration")
serviceName := os.Getenv("OTEL_SERVICE_NAME")
if serviceName == "" {
serviceName = "gotenberg"
}
telemetryConfig := gotenberg.TelemetryConfig{
// OpenTelemetry.
ServiceName: serviceName,
ServiceVersion: Version,
// Logging.
LogLevel: parsedFlags.MustString("log-level"),
LogFieldsPrefix: parsedFlags.MustString("log-fields-prefix"),
LogStdFormat: parsedFlags.MustDeprecatedString("log-format", "log-std-format"),
LogStdEnableGcpFields: parsedFlags.MustDeprecatedBool("log-enable-gcp-fields", "log-std-enable-gcp-fields"),
}
if !hideBanner { if !hideBanner {
fmt.Printf(banner, Version) fmt.Printf(banner, Version)
} }
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
err = telemetryConfig.Validate()
if err != nil {
fmt.Printf("[FATAL] telemetry: %s\n", err)
os.Exit(1)
}
// Telemetry.
shutdownTelemetry, err := gotenberg.StartTelemetry(telemetryConfig)
if err != nil {
fmt.Printf("[FATAL] telemetry: %s\n", err)
os.Exit(1)
}
// Modules.
fmt.Printf("[SYSTEM] modules: %s\n", modsInfo.String())
ctx := gotenberg.NewContext(parsedFlags, descriptors) ctx := gotenberg.NewContext(parsedFlags, descriptors)
// Start application modules.
apps, err := ctx.Modules(new(gotenberg.App)) apps, err := ctx.Modules(new(gotenberg.App))
if err != nil { if err != nil {
fmt.Printf("[FATAL] %s\n", err) fmt.Printf("[FATAL] %s\n", err)
@@ -194,5 +227,12 @@ func Run() {
os.Exit(1) os.Exit(1)
} }
err = shutdownTelemetry(gracefulShutdownCtx)
if err != nil {
fmt.Printf("[FATAL] telemetry: %v\n", err)
os.Exit(1)
}
fmt.Printf("[SYSTEM] telemetry: shutdown success\n")
os.Exit(0) os.Exit(0)
} }

103
compose.yaml Normal file
View File

@@ -0,0 +1,103 @@
services:
gotenberg:
image: ${DOCKER_REGISTRY}/${DOCKER_REPOSITORY}:${GOTENBERG_VERSION}
ports:
- "${API_PORT}:${API_PORT}"
environment:
GOTENBERG_API_BASIC_AUTH_USERNAME: ${GOTENBERG_API_BASIC_AUTH_USERNAME}
GOTENBERG_API_BASIC_AUTH_PASSWORD: ${GOTENBERG_API_BASIC_AUTH_PASSWORD}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME}
OTEL_TRACES_EXPORTER: ${OTEL_TRACES_EXPORTER}
OTEL_METRICS_EXPORTER: ${OTEL_METRICS_EXPORTER}
OTEL_LOGS_EXPORTER: ${OTEL_LOGS_EXPORTER}
OTEL_EXPORTER_OTLP_PROTOCOL: ${OTEL_EXPORTER_OTLP_PROTOCOL}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT}
OTEL_EXPORTER_OTLP_INSECURE: ${OTEL_EXPORTER_OTLP_INSECURE}
command:
- "gotenberg"
- "--gotenberg-hide-banner=${GOTENBERG_HIDE_BANNER}"
- "--gotenberg-graceful-shutdown-duration=${GOTENBERG_GRACEFUL_SHUTDOWN_DURATION}"
- "--gotenberg-build-debug-data=${GOTENBERG_BUILD_DEBUG_DATA}"
- "--api-port=${API_PORT}"
- "--api-port-from-env=${API_PORT_FROM_ENV}"
- "--api-bind-ip=${API_BIND_IP}"
- "--api-start-timeout=${API_START_TIMEOUT}"
- "--api-timeout=${API_TIMEOUT}"
- "--api-body-limit=${API_BODY_LIMIT}"
- "--api-root-path=${API_ROOT_PATH}"
- "--api-correlation-id-header=${API_CORRELATION_ID_HEADER}"
- "--api-enable-basic-auth=${API_ENABLE_BASIC_AUTH}"
- "--api-download-from-allow-list=${API_DOWNLOAD_FROM_ALLOW_LIST}"
- "--api-download-from-deny-list=${API_DOWNLOAD_FROM_DENY_LIST}"
- "--api-download-from-max-retry=${API_DOWNLOAD_FROM_MAX_RETRY}"
- "--api-disable-download-from=${API_DISABLE_DOWNLOAD_FROM}"
- "--api-disable-health-check-route-telemetry=${API_DISABLE_HEALTH_CHECK_ROUTE_TELEMETRY}"
- "--api-disable-root-route-telemetry=${API_DISABLE_ROOT_ROUTE_TELEMETRY}"
- "--api-disable-version-route-telemetry=${API_DISABLE_VERSION_ROUTE_TELEMETRY}"
- "--api-disable-debug-route-telemetry=${API_DISABLE_DEBUG_ROUTE_TELEMETRY}"
- "--api-enable-debug-route=${API_ENABLE_DEBUG_ROUTE}"
- "--chromium-restart-after=${CHROMIUM_RESTART_AFTER}"
- "--chromium-auto-start=${CHROMIUM_AUTO_START}"
- "--chromium-max-queue-size=${CHROMIUM_MAX_QUEUE_SIZE}"
- "--chromium-max-concurrency=${CHROMIUM_MAX_CONCURRENCY}"
- "--chromium-start-timeout=${CHROMIUM_START_TIMEOUT}"
- "--chromium-allow-insecure-localhost=${CHROMIUM_ALLOW_INSECURE_LOCALHOST}"
- "--chromium-ignore-certificate-errors=${CHROMIUM_IGNORE_CERTIFICATE_ERRORS}"
- "--chromium-disable-web-security=${CHROMIUM_DISABLE_WEB_SECURITY}"
- "--chromium-allow-file-access-from-files=${CHROMIUM_ALLOW_FILE_ACCESS_FROM_FILES}"
- "--chromium-host-resolver-rules=${CHROMIUM_HOST_RESOLVER_RULES}"
- "--chromium-proxy-server=${CHROMIUM_PROXY_SERVER}"
- "--chromium-allow-list=${CHROMIUM_ALLOW_LIST}"
- "--chromium-deny-list=${CHROMIUM_DENY_LIST}"
- "--chromium-clear-cache=${CHROMIUM_CLEAR_CACHE}"
- "--chromium-clear-cookies=${CHROMIUM_CLEAR_COOKIES}"
- "--chromium-disable-javascript=${CHROMIUM_DISABLE_JAVASCRIPT}"
- "--chromium-disable-routes=${CHROMIUM_DISABLE_ROUTES}"
- "--libreoffice-restart-after=${LIBREOFFICE_RESTART_AFTER}"
- "--libreoffice-max-queue-size=${LIBREOFFICE_MAX_QUEUE_SIZE}"
- "--libreoffice-auto-start=${LIBREOFFICE_AUTO_START}"
- "--libreoffice-start-timeout=${LIBREOFFICE_START_TIMEOUT}"
- "--libreoffice-disable-routes=${LIBREOFFICE_DISABLE_ROUTES}"
- "--log-level=${LOG_LEVEL}"
- "--log-fields-prefix=${LOG_FIELDS_PREFIX}"
- "--log-std-format=${LOG_STD_FORMAT}"
- "--log-std-enable-gcp-fields=${LOG_STD_ENABLE_GCP_FIELDS}"
- "--pdfengines-merge-engines=${PDFENGINES_MERGE_ENGINES}"
- "--pdfengines-split-engines=${PDFENGINES_SPLIT_ENGINES}"
- "--pdfengines-flatten-engines=${PDFENGINES_FLATTEN_ENGINES}"
- "--pdfengines-convert-engines=${PDFENGINES_CONVERT_ENGINES}"
- "--pdfengines-read-metadata-engines=${PDFENGINES_READ_METADATA_ENGINES}"
- "--pdfengines-write-metadata-engines=${PDFENGINES_WRITE_METADATA_ENGINES}"
- "--pdfengines-encrypt-engines=${PDFENGINES_ENCRYPT_ENGINES}"
- "--pdfengines-disable-routes=${PDFENGINES_DISABLE_ROUTES}"
- "--pdfengines-add-attachments-engines=${PDFENGINES_ADD_ATTACHMENTS_ENGINES}"
- "--webhook-enable-sync-mode=${WEBHOOK_ENABLE_SYNC_MODE}"
- "--webhook-allow-list=${WEBHOOK_ALLOW_LIST}"
- "--webhook-deny-list=${WEBHOOK_DENY_LIST}"
- "--webhook-error-allow-list=${WEBHOOK_ERROR_ALLOW_LIST}"
- "--webhook-error-deny-list=${WEBHOOK_ERROR_DENY_LIST}"
- "--webhook-max-retry=${WEBHOOK_MAX_RETRY}"
- "--webhook-retry-min-wait=${WEBHOOK_RETRY_MIN_WAIT}"
- "--webhook-retry-max-wait=${WEBHOOK_RETRY_MAX_WAIT}"
- "--webhook-client-timeout=${WEBHOOK_CLIENT_TIMEOUT}"
- "--webhook-disable=${WEBHOOK_DISABLE}"
otel-collector:
image: otel/opentelemetry-collector-contrib:latest
command: ["--config=/etc/otel-collector-config.yaml"]
volumes:
- ./otel-collector-config.yaml:/etc/otel-collector-config.yaml
ports:
- "4317:4317" # OTLP gRPC receiver
depends_on:
- openobserve
openobserve:
image: public.ecr.aws/zinclabs/openobserve:latest
restart: always
ports:
- "5080:5080"
- "5081:5081" # OTLP gRPC ingestion
environment:
ZO_ROOT_USER_EMAIL: telemetry@gotenberg.dev
ZO_ROOT_USER_PASSWORD: telemetry

63
go.mod
View File

@@ -18,16 +18,24 @@ require (
github.com/labstack/gommon v0.4.2 github.com/labstack/gommon v0.4.2
github.com/mholt/archives v0.1.5 github.com/mholt/archives v0.1.5
github.com/microcosm-cc/bluemonday v1.0.27 github.com/microcosm-cc/bluemonday v1.0.27
github.com/prometheus/client_golang v1.23.2 github.com/shirou/gopsutil/v4 v4.26.1
github.com/shirou/gopsutil/v4 v4.26.2
github.com/spf13/pflag v1.0.10 github.com/spf13/pflag v1.0.10
github.com/testcontainers/testcontainers-go v0.41.0 github.com/stretchr/testify v1.11.1
github.com/testcontainers/testcontainers-go v0.40.0
go.opentelemetry.io/contrib/bridges/otelslog v0.15.0
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0
go.opentelemetry.io/otel v1.42.0
go.opentelemetry.io/otel/log v0.18.0
go.opentelemetry.io/otel/metric v1.42.0
go.opentelemetry.io/otel/sdk v1.42.0
go.opentelemetry.io/otel/sdk/log v0.18.0
go.opentelemetry.io/otel/sdk/metric v1.42.0
go.opentelemetry.io/otel/trace v1.42.0
go.uber.org/multierr v1.11.0 go.uber.org/multierr v1.11.0
go.uber.org/zap v1.27.1 golang.org/x/net v0.51.0
golang.org/x/net v0.52.0 golang.org/x/sync v0.19.0
golang.org/x/sync v0.20.0 golang.org/x/term v0.40.0
golang.org/x/term v0.41.0 golang.org/x/text v0.34.0
golang.org/x/text v0.35.0
) )
require ( require (
@@ -67,6 +75,7 @@ require (
github.com/gobwas/ws v1.4.0 // indirect github.com/gobwas/ws v1.4.0 // indirect
github.com/gofrs/uuid v4.4.0+incompatible // indirect github.com/gofrs/uuid v4.4.0+incompatible // indirect
github.com/gorilla/css v1.0.1 // indirect github.com/gorilla/css v1.0.1 // indirect
github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 // indirect
github.com/hashicorp/go-cleanhttp v0.5.2 // indirect github.com/hashicorp/go-cleanhttp v0.5.2 // indirect
github.com/hashicorp/go-immutable-radix v1.3.1 // indirect github.com/hashicorp/go-immutable-radix v1.3.1 // indirect
github.com/hashicorp/go-memdb v1.3.5 // indirect github.com/hashicorp/go-memdb v1.3.5 // indirect
@@ -79,7 +88,7 @@ require (
github.com/mattn/go-colorable v0.1.14 // indirect github.com/mattn/go-colorable v0.1.14 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect github.com/mattn/go-isatty v0.0.20 // indirect
github.com/mikelolasagasti/xz v1.0.1 // indirect github.com/mikelolasagasti/xz v1.0.1 // indirect
github.com/minio/minlz v1.1.0 // indirect github.com/minio/minlz v1.0.1 // indirect
github.com/moby/docker-image-spec v1.3.1 // indirect github.com/moby/docker-image-spec v1.3.1 // indirect
github.com/moby/go-archive v0.2.0 // indirect github.com/moby/go-archive v0.2.0 // indirect
github.com/moby/patternmatcher v0.6.0 // indirect github.com/moby/patternmatcher v0.6.0 // indirect
@@ -92,17 +101,18 @@ require (
github.com/nwaples/rardecode/v2 v2.2.2 // indirect github.com/nwaples/rardecode/v2 v2.2.2 // indirect
github.com/opencontainers/go-digest v1.0.0 // indirect github.com/opencontainers/go-digest v1.0.0 // indirect
github.com/opencontainers/image-spec v1.1.1 // indirect github.com/opencontainers/image-spec v1.1.1 // indirect
github.com/pierrec/lz4/v4 v4.1.26 // indirect github.com/pierrec/lz4/v4 v4.1.25 // indirect
github.com/pkg/errors v0.9.1 // indirect github.com/pkg/errors v0.9.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect github.com/power-devops/perfstat v0.0.0-20240221224432-82ca36839d55 // indirect
github.com/prometheus/client_golang v1.23.2 // indirect
github.com/prometheus/client_model v0.6.2 // indirect github.com/prometheus/client_model v0.6.2 // indirect
github.com/prometheus/common v0.67.5 // indirect github.com/prometheus/common v0.67.5 // indirect
github.com/prometheus/otlptranslator v1.0.0 // indirect
github.com/prometheus/procfs v0.20.1 // indirect github.com/prometheus/procfs v0.20.1 // indirect
github.com/sirupsen/logrus v1.9.4 // indirect github.com/sirupsen/logrus v1.9.4 // indirect
github.com/sorairolake/lzip-go v0.3.8 // indirect github.com/sorairolake/lzip-go v0.3.8 // indirect
github.com/spf13/afero v1.15.0 // indirect github.com/spf13/afero v1.15.0 // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/tklauser/go-sysconf v0.3.16 // indirect github.com/tklauser/go-sysconf v0.3.16 // indirect
github.com/tklauser/numcpus v0.11.0 // indirect github.com/tklauser/numcpus v0.11.0 // indirect
github.com/ulikunitz/xz v0.5.15 // indirect github.com/ulikunitz/xz v0.5.15 // indirect
@@ -110,17 +120,28 @@ require (
github.com/valyala/fasttemplate v1.2.2 // indirect github.com/valyala/fasttemplate v1.2.2 // indirect
github.com/yusufpapurcu/wmi v1.2.4 // indirect github.com/yusufpapurcu/wmi v1.2.4 // indirect
go.opentelemetry.io/auto/sdk v1.2.1 // indirect go.opentelemetry.io/auto/sdk v1.2.1 // indirect
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 // indirect go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 // indirect
go.opentelemetry.io/otel v1.42.0 // indirect go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 // indirect
go.opentelemetry.io/otel/metric v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 // indirect
go.opentelemetry.io/otel/trace v1.42.0 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.7.1 // indirect go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 // indirect
go.yaml.in/yaml/v2 v2.4.4 // indirect go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 // indirect
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 // indirect
go.opentelemetry.io/proto/otlp v1.9.0 // indirect
go.yaml.in/yaml/v2 v2.4.3 // indirect
go4.org v0.0.0-20260112195520-a5071408f32f // indirect go4.org v0.0.0-20260112195520-a5071408f32f // indirect
golang.org/x/crypto v0.49.0 // indirect golang.org/x/crypto v0.48.0 // indirect
golang.org/x/sys v0.42.0 // indirect golang.org/x/sys v0.41.0 // indirect
golang.org/x/time v0.15.0 // indirect golang.org/x/time v0.14.0 // indirect
google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 // indirect
google.golang.org/grpc v1.79.2 // indirect
google.golang.org/protobuf v1.36.11 // indirect google.golang.org/protobuf v1.36.11 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
) )

119
go.sum
View File

@@ -98,6 +98,8 @@ github.com/gofrs/uuid v4.2.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRx
github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.3.1+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA= github.com/gofrs/uuid v4.4.0+incompatible h1:3qXRTX8/NbyulANqlc0lchS1gqAVxRgsuW1YrTJupqA=
github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM= github.com/gofrs/uuid v4.4.0+incompatible/go.mod h1:b2aQJv3Z4Fp6yNu3cdSllBxTCLRxnplIgP/c0N/04lM=
github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek=
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab h1:VYNivV7P8IRHUam2swVUNkhIdp0LRRFKe4hXNnoZKTc=
github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA= github.com/gomarkdown/markdown v0.0.0-20260217112301-37c66b85d6ab/go.mod h1:JDGcbDT52eL4fju3sZ4TeHGsQwhG9nbDV21aMyhwPoA=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
@@ -107,8 +109,8 @@ github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8= github.com/gorilla/css v1.0.1 h1:ntNaBIghp6JmvWnxbZKANoLyuXTPZ4cAMlo6RyhlbO8=
github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0= github.com/gorilla/css v1.0.1/go.mod h1:BvnYkspnSzMmwRK+b8/xgNPLiIuNZr6vbZBTPQ2A3b0=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1 h1:X5VWvz21y3gzm9Nw/kaUeku/1+uBhcekkmy4IkffJww= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0 h1:HWRh5R2+9EifMyIHV7ZV+MIZqgz+PMpZ14Jynv3O2Zs=
github.com/grpc-ecosystem/grpc-gateway/v2 v2.27.1/go.mod h1:Zanoh4+gvIgluNqcfMVTJueD4wSS5hT7zTt4Mrutd90= github.com/grpc-ecosystem/grpc-gateway/v2 v2.28.0/go.mod h1:JfhWUomR1baixubs02l85lZYYOm7LV6om4ceouMv45c=
github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ= github.com/hashicorp/go-cleanhttp v0.5.2 h1:035FKYIWjmULyFRBKPs8TBQoi0x6d9G4xc9neXJWAZQ=
github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48= github.com/hashicorp/go-cleanhttp v0.5.2/go.mod h1:kO/YDlP8L1346E6Sodw+PrpBSV4/SoxCXGY6BqNFT48=
github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k= github.com/hashicorp/go-hclog v1.6.3 h1:Qr2kF+eVWjTiYmU7Y31tYlP1h0q/X3Nl3tPGdaB11/k=
@@ -166,8 +168,8 @@ github.com/microcosm-cc/bluemonday v1.0.27 h1:MpEUotklkwCSLeH+Qdx1VJgNqLlpY2KXwX
github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA= github.com/microcosm-cc/bluemonday v1.0.27/go.mod h1:jFi9vgW+H7c3V0lb6nR74Ib/DIB5OBs92Dimizgw2cA=
github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0= github.com/mikelolasagasti/xz v1.0.1 h1:Q2F2jX0RYJUG3+WsM+FJknv+6eVjsjXNDV0KJXZzkD0=
github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc= github.com/mikelolasagasti/xz v1.0.1/go.mod h1:muAirjiOUxPRXwm9HdDtB3uoRPrGnL85XHtokL9Hcgc=
github.com/minio/minlz v1.1.0 h1:rUOGu3EP4EqJC5k3qCsIwEnZiJULKqtRyDdqbhlvMmQ= github.com/minio/minlz v1.0.1 h1:OUZUzXcib8diiX+JYxyRLIdomyZYzHct6EShOKtQY2A=
github.com/minio/minlz v1.1.0/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec= github.com/minio/minlz v1.0.1/go.mod h1:qT0aEB35q79LLornSzeDH75LBf3aH1MV+jB5w9Wasec=
github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0= github.com/moby/docker-image-spec v1.3.1 h1:jMKff3w6PgbfSa69GfNg+zN/XLhfXJGnEx3Nl2EsFP0=
github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo= github.com/moby/docker-image-spec v1.3.1/go.mod h1:eKmb5VW8vQEh/BAr2yvVNvuiJuY6UIocYsFu/DxxRpo=
github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8= github.com/moby/go-archive v0.2.0 h1:zg5QDUM2mi0JIM9fdQZWC7U8+2ZfixfTYoHL7rWUcP8=
@@ -196,8 +198,8 @@ github.com/opencontainers/image-spec v1.1.1 h1:y0fUlFfIZhPF1W537XOLg0/fcx6zcHCJw
github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M= github.com/opencontainers/image-spec v1.1.1/go.mod h1:qpqAh3Dmcf36wStyyWU+kCeDgrGnAve2nCC8+7h8Q0M=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde h1:x0TT0RDC7UhAVbbWWBzr41ElhJx5tXPWkIHA2HWPRuw=
github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0= github.com/orisano/pixelmatch v0.0.0-20220722002657-fb0b55479cde/go.mod h1:nZgzbfBr3hhjoZnS66nKrHmduYNpc34ny7RK4z5/HM0=
github.com/pierrec/lz4/v4 v4.1.26 h1:GrpZw1gZttORinvzBdXPUXATeqlJjqUG/D87TKMnhjY= github.com/pierrec/lz4/v4 v4.1.25 h1:kocOqRffaIbU5djlIBr7Wh+cx82C0vtFb0fOurZHqD0=
github.com/pierrec/lz4/v4 v4.1.26/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4= github.com/pierrec/lz4/v4 v4.1.25/go.mod h1:EoQMVJgeeEOMsCqCzqFm2O0cJvljX2nGZjcRIPL34O4=
github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4= github.com/pkg/errors v0.9.1 h1:FEBLx1zS214owpjy7qsBeixbURkuhQAwrK5UwLGTwt4=
github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0= github.com/pkg/errors v0.9.1/go.mod h1:bwawxfHBFNV+L2hUp1rHADufV3IMtnDRdf1r5NINEl0=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
@@ -210,13 +212,15 @@ github.com/prometheus/client_model v0.6.2 h1:oBsgwpGs7iVziMvrGhE53c/GrLUsZdHnqNw
github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE= github.com/prometheus/client_model v0.6.2/go.mod h1:y3m2F6Gdpfy6Ut/GBsUqTWZqCUvMVzSfMLjcu6wAwpE=
github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4= github.com/prometheus/common v0.67.5 h1:pIgK94WWlQt1WLwAC5j2ynLaBRDiinoAb86HZHTUGI4=
github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw= github.com/prometheus/common v0.67.5/go.mod h1:SjE/0MzDEEAyrdr5Gqc6G+sXI67maCxzaT3A2+HqjUw=
github.com/prometheus/otlptranslator v1.0.0 h1:s0LJW/iN9dkIH+EnhiD3BlkkP5QVIUVEoIwkU+A6qos=
github.com/prometheus/otlptranslator v1.0.0/go.mod h1:vRYWnXvI6aWGpsdY/mOT/cbeVRBlPWtBNDb7kGR3uKM=
github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc= github.com/prometheus/procfs v0.20.1 h1:XwbrGOIplXW/AU3YhIhLODXMJYyC1isLFfYCsTEycfc=
github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo= github.com/prometheus/procfs v0.20.1/go.mod h1:o9EMBZGRyvDrSPH1RqdxhojkuXstoe4UlK79eF5TGGo=
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ= github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc= github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/shirou/gopsutil/v4 v4.26.2 h1:X8i6sicvUFih4BmYIGT1m2wwgw2VG9YgrDTi7cIRGUI= github.com/shirou/gopsutil/v4 v4.26.1 h1:TOkEyriIXk2HX9d4isZJtbjXbEjf5qyKPAzbzY0JWSo=
github.com/shirou/gopsutil/v4 v4.26.2/go.mod h1:LZ6ewCSkBqUpvSOf+LsTGnRinC6iaNUNMGBtDkJBaLQ= github.com/shirou/gopsutil/v4 v4.26.1/go.mod h1:medLI9/UNAb0dOI9Q3/7yWSqKkj00u+1tgY8nvv41pc=
github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w= github.com/sirupsen/logrus v1.9.4 h1:TsZE7l11zFCLZnZ+teH4Umoq5BhEIfIzfRDZ1Uzql2w=
github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g= github.com/sirupsen/logrus v1.9.4/go.mod h1:ftWc9WdOfJ0a92nsE2jF5u5ZwH8Bv2zdeOC42RjbV2g=
github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik= github.com/sorairolake/lzip-go v0.3.8 h1:j5Q2313INdTA80ureWYRhX+1K78mUXfMoPZCw/ivWik=
@@ -240,8 +244,8 @@ github.com/stretchr/testify v1.8.1/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o
github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4= github.com/stretchr/testify v1.8.2/go.mod h1:w2LPCIKwWwSfY2zedu0+kehJoqGctiVI29o6fzry7u4=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/testcontainers/testcontainers-go v0.41.0 h1:mfpsD0D36YgkxGj2LrIyxuwQ9i2wCKAD+ESsYM1wais= github.com/testcontainers/testcontainers-go v0.40.0 h1:pSdJYLOVgLE8YdUY2FHQ1Fxu+aMnb6JfVz1mxk7OeMU=
github.com/testcontainers/testcontainers-go v0.41.0/go.mod h1:pdFrEIfaPl24zmBjerWTTYaY0M6UHsqA1YSvsoU40MI= github.com/testcontainers/testcontainers-go v0.40.0/go.mod h1:FSXV5KQtX2HAMlm7U3APNyLkkap35zNLxukw9oBi/MY=
github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA= github.com/tklauser/go-sysconf v0.3.16 h1:frioLaCQSsF5Cy1jgRBrzr6t502KIIwQ0MArYICU0nA=
github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI= github.com/tklauser/go-sysconf v0.3.16/go.mod h1:/qNL9xxDhc7tx3HSRsLWNnuzbVfh3e7gh/BmM179nYI=
github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw= github.com/tklauser/numcpus v0.11.0 h1:nSTwhKH5e1dMNsCdVBukSZrURJRoHbSEQjdEbY+9RXw=
@@ -259,61 +263,90 @@ github.com/yusufpapurcu/wmi v1.2.4 h1:zFUKzehAFReQwLys1b/iSMl+JQGSCSjtVqQn9bBrPo
github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0= github.com/yusufpapurcu/wmi v1.2.4/go.mod h1:SBZ9tNy3G9/m5Oi98Zks0QjeHVDvuK0qfxQmPyzfmi0=
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0 h1:OyrsyzuttWTSur2qN/Lm0m2a8yqyIjUVBZcxFPuXq2o= go.opentelemetry.io/contrib/bridges/otelslog v0.15.0 h1:yOYhGNPZseueTTvWp5iBD3/CthrmvayUXYEX862dDi4=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.67.0/go.mod h1:C2NGBr+kAB4bk3xtMXfZ94gqFDtg/GkI7e9zqGh5Beg= go.opentelemetry.io/contrib/bridges/otelslog v0.15.0/go.mod h1:CvaNVqIfcybc+7xqZNubbE+26K6P7AKZF/l0lE2kdCk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0 h1:dkBzNEAIKADEaFnuESzcXvpd09vxvDZsOjx11gjUqLk=
go.opentelemetry.io/contrib/bridges/prometheus v0.67.0/go.mod h1:Z5RIwRkZgauOIfnG5IpidvLpERjhTninpP1dTG2jTl4=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0 h1:4fnRcNpc6YFtG3zsFw9achKn3XgmxPxuMuqIL5rE8e8=
go.opentelemetry.io/contrib/exporters/autoexport v0.67.0/go.mod h1:qTvIHMFKoxW7HXg02gm6/Wofhq5p3Ib/A/NNt1EoBSQ=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0 h1:7iP2uCb7sGddAr30RRS6xjKy7AZ2JtTOPA3oolgVSw8=
go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp v0.65.0/go.mod h1:c7hN3ddxs/z6q9xwvfLPk+UHlWRQyaeR1LdgfL/66l0=
go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho= go.opentelemetry.io/otel v1.42.0 h1:lSQGzTgVR3+sgJDAU/7/ZMjN9Z+vUip7leaqBKy4sho=
go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc= go.opentelemetry.io/otel v1.42.0/go.mod h1:lJNsdRMxCUIWuMlVJWzecSMuNjE7dOYyWlqOXWkdqCc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0 h1:Ahq7pZmv87yiyn3jeFz/LekZmPLLdKejuO3NcK9MssM= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0 h1:deI9UQMoGFgrg5iLPgzueqFPHevDl+28YKfSpPTI6rY=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.37.0/go.mod h1:MJTqhM0im3mRLw1i8uGHnCvUEeS7VwRyxlLC78PA18M= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploggrpc v0.18.0/go.mod h1:PFx9NgpNUKXdf7J4Q3agRxMs3Y07QhTCVipKmLsMKnU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0 h1:inYW9ZhgqiDqh6BioM7DVHHzEGVq76Db5897WLGZ5Go= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0 h1:icqq3Z34UrEFk2u+HMhTtRsvo7Ues+eiJVjaJt62njs=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.41.0/go.mod h1:Izur+Wt8gClgMJqO/cZ8wdeeMryJ/xxiOVgFSSfpDTY= go.opentelemetry.io/otel/exporters/otlp/otlplog/otlploghttp v0.18.0/go.mod h1:W2m8P+d5Wn5kipj4/xmbt9uMqezEKfBjzVJadfABSBE=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0 h1:MdKucPl/HbzckWWEisiNqMPhRrAOQX8r4jTuGr636gk=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetricgrpc v1.42.0/go.mod h1:RolT8tWtfHcjajEH5wFIZ4Dgh5jpPdFXYV9pTAk/qjc=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0 h1:H7O6RlGOMTizyl3R08Kn5pdM06bnH8oscSj7o11tmLA=
go.opentelemetry.io/otel/exporters/otlp/otlpmetric/otlpmetrichttp v1.42.0/go.mod h1:mBFWu/WOVDkWWsR7Tx7h6EpQB8wsv7P0Yrh0Pb7othc=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0 h1:THuZiwpQZuHPul65w4WcwEnkX2QIuMT+UFoOrygtoJw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.42.0/go.mod h1:J2pvYM5NGHofZ2/Ru6zw/TNWnEQp5crgyDeSrYpXkAw=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0 h1:zWWrB1U6nqhS/k6zYB74CjRpuiitRtLLi68VcgmOEto=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc v1.42.0/go.mod h1:2qXPNBX1OVRC0IwOnfo1ljoid+RD0QK3443EaqVlsOU=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0 h1:uLXP+3mghfMf7XmV4PkGfFhFKuNWoCvvx5wP/wOXo0o=
go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracehttp v1.42.0/go.mod h1:v0Tj04armyT59mnURNUJf7RCKcKzq+lgJs6QSjHjaTc=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0 h1:g0LRDXMX/G1SEZtK8zl8Chm4K6GBwRkjPKE36LxiTYs=
go.opentelemetry.io/otel/exporters/prometheus v0.64.0/go.mod h1:UrgcjnarfdlBDP3GjDIJWe6HTprwSazNjwsI+Ru6hro=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0 h1:KJVjPD3rcPb98rIs3HznyJlrfx9ge5oJvxxlGR+P/7s=
go.opentelemetry.io/otel/exporters/stdout/stdoutlog v0.18.0/go.mod h1:K3kRa2ckmHWQaTWQdPRHc7qGXASuVuoEQXzrvlA98Ws=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0 h1:lSZHgNHfbmQTPfuTmWVkEu8J8qXaQwuV30pjCcAUvP8=
go.opentelemetry.io/otel/exporters/stdout/stdoutmetric v1.42.0/go.mod h1:so9ounLcuoRDu033MW/E0AD4hhUjVqswrMF5FoZlBcw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0 h1:s/1iRkCKDfhlh1JF26knRneorus8aOwVIDhvYx9WoDw=
go.opentelemetry.io/otel/exporters/stdout/stdouttrace v1.42.0/go.mod h1:UI3wi0FXg1Pofb8ZBiBLhtMzgoTm1TYkMvn71fAqDzs=
go.opentelemetry.io/otel/log v0.18.0 h1:XgeQIIBjZZrliksMEbcwMZefoOSMI1hdjiLEiiB0bAg=
go.opentelemetry.io/otel/log v0.18.0/go.mod h1:KEV1kad0NofR3ycsiDH4Yjcoj0+8206I6Ox2QYFSNgI=
go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4= go.opentelemetry.io/otel/metric v1.42.0 h1:2jXG+3oZLNXEPfNmnpxKDeZsFI5o4J+nz6xUlaFdF/4=
go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI= go.opentelemetry.io/otel/metric v1.42.0/go.mod h1:RlUN/7vTU7Ao/diDkEpQpnz3/92J9ko05BIwxYa2SSI=
go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo= go.opentelemetry.io/otel/sdk v1.42.0 h1:LyC8+jqk6UJwdrI/8VydAq/hvkFKNHZVIWuslJXYsDo=
go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts= go.opentelemetry.io/otel/sdk v1.42.0/go.mod h1:rGHCAxd9DAph0joO4W6OPwxjNTYWghRWmkHuGbayMts=
go.opentelemetry.io/otel/sdk/log v0.18.0 h1:n8OyZr7t7otkeTnPTbDNom6rW16TBYGtvyy2Gk6buQw=
go.opentelemetry.io/otel/sdk/log v0.18.0/go.mod h1:C0+wxkTwKpOCZLrlJ3pewPiiQwpzycPI/u6W0Z9fuYk=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0 h1:l3mYuPsuBx6UKE47BVcPrZoZ0q/KER57vbj2qkgDLXA=
go.opentelemetry.io/otel/sdk/log/logtest v0.18.0/go.mod h1:7cHtiVJpZebB3wybTa4NG+FUo5NPe3PROz1FqB0+qdw=
go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA= go.opentelemetry.io/otel/sdk/metric v1.42.0 h1:D/1QR46Clz6ajyZ3G8SgNlTJKBdGp84q9RKCAZ3YGuA=
go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc= go.opentelemetry.io/otel/sdk/metric v1.42.0/go.mod h1:Ua6AAlDKdZ7tdvaQKfSmnFTdHx37+J4ba8MwVCYM5hc=
go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY= go.opentelemetry.io/otel/trace v1.42.0 h1:OUCgIPt+mzOnaUTpOQcBiM/PLQ/Op7oq6g4LenLmOYY=
go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc= go.opentelemetry.io/otel/trace v1.42.0/go.mod h1:f3K9S+IFqnumBkKhRJMeaZeNk9epyhnCmQh/EysQCdc=
go.opentelemetry.io/proto/otlp v1.7.1 h1:gTOMpGDb0WTBOP8JaO72iL3auEZhVmAQg4ipjOVAtj4= go.opentelemetry.io/proto/otlp v1.9.0 h1:l706jCMITVouPOqEnii2fIAuO3IVGBRPV5ICjceRb/A=
go.opentelemetry.io/proto/otlp v1.7.1/go.mod h1:b2rVh6rfI/s2pHWNlB7ILJcRALpcNDzKhACevjI+ZnE= go.opentelemetry.io/proto/otlp v1.9.0/go.mod h1:xE+Cx5E/eEHw+ISFkwPLwCZefwVjY+pqKg1qcK03+/4=
go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto=
go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE=
go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0=
go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y=
go.uber.org/zap v1.27.1 h1:08RqriUEv8+ArZRYSTXy1LeBScaMpVSTBhCeaZYfMYc= go.yaml.in/yaml/v2 v2.4.3 h1:6gvOSjQoTB3vt1l+CU+tSyi/HOjfOjRLJ4YwYZGwRO0=
go.uber.org/zap v1.27.1/go.mod h1:GB2qFLM7cTU87MWRP2mPIjqfIDnGu+VIO4V/SdhGo2E= go.yaml.in/yaml/v2 v2.4.3/go.mod h1:zSxWcmIDjOzPXpjlTTbAsKokqkDNAVtZO0WOMiT90s8=
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= go4.org v0.0.0-20260112195520-a5071408f32f h1:ziUVAjmTPwQMBmYR1tbdRFJPtTcQUI12fH9QQjfb0Sw=
go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0= go4.org v0.0.0-20260112195520-a5071408f32f/go.mod h1:ZRJnO5ZI4zAwMFp+dS1+V6J6MSyAowhRqAE+DPa1Xp0=
golang.org/x/crypto v0.49.0 h1:+Ng2ULVvLHnJ/ZFEq4KdcDd/cfjrrjjNSXNzxg0Y4U4= golang.org/x/crypto v0.48.0 h1:/VRzVqiRSggnhY7gNRxPauEQ5Drw9haKdM0jqfcCFts=
golang.org/x/crypto v0.49.0/go.mod h1:ErX4dUh2UM+CFYiXZRTcMpEcN8b/1gxEuv3nODoYtCA= golang.org/x/crypto v0.48.0/go.mod h1:r0kV5h3qnFPlQnBSrULhlsRfryS2pmewsg+XfMgkVos=
golang.org/x/net v0.52.0 h1:He/TN1l0e4mmR3QqHMT2Xab3Aj3L9qjbhRm78/6jrW0= golang.org/x/net v0.51.0 h1:94R/GTO7mt3/4wIKpcR5gkGmRLOuE/2hNGeWq/GBIFo=
golang.org/x/net v0.52.0/go.mod h1:R1MAz7uMZxVMualyPXb+VaqGSa3LIaUqk0eEt3w36Sw= golang.org/x/net v0.51.0/go.mod h1:aamm+2QF5ogm02fjy5Bb7CQ0WMt1/WVM7FtyaTLlA9Y=
golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20190916202348-b4ddaad3f8a3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs= golang.org/x/sys v0.0.0-20201204225414-ed752295db88/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.42.0 h1:omrd2nAlyT5ESRdCLYdm3+fMfNFE/+Rf4bDIQImRJeo= golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
golang.org/x/sys v0.42.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/term v0.41.0 h1:QCgPso/Q3RTJx2Th4bDLqML4W6iJiaXFq2/ftQF13YU= golang.org/x/term v0.40.0 h1:36e4zGLqU4yhjlmxEaagx2KuYbJq3EwY8K943ZsHcvg=
golang.org/x/term v0.41.0/go.mod h1:3pfBgksrReYfZ5lvYM0kSO0LIkAl4Yl2bXOkKP7Ec2A= golang.org/x/term v0.40.0/go.mod h1:w2P8uVp06p2iyKKuvXIm7N/y0UCRt3UfJTfZ7oOpglM=
golang.org/x/text v0.35.0 h1:JOVx6vVDFokkpaq1AEptVzLTpDe9KGpj5tR4/X+ybL8= golang.org/x/text v0.34.0 h1:oL/Qq0Kdaqxa1KbNeMKwQq0reLCCaFtqu2eNuSeNHbk=
golang.org/x/text v0.35.0/go.mod h1:khi/HExzZJ2pGnjenulevKNX1W67CUy0AsXcNubPGCA= golang.org/x/text v0.34.0/go.mod h1:homfLqTYRFyVYemLBFl5GgL/DWEiH5wcsQ5gSh1yziA=
golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.14.0 h1:MRx4UaLrDotUKUdCIqzPC48t1Y9hANFKIRpNx+Te8PI=
golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= golang.org/x/time v0.14.0/go.mod h1:eL/Oa2bBBK0TkX57Fyni+NgnyQQN4LitPmob2Hjnqw4=
golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0= golang.org/x/xerrors v0.0.0-20191204190536-9bdfabe68543/go.mod h1:I/5z698sn9Ka8TeJc9MKroUUfqBBauWjQqLJ2OPfmY0=
google.golang.org/genproto v0.0.0-20251202230838-ff82c1b0f217 h1:GvESR9BIyHUahIb0NcTum6itIWtdoglGX+rnGxm2934= gonum.org/v1/gonum v0.16.0 h1:5+ul4Swaf3ESvrOnidPp4GZbzf0mxVQpDCYUQE7OJfk=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217 h1:fCvbg86sFXwdrl5LgVcTEvNC+2txB5mgROGmRL5mrls= gonum.org/v1/gonum v0.16.0/go.mod h1:fef3am4MQ93R2HHpKnLk4/Tbh/s0+wqD5nfa6Pnwy4E=
google.golang.org/genproto/googleapis/api v0.0.0-20251202230838-ff82c1b0f217/go.mod h1:+rXWjjaukWZun3mLfjmVnQi18E1AsFbDN9QdJ5YXLto= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171 h1:tu/dtnW1o3wfaxCOjSLn5IRX4YDcJrtlpzYkhHhGaC4=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b h1:Mv8VFug0MP9e5vUxfBcE3vUkV6CImK3cMNMIDFjmzxU= google.golang.org/genproto/googleapis/api v0.0.0-20260226221140-a57be14db171/go.mod h1:M5krXqk4GhBKvB596udGL3UyjL4I1+cTbK0orROM9ng=
google.golang.org/genproto/googleapis/rpc v0.0.0-20251222181119-0a764e51fe1b/go.mod h1:j9x/tPzZkyxcgEFkiKEEGxfvyumM01BEtsW8xzOahRQ= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171 h1:ggcbiqK8WWh6l1dnltU4BgWGIGo+EVYxCaAPih/zQXQ=
google.golang.org/grpc v1.78.0 h1:K1XZG/yGDJnzMdd/uZHAkVqJE+xIDOcmdSFZkBUicNc= google.golang.org/genproto/googleapis/rpc v0.0.0-20260226221140-a57be14db171/go.mod h1:4Hqkh8ycfw05ld/3BWL7rJOSfebL2Q+DVDeRgYgxUU8=
google.golang.org/grpc v1.78.0/go.mod h1:I47qjTo4OKbMkjA/aOOwxDIiPSBofUtQUI5EfpWvW7U= google.golang.org/grpc v1.79.2 h1:fRMD94s2tITpyJGtBBn7MkMseNpOZU8ZxgC3MMBaXRU=
google.golang.org/grpc v1.79.2/go.mod h1:KmT0Kjez+0dde/v2j9vzwoAScgEPx/Bw1CYChhHLrHQ=
google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE= google.golang.org/protobuf v1.36.11 h1:fV6ZwhNocDyBLK0dj+fg8ektcVegBBuEolpbTQyBNVE=
google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco= google.golang.org/protobuf v1.36.11/go.mod h1:HTf+CrKn2C3g5S8VImy6tdcUvCska2kB7j23XfzDpco=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=

View File

@@ -0,0 +1,32 @@
receivers:
otlp:
protocols:
grpc:
endpoint: "0.0.0.0:4317"
processors:
batch:
exporters:
otlp/openobserve:
endpoint: "openobserve:5081"
tls:
insecure: true
headers:
Authorization: "Basic dGVsZW1ldHJ5QGdvdGVuYmVyZy5kZXY6dGVsZW1ldHJ5"
organization: "default"
service:
pipelines:
traces:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]
metrics:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]
logs:
receivers: [otlp]
processors: [batch]
exporters: [otlp/openobserve]

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,18 @@
package log
import "log/slog"
func gcpSeverity(l slog.Level) string {
switch {
case l < slog.LevelInfo:
return "DEBUG"
case l < slog.LevelWarn:
return "INFO"
case l < slog.LevelError:
return "WARNING"
case l >= slog.LevelError:
return "ERROR"
default:
return "DEFAULT"
}
}

View File

@@ -0,0 +1,171 @@
package log
import (
"context"
"errors"
"log/slog"
)
type gotenbergHandler struct {
slog.Handler
fieldsPrefix string
loggerName string
}
func NewGotenbergHandler(next slog.Handler, prefix string) slog.Handler {
return &gotenbergHandler{Handler: next, fieldsPrefix: prefix}
}
func (h *gotenbergHandler) Handle(ctx context.Context, r slog.Record) error {
var newAttrs []slog.Attr
if h.loggerName != "" {
newAttrs = append(newAttrs, slog.String("logger", h.loggerName))
}
var needsNewRecord bool
if len(newAttrs) > 0 {
needsNewRecord = true
}
if h.fieldsPrefix != "" {
r.Attrs(func(a slog.Attr) bool {
if a.Key == "logger" || a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" {
newAttrs = append(newAttrs, a)
needsNewRecord = true
return true
}
a.Key = h.fieldsPrefix + "_" + a.Key
newAttrs = append(newAttrs, a)
needsNewRecord = true
return true
})
} else if needsNewRecord {
r.Attrs(func(a slog.Attr) bool {
newAttrs = append(newAttrs, a)
return true
})
}
if needsNewRecord {
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
newR.AddAttrs(newAttrs...)
r = newR
}
return h.Handler.Handle(ctx, r)
}
func (h *gotenbergHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
var prefixed []slog.Attr
newLoggerName := h.loggerName
for _, a := range attrs {
if a.Key == "logger" {
if newLoggerName == "" {
newLoggerName = a.Value.String()
} else {
newLoggerName = newLoggerName + "." + a.Value.String()
}
continue
}
if h.fieldsPrefix != "" {
if a.Key == "correlation_id" || a.Key == "trace_id" || a.Key == "span_id" {
// Don't prefix these keys
} else {
a.Key = h.fieldsPrefix + "_" + a.Key
}
}
prefixed = append(prefixed, a)
}
newHandler := h.Handler
if len(prefixed) > 0 {
newHandler = h.Handler.WithAttrs(prefixed)
}
return &gotenbergHandler{
Handler: newHandler,
fieldsPrefix: h.fieldsPrefix,
loggerName: newLoggerName,
}
}
func (h *gotenbergHandler) WithGroup(name string) slog.Handler {
return &gotenbergHandler{
Handler: h.Handler.WithGroup(name),
fieldsPrefix: h.fieldsPrefix,
loggerName: h.loggerName,
}
}
type multiHandler struct {
handlers []slog.Handler
}
func FanOut(handlers ...slog.Handler) slog.Handler {
return &multiHandler{handlers: handlers}
}
func (m *multiHandler) Enabled(ctx context.Context, level slog.Level) bool {
for _, h := range m.handlers {
if h.Enabled(ctx, level) {
return true
}
}
return false
}
func (m *multiHandler) Handle(ctx context.Context, r slog.Record) error {
var errs []error
for _, h := range m.handlers {
if h.Enabled(ctx, r.Level) {
if err := h.Handle(ctx, r.Clone()); err != nil {
errs = append(errs, err)
}
}
}
return errors.Join(errs...)
}
func (m *multiHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
cloned := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
cloned[i] = h.WithAttrs(attrs)
}
return &multiHandler{handlers: cloned}
}
func (m *multiHandler) WithGroup(name string) slog.Handler {
cloned := make([]slog.Handler, len(m.handlers))
for i, h := range m.handlers {
cloned[i] = h.WithGroup(name)
}
return &multiHandler{handlers: cloned}
}
type levelHandler struct {
slog.Handler
level slog.Level
}
func LevelFilter(next slog.Handler, level slog.Level) slog.Handler {
return &levelHandler{Handler: next, level: level}
}
func (h *levelHandler) Enabled(ctx context.Context, level slog.Level) bool {
return level >= h.level && h.Handler.Enabled(ctx, level)
}
func (h *levelHandler) Handle(ctx context.Context, r slog.Record) error {
return h.Handler.Handle(ctx, r)
}
func (h *levelHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return &levelHandler{Handler: h.Handler.WithAttrs(attrs), level: h.level}
}
func (h *levelHandler) WithGroup(name string) slog.Handler {
return &levelHandler{Handler: h.Handler.WithGroup(name), level: h.level}
}

View File

@@ -0,0 +1,37 @@
package log
import (
"log/slog"
"sync"
)
// InitLogger initializes the global logger.
func InitLogger(handler slog.Handler) {
if logger != nil {
return
}
mu.Lock()
defer mu.Unlock()
// Double check: ensure it wasn't initialized while we waited for the lock.
if logger != nil {
return
}
logger = slog.New(handler)
}
// Logger returns the global logger.
func Logger() *slog.Logger {
mu.Lock()
defer mu.Unlock()
return logger
}
// logger is Singleton so that we instantiate our [slog.Logger] only once.
var (
logger *slog.Logger
mu sync.Mutex
)

View File

@@ -0,0 +1,127 @@
package log
import (
"context"
"log/slog"
"os"
"strings"
"go.opentelemetry.io/otel/trace"
"golang.org/x/term"
)
const (
autoLoggingFormat = "auto"
jsonLoggingFormat = "json"
textLoggingFormat = "text"
)
// traceContextHandler is an internal wrapper that specifically injects
// trace_id and span_id into the log record before they are written to the standard output.
type traceContextHandler struct {
slog.Handler
}
func (h traceContextHandler) Handle(ctx context.Context, r slog.Record) error {
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
// Since slog.Record is immutable with regards to adding attributes in-place,
// we must clone and add them.
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
r.Attrs(func(a slog.Attr) bool {
newR.AddAttrs(a)
return true
})
newR.AddAttrs(
slog.String("trace_id", spanCtx.TraceID().String()),
slog.String("span_id", spanCtx.SpanID().String()),
)
r = newR
}
return h.Handler.Handle(ctx, r)
}
func (h traceContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
return traceContextHandler{Handler: h.Handler.WithAttrs(attrs)}
}
func (h traceContextHandler) WithGroup(name string) slog.Handler {
return traceContextHandler{Handler: h.Handler.WithGroup(name)}
}
// NewStdHandler returns a [slog.Handler] instance for the standard output.
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
// #nosec: G115
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
// Normalize the log format based on the output device.
if format == autoLoggingFormat {
if isTerminal {
format = textLoggingFormat
} else {
format = jsonLoggingFormat
}
}
opts := &slog.HandlerOptions{
Level: level,
}
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
// Configure level encoding based on format and GCP settings.
if a.Key == slog.LevelKey {
l := a.Value.Any().(slog.Level)
if format == textLoggingFormat && isTerminal {
if enableGcpFields {
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
} else {
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
}
} else if enableGcpFields && format != textLoggingFormat {
a.Key = "severity"
a.Value = slog.StringValue(gcpSeverity(l))
} else {
a.Value = slog.StringValue(strings.ToLower(l.String()))
}
}
if a.Key == slog.TimeKey {
if enableGcpFields && format != textLoggingFormat {
a.Key = "time"
} else {
a.Key = "ts"
}
if isTerminal {
a.Value = slog.StringValue(a.Value.Time().Local().Format("2006/01/02 15:04:05.000"))
} else if !enableGcpFields {
a.Value = slog.Float64Value(float64(a.Value.Time().UnixNano()) / 1e9)
}
}
if a.Key == slog.MessageKey {
if enableGcpFields && format != textLoggingFormat {
a.Key = "message"
} else {
a.Key = "msg"
}
}
return a
}
var handler slog.Handler
if format == textLoggingFormat {
handler = slog.NewTextHandler(os.Stderr, opts)
} else {
handler = slog.NewJSONHandler(os.Stderr, opts)
}
return traceContextHandler{Handler: handler}, nil
}
func gcpSeverityColorEncoder(l slog.Level) string {
severity := gcpSeverity(l)
c := levelToColor(l)
return c.Add(severity)
}

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 +0,0 @@
package gotenberg
import (
"fmt"
"github.com/hashicorp/go-retryablehttp"
"go.uber.org/zap"
)
// LoggerProvider is an interface for a module that supplies a method for
// creating a [zap.Logger] instance for use by other modules.
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m)
// }
type LoggerProvider interface {
Logger(mod Module) (*zap.Logger, error)
}
// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *zap.Logger
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
}
}
// Error logs a message at the error level using the wrapped zap.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at the warning level using the wrapped zap.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at the info level using the wrapped zap.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at the debug level using the wrapped zap.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -1,26 +0,0 @@
package gotenberg
// Metric represents a unitary metric.
type Metric struct {
// Name is the unique identifier.
// Required.
Name string
// Description describes the metric.
// Optional.
Description string
// Read returns the current value.
// Required.
Read func() float64
}
// MetricsProvider is a module interface which provides a list of [Metric].
//
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
// provider, _ := ctx.Module(new(gotenberg.MetricsProvider))
// metrics, _ := provider.(gotenberg.MetricsProvider).Metrics()
// }
type MetricsProvider interface {
Metrics() ([]Metric, error)
}

View File

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

View File

@@ -4,8 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"go.uber.org/zap"
) )
var ( var (
@@ -28,14 +27,6 @@ var (
// ErrPdfEncryptionNotSupported is returned when encryption // ErrPdfEncryptionNotSupported is returned when encryption
// is not supported by the PDF engine. // is not supported by the PDF engine.
ErrPdfEncryptionNotSupported = errors.New("encryption not supported") ErrPdfEncryptionNotSupported = errors.New("encryption not supported")
// ErrPdfStampSourceNotSupported is returned when a stamp source type
// is not supported by the PDF engine.
ErrPdfStampSourceNotSupported = errors.New("stamp source not supported")
// ErrPdfRotateAngleNotSupported is returned when the rotation angle is
// not supported.
ErrPdfRotateAngleNotSupported = errors.New("rotation angle not supported")
) )
// PdfEngineInvalidArgsError represents an error returned by a PDF engine when // PdfEngineInvalidArgsError represents an error returned by a PDF engine when
@@ -57,33 +48,6 @@ func NewPdfEngineInvalidArgs(engine, msg string) error {
return &PdfEngineInvalidArgsError{engine, msg} return &PdfEngineInvalidArgsError{engine, msg}
} }
const (
// StampSourceText represents a text-based stamp source.
StampSourceText string = "text"
// StampSourceImage represents an image-based stamp source.
StampSourceImage string = "image"
// StampSourcePDF represents a PDF-based stamp source.
StampSourcePDF string = "pdf"
)
// Stamp gathers the data required to apply a watermark or stamp to a PDF.
type Stamp struct {
// Source is one of "text", "image", or "pdf".
Source string
// Expression is the text content (for text source) or file path (for
// image/pdf source).
Expression string
// Pages is the optional page range to apply the stamp to.
Pages string
// Options holds engine-specific styling options.
Options map[string]string
}
const ( const (
// SplitModeIntervals represents a mode where a PDF is split at specific // SplitModeIntervals represents a mode where a PDF is split at specific
// intervals. // intervals.
@@ -144,14 +108,6 @@ type PdfFormats struct {
PdfUa bool PdfUa bool
} }
// Bookmark represents a node in the PDF document's outline
// (table of contents).
type Bookmark struct {
Title string `json:"title"`
Page int `json:"page"`
Children []Bookmark `json:"children,omitempty"`
}
// PdfEngine provides an interface for operations on PDFs. Implementations // PdfEngine provides an interface for operations on PDFs. Implementations
// can use various tools like PDFtk, or implement functionality directly in // can use various tools like PDFtk, or implement functionality directly in
// Go. // Go.
@@ -160,57 +116,36 @@ type Bookmark struct {
type PdfEngine interface { type PdfEngine interface {
// Merge combines multiple PDFs into a single PDF. The resulting page order // Merge combines multiple PDFs into a single PDF. The resulting page order
// is determined by the order of files provided in inputPaths. // is determined by the order of files provided in inputPaths.
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
// Split splits a given PDF file. // Split splits a given PDF file.
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
// Flatten merges existing annotation appearances with page content, // Flatten merges existing annotation appearances with page content,
// effectively deleting the original annotations. This process can flatten // effectively deleting the original annotations. This process can flatten
// forms as well as forms share a relationship with annotations. Note that // forms as well as forms share a relationship with annotations. Note that
// this operation is irreversible. // this operation is irreversible.
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error
// Convert transforms a given PDF to the specified formats defined in // Convert transforms a given PDF to the specified formats defined in
// PdfFormats. If no format, it does nothing. // PdfFormats. If no format, it does nothing.
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
// ReadMetadata extracts the metadata of a given PDF file. // ReadMetadata extracts the metadata of a given PDF file.
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
// PageCount returns the number of pages in a PDF file.
PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
// WriteMetadata writes the metadata into a given PDF file. // WriteMetadata writes the metadata into a given PDF file.
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
// The bookmarks parameter represents the hierarchical tree of the outline.
WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
// Encrypt adds password protection to a PDF file. // Encrypt adds password protection to a PDF file.
// The userPassword is required to open the document. // The userPassword is required to open the document.
// The ownerPassword provides full access to the document. // The ownerPassword provides full access to the document.
// If the ownerPassword is empty, it defaults to the userPassword. // If the ownerPassword is empty, it defaults to the userPassword.
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments // AddAttachments adds attachments into a PDF. All files are attached as
// without modifying the main PDF content. // file attachments without modifying the main PDF content.
// TODO: attachments instead? Rename the route? AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
EmbedFiles(ctx context.Context, logger *zap.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
// 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
// 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
} }
// PdfEngineProvider offers an interface to instantiate a [PdfEngine]. // PdfEngineProvider offers an interface to instantiate a [PdfEngine].

View File

@@ -0,0 +1,48 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/bench_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"net/url"
"testing"
"go.opentelemetry.io/otel/attribute"
)
var benchHTTPServerRequestResults []attribute.KeyValue
// BenchmarkHTTPServerRequest allows comparison between different version of the HTTP server.
// To use an alternative start this test with OTEL_SEMCONV_STABILITY_OPT_IN set to the
// version under test.
func BenchmarkHTTPServerRequest(b *testing.B) {
// Request was generated from TestHTTPServerRequest request.
req := &http.Request{
Method: http.MethodGet,
URL: &url.URL{
Path: "/",
},
Proto: "HTTP/1.1",
ProtoMajor: 1,
ProtoMinor: 1,
Header: http.Header{
"User-Agent": []string{"Go-http-client/1.1"},
"Accept-Encoding": []string{"gzip"},
},
Body: http.NoBody,
Host: "127.0.0.1:39093",
RemoteAddr: "127.0.0.1:38738",
RequestURI: "/",
}
serv := NewHTTPServer(nil)
b.ReportAllocs()
b.ResetTimer()
for range b.N {
benchHTTPServerRequestResults = serv.RequestTraceAttrs("", req, RequestTraceAttrsOpts{})
}
}

View File

@@ -0,0 +1,308 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/client.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package semconv provides OpenTelemetry semantic convention types and
// functionality.
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
import (
"context"
"fmt"
"net/http"
"reflect"
"slices"
"strconv"
"strings"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/semconv/v1.39.0/httpconv"
)
type HTTPClient struct {
requestBodySize httpconv.ClientRequestBodySize
requestDuration httpconv.ClientRequestDuration
}
func NewHTTPClient(meter metric.Meter) HTTPClient {
client := HTTPClient{}
var err error
client.requestBodySize, err = httpconv.NewClientRequestBodySize(meter)
handleErr(err)
client.requestDuration, err = httpconv.NewClientRequestDuration(
meter,
metric.WithExplicitBucketBoundaries(0.005, 0.01, 0.025, 0.05, 0.075, 0.1, 0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10),
)
handleErr(err)
return client
}
func (n HTTPClient) Status(code int) (codes.Code, string) {
if code == 0 {
return codes.Error, "No HTTP status code"
}
if code < 100 || code >= 600 {
return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code)
}
if code >= 400 {
return codes.Error, http.StatusText(code)
}
return codes.Ok, ""
}
// RequestTraceAttrs returns trace attributes for an HTTP request made by a client.
func (n HTTPClient) RequestTraceAttrs(req *http.Request) []attribute.KeyValue {
/*
below attributes are returned:
- http.request.method
- http.request.method.original
- url.full
- server.address
- server.port
- network.protocol.name
- network.protocol.version
*/
numOfAttributes := 3 // URL, server address, proto, and method.
var urlHost string
if req.URL != nil {
urlHost = req.URL.Host
}
var requestHost string
var requestPort int
for _, hostport := range []string{urlHost, req.Header.Get("Host")} {
requestHost, requestPort = SplitHostPort(hostport)
if requestHost != "" || requestPort > 0 {
break
}
}
eligiblePort := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort)
if eligiblePort > 0 {
numOfAttributes++
}
useragent := req.UserAgent()
if useragent != "" {
numOfAttributes++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" && protoName != "http" {
numOfAttributes++
}
if protoVersion != "" {
numOfAttributes++
}
method, originalMethod := n.method(req.Method)
if originalMethod != (attribute.KeyValue{}) {
numOfAttributes++
}
attrs := make([]attribute.KeyValue, 0, numOfAttributes)
attrs = append(attrs, method)
if originalMethod != (attribute.KeyValue{}) {
attrs = append(attrs, originalMethod)
}
var u string
if req.URL != nil {
// Remove any username/password info that may be in the URL.
userinfo := req.URL.User
req.URL.User = nil
u = req.URL.String()
// Restore any username/password info that was removed.
req.URL.User = userinfo
}
attrs = append(attrs, semconv.URLFull(u))
attrs = append(attrs, semconv.ServerAddress(requestHost))
if eligiblePort > 0 {
attrs = append(attrs, semconv.ServerPort(eligiblePort))
}
if protoName != "" && protoName != "http" {
attrs = append(attrs, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion))
}
return attrs
}
// ResponseTraceAttrs returns trace attributes for an HTTP response made by a client.
func (n HTTPClient) ResponseTraceAttrs(resp *http.Response) []attribute.KeyValue {
/*
below attributes are returned:
- http.response.status_code
- error.type
*/
var count int
if resp.StatusCode > 0 {
count++
}
if isErrorStatusCode(resp.StatusCode) {
count++
}
attrs := make([]attribute.KeyValue, 0, count)
if resp.StatusCode > 0 {
attrs = append(attrs, semconv.HTTPResponseStatusCode(resp.StatusCode))
}
if isErrorStatusCode(resp.StatusCode) {
errorType := strconv.Itoa(resp.StatusCode)
attrs = append(attrs, semconv.ErrorTypeKey.String(errorType))
}
return attrs
}
func (n HTTPClient) ErrorType(err error) attribute.KeyValue {
t := reflect.TypeOf(err)
var value string
if t.PkgPath() == "" && t.Name() == "" {
// Likely a builtin type.
value = t.String()
} else {
value = fmt.Sprintf("%s.%s", t.PkgPath(), t.Name())
}
if value == "" {
return semconv.ErrorTypeOther
}
return semconv.ErrorTypeKey.String(value)
}
func (n HTTPClient) method(method string) (attribute.KeyValue, attribute.KeyValue) {
if method == "" {
return semconv.HTTPRequestMethodGet, attribute.KeyValue{}
}
if attr, ok := methodLookup[method]; ok {
return attr, attribute.KeyValue{}
}
orig := semconv.HTTPRequestMethodOriginal(method)
if attr, ok := methodLookup[strings.ToUpper(method)]; ok {
return attr, orig
}
return semconv.HTTPRequestMethodGet, orig
}
func (n HTTPClient) MetricAttributes(req *http.Request, statusCode int, additionalAttributes []attribute.KeyValue) []attribute.KeyValue {
num := len(additionalAttributes) + 2
var h string
if req.URL != nil {
h = req.URL.Host
}
var requestHost string
var requestPort int
for _, hostport := range []string{h, req.Header.Get("Host")} {
requestHost, requestPort = SplitHostPort(hostport)
if requestHost != "" || requestPort > 0 {
break
}
}
port := requiredHTTPPort(req.URL != nil && req.URL.Scheme == "https", requestPort)
if port > 0 {
num++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" {
num++
}
if protoVersion != "" {
num++
}
if statusCode > 0 {
num++
}
attributes := slices.Grow(additionalAttributes, num)
attributes = append(attributes,
semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)),
semconv.ServerAddress(requestHost),
n.scheme(req),
)
if port > 0 {
attributes = append(attributes, semconv.ServerPort(port))
}
if protoName != "" {
attributes = append(attributes, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion))
}
if statusCode > 0 {
attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode))
}
return attributes
}
type MetricOpts struct {
measurement metric.MeasurementOption
addOptions metric.AddOption
}
func (o MetricOpts) MeasurementOption() metric.MeasurementOption {
return o.measurement
}
func (o MetricOpts) AddOptions() metric.AddOption {
return o.addOptions
}
func (n HTTPClient) MetricOptions(ma MetricAttributes) map[string]MetricOpts {
opts := map[string]MetricOpts{}
attributes := n.MetricAttributes(ma.Req, ma.StatusCode, ma.AdditionalAttributes)
set := metric.WithAttributeSet(attribute.NewSet(attributes...))
opts["new"] = MetricOpts{
measurement: set,
addOptions: set,
}
return opts
}
func (n HTTPClient) RecordMetrics(ctx context.Context, md MetricData, opts map[string]MetricOpts) {
n.requestBodySize.Inst().Record(ctx, md.RequestSize, opts["new"].MeasurementOption())
n.requestDuration.Inst().Record(ctx, md.ElapsedTime/1000, opts["new"].MeasurementOption())
}
// TraceAttributes returns attributes for httptrace.
func (n HTTPClient) TraceAttributes(host string) []attribute.KeyValue {
return []attribute.KeyValue{
semconv.ServerAddress(host),
}
}
func (n HTTPClient) scheme(req *http.Request) attribute.KeyValue {
if req.URL != nil && req.URL.Scheme != "" {
return semconv.URLScheme(req.URL.Scheme)
}
if req.TLS != nil {
return semconv.URLScheme("https")
}
return semconv.URLScheme("http")
}
func isErrorStatusCode(code int) bool {
return code >= 400 || code < 100
}

View File

@@ -0,0 +1,182 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/client_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
)
func TestHTTPClientStatus(t *testing.T) {
tests := []struct {
code int
stat codes.Code
msg bool
}{
{0, codes.Error, true},
{http.StatusContinue, codes.Ok, false},
{http.StatusSwitchingProtocols, codes.Ok, false},
{http.StatusProcessing, codes.Ok, false},
{http.StatusEarlyHints, codes.Ok, false},
{http.StatusOK, codes.Ok, false},
{http.StatusCreated, codes.Ok, false},
{http.StatusAccepted, codes.Ok, false},
{http.StatusNonAuthoritativeInfo, codes.Ok, false},
{http.StatusNoContent, codes.Ok, false},
{http.StatusResetContent, codes.Ok, false},
{http.StatusPartialContent, codes.Ok, false},
{http.StatusMultiStatus, codes.Ok, false},
{http.StatusAlreadyReported, codes.Ok, false},
{http.StatusIMUsed, codes.Ok, false},
{http.StatusMultipleChoices, codes.Ok, false},
{http.StatusMovedPermanently, codes.Ok, false},
{http.StatusFound, codes.Ok, false},
{http.StatusSeeOther, codes.Ok, false},
{http.StatusNotModified, codes.Ok, false},
{http.StatusUseProxy, codes.Ok, false},
{306, codes.Ok, false},
{http.StatusTemporaryRedirect, codes.Ok, false},
{http.StatusPermanentRedirect, codes.Ok, false},
{http.StatusBadRequest, codes.Error, true},
{http.StatusUnauthorized, codes.Error, true},
{http.StatusPaymentRequired, codes.Error, true},
{http.StatusForbidden, codes.Error, true},
{http.StatusNotFound, codes.Error, true},
{http.StatusMethodNotAllowed, codes.Error, true},
{http.StatusNotAcceptable, codes.Error, true},
{http.StatusProxyAuthRequired, codes.Error, true},
{http.StatusRequestTimeout, codes.Error, true},
{http.StatusConflict, codes.Error, true},
{http.StatusGone, codes.Error, true},
{http.StatusLengthRequired, codes.Error, true},
{http.StatusPreconditionFailed, codes.Error, true},
{http.StatusRequestEntityTooLarge, codes.Error, true},
{http.StatusRequestURITooLong, codes.Error, true},
{http.StatusUnsupportedMediaType, codes.Error, true},
{http.StatusRequestedRangeNotSatisfiable, codes.Error, true},
{http.StatusExpectationFailed, codes.Error, true},
{http.StatusTeapot, codes.Error, true},
{http.StatusMisdirectedRequest, codes.Error, true},
{http.StatusUnprocessableEntity, codes.Error, true},
{http.StatusLocked, codes.Error, true},
{http.StatusFailedDependency, codes.Error, true},
{http.StatusTooEarly, codes.Error, true},
{http.StatusUpgradeRequired, codes.Error, true},
{http.StatusPreconditionRequired, codes.Error, true},
{http.StatusTooManyRequests, codes.Error, true},
{http.StatusRequestHeaderFieldsTooLarge, codes.Error, true},
{http.StatusUnavailableForLegalReasons, codes.Error, true},
{499, codes.Error, false},
{http.StatusInternalServerError, codes.Error, true},
{http.StatusNotImplemented, codes.Error, true},
{http.StatusBadGateway, codes.Error, true},
{http.StatusServiceUnavailable, codes.Error, true},
{http.StatusGatewayTimeout, codes.Error, true},
{http.StatusHTTPVersionNotSupported, codes.Error, true},
{http.StatusVariantAlsoNegotiates, codes.Error, true},
{http.StatusInsufficientStorage, codes.Error, true},
{http.StatusLoopDetected, codes.Error, true},
{http.StatusNotExtended, codes.Error, true},
{http.StatusNetworkAuthenticationRequired, codes.Error, true},
{600, codes.Error, true},
}
for _, test := range tests {
t.Run(strconv.Itoa(test.code), func(t *testing.T) {
c, msg := HTTPClient{}.Status(test.code)
assert.Equal(t, test.stat, c)
if test.msg && msg == "" {
t.Errorf("expected non-empty message for %d", test.code)
} else if !test.msg && msg != "" {
t.Errorf("expected empty message for %d, got: %s", test.code, msg)
}
})
}
}
func TestHTTPClient_MetricAttributes(t *testing.T) {
defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody)
require.NoError(t, err)
httpsRequest, err := http.NewRequest("GET", "https://example.com/path?query=test", http.NoBody)
require.NoError(t, err)
tests := []struct {
name string
server string
req *http.Request
statusCode int
additionalAttributes []attribute.KeyValue
wantFunc func(t *testing.T, attrs []attribute.KeyValue)
}{
{
name: "routine testing",
req: defaultRequest,
statusCode: 200,
additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")},
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 7)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "http"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
attribute.String("test", "test"),
}, attrs)
},
},
{
name: "use server address",
req: defaultRequest,
statusCode: 200,
additionalAttributes: nil,
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 6)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "http"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
}, attrs)
},
},
{
name: "https scheme",
req: httpsRequest,
statusCode: 200,
additionalAttributes: nil,
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 6)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "https"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
}, attrs)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HTTPClient{}.MetricAttributes(tt.req, tt.statusCode, tt.additionalAttributes)
tt.wantFunc(t, got)
})
}
}

View File

@@ -0,0 +1,71 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/common_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv_test
import (
"net/http"
"net/http/httptest"
"net/url"
"strconv"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
"go.opentelemetry.io/otel/attribute"
)
type testServerReq struct {
hostname string
serverPort int
peerAddr string
peerPort int
clientIP string
}
func testTraceRequest(t *testing.T, serv semconv.HTTPServer, want func(testServerReq) []attribute.KeyValue) {
t.Helper()
got := make(chan *http.Request, 1)
handler := func(w http.ResponseWriter, r *http.Request) {
got <- r
close(got)
w.WriteHeader(http.StatusOK)
}
srv := httptest.NewServer(http.HandlerFunc(handler))
defer srv.Close()
srvURL, err := url.Parse(srv.URL)
require.NoError(t, err)
srvPort, err := strconv.ParseInt(srvURL.Port(), 10, 32)
require.NoError(t, err)
resp, err := srv.Client().Get(srv.URL)
require.NoError(t, err)
require.NoError(t, resp.Body.Close())
req := <-got
peer, peerPort := semconv.SplitHostPort(req.RemoteAddr)
const user = "alice"
req.SetBasicAuth(user, "pswrd")
const clientIP = "127.0.0.5"
req.Header.Add("X-Forwarded-For", clientIP)
srvReq := testServerReq{
hostname: srvURL.Hostname(),
serverPort: int(srvPort),
peerAddr: peer,
peerPort: peerPort,
clientIP: clientIP,
}
assert.ElementsMatch(t, want(srvReq), serv.RequestTraceAttrs("", req, semconv.RequestTraceAttrsOpts{}))
}

View File

@@ -0,0 +1,7 @@
// Package semconv is a copy/paste of utilities that are currently not exposed
// in the OpenTelemery Go SDK.
//
// This package MUST be removed once an "official" API is provided.
//
// See: https://github.com/open-telemetry/opentelemetry-go-contrib/issues/4580.
package semconv

View File

@@ -0,0 +1,15 @@
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
// Generate semconv package:
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/bench_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=bench_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/common_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=common_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/server.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=server.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/server_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=server_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/client.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=client.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/client_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=client_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/httpconvtest_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=httpconvtest_test.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/util.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=util.go
//go:generate gotmpl --body=../../../../../../internal/shared/semconv/util_test.go.tmpl "--data={ \"pkg\": \"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp\" }" --out=util_test.go

View File

@@ -0,0 +1,455 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/httpconv_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv_test
import (
"errors"
"fmt"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/sdk/instrumentation"
sdkmetric "go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/metric/metricdata"
"go.opentelemetry.io/otel/sdk/metric/metricdata/metricdatatest"
)
func TestNewTraceRequest(t *testing.T) {
serv := semconv.NewHTTPServer(nil)
want := func(req testServerReq) []attribute.KeyValue {
return []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("url.scheme", "http"),
attribute.String("server.address", req.hostname),
attribute.Int("server.port", req.serverPort),
attribute.String("network.peer.address", req.peerAddr),
attribute.Int("network.peer.port", req.peerPort),
attribute.String("user_agent.original", "Go-http-client/1.1"),
attribute.String("client.address", req.clientIP),
attribute.String("network.protocol.version", "1.1"),
attribute.String("url.path", "/"),
}
}
testTraceRequest(t, serv, want)
}
func TestNewServerRecordMetrics(t *testing.T) {
oldAttrs := attribute.NewSet(
attribute.String("http.scheme", "http"),
attribute.String("http.method", "POST"),
attribute.Int64("http.status_code", 301),
attribute.String("key", "value"),
attribute.String("net.host.name", "stuff"),
attribute.String("net.protocol.name", "http"),
attribute.String("net.protocol.version", "1.1"),
)
currAttrs := attribute.NewSet(
attribute.String("http.request.method", "POST"),
attribute.Int64("http.response.status_code", 301),
attribute.String("key", "value"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.String("server.address", "stuff"),
attribute.String("url.scheme", "http"),
)
// the HTTPServer version
expectedCurrentScopeMetric := metricdata.ScopeMetrics{
Scope: instrumentation.Scope{
Name: "test",
},
Metrics: []metricdata.Metrics{
{
Name: "http.server.request.body.size",
Description: "Size of HTTP server request bodies.",
Unit: "By",
Data: metricdata.Histogram[int64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[int64]{
{
Attributes: currAttrs,
},
},
},
},
{
Name: "http.server.response.body.size",
Description: "Size of HTTP server response bodies.",
Unit: "By",
Data: metricdata.Histogram[int64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[int64]{
{
Attributes: currAttrs,
},
},
},
},
{
Name: "http.server.request.duration",
Description: "Duration of HTTP server requests.",
Unit: "s",
Data: metricdata.Histogram[float64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[float64]{
{
Attributes: currAttrs,
},
},
},
},
},
}
// The OldHTTPServer version
expectedOldScopeMetric := expectedCurrentScopeMetric
expectedOldScopeMetric.Metrics = append(expectedOldScopeMetric.Metrics, []metricdata.Metrics{
{
Name: "http.server.request.size",
Description: "Measures the size of HTTP request messages.",
Unit: "By",
Data: metricdata.Sum[int64]{
Temporality: metricdata.CumulativeTemporality,
IsMonotonic: true,
DataPoints: []metricdata.DataPoint[int64]{
{
Attributes: oldAttrs,
},
},
},
},
{
Name: "http.server.response.size",
Description: "Measures the size of HTTP response messages.",
Unit: "By",
Data: metricdata.Sum[int64]{
Temporality: metricdata.CumulativeTemporality,
IsMonotonic: true,
DataPoints: []metricdata.DataPoint[int64]{
{
Attributes: oldAttrs,
},
},
},
},
{
Name: "http.server.duration",
Description: "Measures the duration of inbound HTTP requests.",
Unit: "ms",
Data: metricdata.Histogram[float64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[float64]{
{
Attributes: oldAttrs,
},
},
},
},
}...)
tests := []struct {
name string
serverFunc func(metric.MeterProvider) semconv.HTTPServer
wantFunc func(t *testing.T, rm metricdata.ResourceMetrics)
}{
{
name: "No Meter",
serverFunc: func(metric.MeterProvider) semconv.HTTPServer {
return semconv.NewHTTPServer(nil)
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
assert.Empty(t, rm.ScopeMetrics)
},
},
{
name: "With Meter",
serverFunc: func(mp metric.MeterProvider) semconv.HTTPServer {
return semconv.NewHTTPServer(mp.Meter("test"))
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
require.Len(t, rm.ScopeMetrics, 1)
// because of OldHTTPServer
require.Len(t, rm.ScopeMetrics[0].Metrics, 3)
metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := sdkmetric.NewManualReader()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
server := tt.serverFunc(mp)
req, err := http.NewRequest("POST", "http://example.com", http.NoBody)
assert.NoError(t, err)
server.RecordMetrics(t.Context(), semconv.ServerMetricData{
ServerName: "stuff",
ResponseSize: 200,
MetricAttributes: semconv.MetricAttributes{
Req: req,
StatusCode: 301,
AdditionalAttributes: []attribute.KeyValue{
attribute.String("key", "value"),
},
},
MetricData: semconv.MetricData{
RequestSize: 100,
ElapsedTime: 300,
},
})
rm := metricdata.ResourceMetrics{}
require.NoError(t, reader.Collect(t.Context(), &rm))
tt.wantFunc(t, rm)
})
}
}
func TestNewTraceResponse(t *testing.T) {
testCases := []struct {
name string
resp semconv.ResponseTelemetry
want []attribute.KeyValue
}{
{
name: "empty",
resp: semconv.ResponseTelemetry{},
want: nil,
},
{
name: "no errors",
resp: semconv.ResponseTelemetry{
StatusCode: 200,
ReadBytes: 701,
WriteBytes: 802,
},
want: []attribute.KeyValue{
attribute.Int("http.request.body.size", 701),
attribute.Int("http.response.body.size", 802),
attribute.Int("http.response.status_code", 200),
},
},
{
name: "with errors",
resp: semconv.ResponseTelemetry{
StatusCode: 200,
ReadBytes: 701,
ReadError: fmt.Errorf("read error"),
WriteBytes: 802,
WriteError: fmt.Errorf("write error"),
},
want: []attribute.KeyValue{
attribute.Int("http.request.body.size", 701),
attribute.Int("http.response.body.size", 802),
attribute.Int("http.response.status_code", 200),
},
},
}
for _, tt := range testCases {
t.Run(tt.name, func(t *testing.T) {
got := semconv.HTTPServer{}.ResponseTraceAttrs(tt.resp)
assert.ElementsMatch(t, tt.want, got)
})
}
}
func TestNewTraceRequest_Client(t *testing.T) {
body := strings.NewReader("Hello, world!")
url := "https://example.com:8888/foo/bar?stuff=morestuff"
req := httptest.NewRequest("pOST", url, body)
req.Header.Set("User-Agent", "go-test-agent")
want := []attribute.KeyValue{
attribute.String("http.request.method", "POST"),
attribute.String("http.request.method_original", "pOST"),
attribute.String("url.full", url),
attribute.String("server.address", "example.com"),
attribute.Int("server.port", 8888),
attribute.String("network.protocol.version", "1.1"),
}
client := semconv.NewHTTPClient(nil)
assert.ElementsMatch(t, want, client.RequestTraceAttrs(req))
}
func TestNewTraceResponse_Client(t *testing.T) {
testcases := []struct {
resp http.Response
want []attribute.KeyValue
}{
{resp: http.Response{StatusCode: 200, ContentLength: 123}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 200)}},
{resp: http.Response{StatusCode: 404, ContentLength: 0}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 404), attribute.String("error.type", "404")}},
}
for _, tt := range testcases {
client := semconv.NewHTTPClient(nil)
assert.ElementsMatch(t, tt.want, client.ResponseTraceAttrs(&tt.resp))
}
}
func TestClientRequest(t *testing.T) {
body := strings.NewReader("Hello, world!")
url := "https://example.com:8888/foo/bar?stuff=morestuff"
req := httptest.NewRequest("pOST", url, body)
req.Header.Set("User-Agent", "go-test-agent")
want := []attribute.KeyValue{
attribute.String("http.request.method", "POST"),
attribute.String("http.request.method_original", "pOST"),
attribute.String("url.full", url),
attribute.String("server.address", "example.com"),
attribute.Int("server.port", 8888),
attribute.String("network.protocol.version", "1.1"),
}
got := semconv.HTTPClient{}.RequestTraceAttrs(req)
assert.ElementsMatch(t, want, got)
}
func TestClientResponse(t *testing.T) {
testcases := []struct {
resp http.Response
want []attribute.KeyValue
}{
{resp: http.Response{StatusCode: 200, ContentLength: 123}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 200)}},
{resp: http.Response{StatusCode: 404, ContentLength: 0}, want: []attribute.KeyValue{attribute.Int("http.response.status_code", 404), attribute.String("error.type", "404")}},
}
for _, tt := range testcases {
got := semconv.HTTPClient{}.ResponseTraceAttrs(&tt.resp)
assert.ElementsMatch(t, tt.want, got)
}
}
func TestRequestErrorType(t *testing.T) {
testcases := []struct {
err error
want attribute.KeyValue
}{
{err: errors.New("http: nil Request.URL"), want: attribute.String("error.type", "*errors.errorString")},
{err: customError{}, want: attribute.String("error.type", "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv_test.customError")},
}
for _, tt := range testcases {
got := semconv.HTTPClient{}.ErrorType(tt.err)
assert.Equal(t, tt.want, got)
}
}
func TestNewClientRecordMetrics(t *testing.T) {
currAttrs := attribute.NewSet(
attribute.String("http.request.method", "POST"),
attribute.Int64("http.response.status_code", 301),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.String("server.address", "example.com"),
attribute.String("url.scheme", "http"),
)
// the HTTPClient version
expectedCurrentScopeMetric := metricdata.ScopeMetrics{
Scope: instrumentation.Scope{
Name: "test",
},
Metrics: []metricdata.Metrics{
{
Name: "http.client.request.body.size",
Description: "Size of HTTP client request bodies.",
Unit: "By",
Data: metricdata.Histogram[int64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[int64]{
{
Attributes: currAttrs,
},
},
},
},
{
Name: "http.client.request.duration",
Description: "Duration of HTTP client requests.",
Unit: "s",
Data: metricdata.Histogram[float64]{
Temporality: metricdata.CumulativeTemporality,
DataPoints: []metricdata.HistogramDataPoint[float64]{
{
Attributes: currAttrs,
},
},
},
},
},
}
tests := []struct {
name string
clientFunc func(metric.MeterProvider) semconv.HTTPClient
wantFunc func(t *testing.T, rm metricdata.ResourceMetrics)
}{
{
name: "No environment variable set, and no Meter",
clientFunc: func(metric.MeterProvider) semconv.HTTPClient {
return semconv.NewHTTPClient(nil)
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
assert.Empty(t, rm.ScopeMetrics)
},
},
{
name: "With Meter",
clientFunc: func(mp metric.MeterProvider) semconv.HTTPClient {
return semconv.NewHTTPClient(mp.Meter("test"))
},
wantFunc: func(t *testing.T, rm metricdata.ResourceMetrics) {
require.Len(t, rm.ScopeMetrics, 1)
require.Len(t, rm.ScopeMetrics[0].Metrics, 2)
metricdatatest.AssertEqual(t, expectedCurrentScopeMetric, rm.ScopeMetrics[0], metricdatatest.IgnoreTimestamp(), metricdatatest.IgnoreValue())
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
reader := sdkmetric.NewManualReader()
mp := sdkmetric.NewMeterProvider(sdkmetric.WithReader(reader))
client := tt.clientFunc(mp)
req, err := http.NewRequest("POST", "http://example.com", http.NoBody)
assert.NoError(t, err)
client.RecordMetrics(t.Context(), semconv.MetricData{
RequestSize: 100,
ElapsedTime: 300,
}, client.MetricOptions(semconv.MetricAttributes{
Req: req,
StatusCode: 301,
}))
rm := metricdata.ResourceMetrics{}
require.NoError(t, reader.Collect(t.Context(), &rm))
tt.wantFunc(t, rm)
})
}
}
type customError struct{}
func (customError) Error() string {
return "custom error"
}

View File

@@ -0,0 +1,405 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/server.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
// Package semconv provides OpenTelemetry semantic convention types and
// functionality.
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
import (
"context"
"fmt"
"net/http"
"slices"
"strings"
"sync"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/semconv/v1.39.0/httpconv"
)
type RequestTraceAttrsOpts struct {
// If set, this is used as value for the "http.client_ip" attribute.
HTTPClientIP string
}
type ResponseTelemetry struct {
StatusCode int
ReadBytes int64
ReadError error
WriteBytes int64
WriteError error
}
type HTTPServer struct {
requestBodySizeHistogram httpconv.ServerRequestBodySize
responseBodySizeHistogram httpconv.ServerResponseBodySize
requestDurationHistogram httpconv.ServerRequestDuration
}
func NewHTTPServer(meter metric.Meter) HTTPServer {
server := HTTPServer{}
var err error
server.requestBodySizeHistogram, err = httpconv.NewServerRequestBodySize(meter)
handleErr(err)
server.responseBodySizeHistogram, err = httpconv.NewServerResponseBodySize(meter)
handleErr(err)
server.requestDurationHistogram, err = httpconv.NewServerRequestDuration(
meter,
metric.WithExplicitBucketBoundaries(
0.005, 0.01, 0.025, 0.05, 0.075, 0.1,
0.25, 0.5, 0.75, 1, 2.5, 5, 7.5, 10,
),
)
handleErr(err)
return server
}
// Status returns a span status code and message for an HTTP status code
// value returned by a server.
func (n HTTPServer) Status(code int) (codes.Code, string) {
if code == 0 {
return codes.Error, "No HTTP status code"
}
if code < 100 || code >= 600 {
return codes.Error, fmt.Sprintf("Invalid HTTP status code %d", code)
}
if code >= 400 {
return codes.Error, http.StatusText(code)
}
return codes.Ok, ""
}
// RequestTraceAttrs returns trace attributes for an HTTP request received by a
// server.
//
// The server must be the primary server name if it is known. For example this
// would be the ServerName directive
// (https://httpd.apache.org/docs/2.4/mod/core.html#servername) for an Apache
// server, and the server_name directive
// (http://nginx.org/en/docs/http/ngx_http_core_module.html#server_name) for an
// nginx server. More generically, the primary server name would be the host
// header value that matches the default virtual host of an HTTP server. It
// should include the host identifier and if a port is used to route to the
// server that port identifier should be included as an appropriate port
// suffix.
//
// If the primary server name is not known, server should be an empty string.
// The req Host will be used to determine the server instead.
func (n HTTPServer) RequestTraceAttrs(server string, req *http.Request, opts RequestTraceAttrsOpts) []attribute.KeyValue {
count := 3 // ServerAddress, Method, Scheme
var host string
var p int
if server == "" {
host, p = SplitHostPort(req.Host)
} else {
// Prioritize the primary server name.
host, p = SplitHostPort(server)
if p < 0 {
_, p = SplitHostPort(req.Host)
}
}
hostPort := requiredHTTPPort(req.TLS != nil, p)
if hostPort > 0 {
count++
}
method, methodOriginal := n.method(req.Method)
if methodOriginal != (attribute.KeyValue{}) {
count++
}
scheme := n.scheme(req.TLS != nil)
peer, peerPort := SplitHostPort(req.RemoteAddr)
if peer != "" {
// The Go HTTP server sets RemoteAddr to "IP:port", this will not be a
// file-path that would be interpreted with a sock family.
count++
if peerPort > 0 {
count++
}
}
useragent := req.UserAgent()
if useragent != "" {
count++
}
// For client IP, use, in order:
// 1. The value passed in the options
// 2. The value in the X-Forwarded-For header
// 3. The peer address
clientIP := opts.HTTPClientIP
if clientIP == "" {
clientIP = serverClientIP(req.Header.Get("X-Forwarded-For"))
if clientIP == "" {
clientIP = peer
}
}
if clientIP != "" {
count++
}
if req.URL != nil && req.URL.Path != "" {
count++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" && protoName != "http" {
count++
}
if protoVersion != "" {
count++
}
route := httpRoute(req.Pattern)
if route != "" {
count++
}
attrs := make([]attribute.KeyValue, 0, count)
attrs = append(attrs,
semconv.ServerAddress(host),
method,
scheme,
)
if hostPort > 0 {
attrs = append(attrs, semconv.ServerPort(hostPort))
}
if methodOriginal != (attribute.KeyValue{}) {
attrs = append(attrs, methodOriginal)
}
if peer, peerPort := SplitHostPort(req.RemoteAddr); peer != "" {
// The Go HTTP server sets RemoteAddr to "IP:port", this will not be a
// file-path that would be interpreted with a sock family.
attrs = append(attrs, semconv.NetworkPeerAddress(peer))
if peerPort > 0 {
attrs = append(attrs, semconv.NetworkPeerPort(peerPort))
}
}
if useragent != "" {
attrs = append(attrs, semconv.UserAgentOriginal(useragent))
}
if clientIP != "" {
attrs = append(attrs, semconv.ClientAddress(clientIP))
}
if req.URL != nil && req.URL.Path != "" {
attrs = append(attrs, semconv.URLPath(req.URL.Path))
}
if protoName != "" && protoName != "http" {
attrs = append(attrs, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attrs = append(attrs, semconv.NetworkProtocolVersion(protoVersion))
}
if route != "" {
attrs = append(attrs, n.Route(route))
}
return attrs
}
func (s HTTPServer) NetworkTransportAttr(network string) []attribute.KeyValue {
attr := semconv.NetworkTransportPipe
switch network {
case "tcp", "tcp4", "tcp6":
attr = semconv.NetworkTransportTCP
case "udp", "udp4", "udp6":
attr = semconv.NetworkTransportUDP
case "unix", "unixgram", "unixpacket":
attr = semconv.NetworkTransportUnix
}
return []attribute.KeyValue{attr}
}
type ServerMetricData struct {
ServerName string
ResponseSize int64
MetricData
MetricAttributes
}
type MetricAttributes struct {
Req *http.Request
StatusCode int
Route string
AdditionalAttributes []attribute.KeyValue
}
type MetricData struct {
RequestSize int64
// The request duration, in milliseconds
ElapsedTime float64
}
var (
metricAddOptionPool = &sync.Pool{
New: func() any {
return &[]metric.AddOption{}
},
}
metricRecordOptionPool = &sync.Pool{
New: func() any {
return &[]metric.RecordOption{}
},
}
)
func (n HTTPServer) RecordMetrics(ctx context.Context, md ServerMetricData) {
attributes := n.MetricAttributes(md.ServerName, md.Req, md.StatusCode, md.Route, md.AdditionalAttributes)
o := metric.WithAttributeSet(attribute.NewSet(attributes...))
recordOpts := metricRecordOptionPool.Get().(*[]metric.RecordOption)
*recordOpts = append(*recordOpts, o)
n.requestBodySizeHistogram.Inst().Record(ctx, md.RequestSize, *recordOpts...)
n.responseBodySizeHistogram.Inst().Record(ctx, md.ResponseSize, *recordOpts...)
n.requestDurationHistogram.Inst().Record(ctx, md.ElapsedTime/1000.0, o)
*recordOpts = (*recordOpts)[:0]
metricRecordOptionPool.Put(recordOpts)
}
func (n HTTPServer) method(method string) (attribute.KeyValue, attribute.KeyValue) {
if method == "" {
return semconv.HTTPRequestMethodGet, attribute.KeyValue{}
}
if attr, ok := methodLookup[method]; ok {
return attr, attribute.KeyValue{}
}
orig := semconv.HTTPRequestMethodOriginal(method)
if attr, ok := methodLookup[strings.ToUpper(method)]; ok {
return attr, orig
}
return semconv.HTTPRequestMethodGet, orig
}
func (n HTTPServer) scheme(https bool) attribute.KeyValue { //nolint:revive // ignore linter
if https {
return semconv.URLScheme("https")
}
return semconv.URLScheme("http")
}
// ResponseTraceAttrs returns trace attributes for telemetry from an HTTP
// response.
//
// If any of the fields in the ResponseTelemetry are not set the attribute will
// be omitted.
func (n HTTPServer) ResponseTraceAttrs(resp ResponseTelemetry) []attribute.KeyValue {
var count int
if resp.ReadBytes > 0 {
count++
}
if resp.WriteBytes > 0 {
count++
}
if resp.StatusCode > 0 {
count++
}
attributes := make([]attribute.KeyValue, 0, count)
if resp.ReadBytes > 0 {
attributes = append(attributes,
semconv.HTTPRequestBodySize(int(resp.ReadBytes)),
)
}
if resp.WriteBytes > 0 {
attributes = append(attributes,
semconv.HTTPResponseBodySize(int(resp.WriteBytes)),
)
}
if resp.StatusCode > 0 {
attributes = append(attributes,
semconv.HTTPResponseStatusCode(resp.StatusCode),
)
}
return attributes
}
// Route returns the attribute for the route.
func (n HTTPServer) Route(route string) attribute.KeyValue {
return semconv.HTTPRoute(route)
}
func (n HTTPServer) MetricAttributes(server string, req *http.Request, statusCode int, route string, additionalAttributes []attribute.KeyValue) []attribute.KeyValue {
num := len(additionalAttributes) + 3
var host string
var p int
if server == "" {
host, p = SplitHostPort(req.Host)
} else {
// Prioritize the primary server name.
host, p = SplitHostPort(server)
if p < 0 {
_, p = SplitHostPort(req.Host)
}
}
hostPort := requiredHTTPPort(req.TLS != nil, p)
if hostPort > 0 {
num++
}
protoName, protoVersion := netProtocol(req.Proto)
if protoName != "" {
num++
}
if protoVersion != "" {
num++
}
if statusCode > 0 {
num++
}
if route != "" {
num++
}
attributes := slices.Grow(additionalAttributes, num)
attributes = append(attributes,
semconv.HTTPRequestMethodKey.String(standardizeHTTPMethod(req.Method)),
n.scheme(req.TLS != nil),
semconv.ServerAddress(host))
if hostPort > 0 {
attributes = append(attributes, semconv.ServerPort(hostPort))
}
if protoName != "" {
attributes = append(attributes, semconv.NetworkProtocolName(protoName))
}
if protoVersion != "" {
attributes = append(attributes, semconv.NetworkProtocolVersion(protoVersion))
}
if statusCode > 0 {
attributes = append(attributes, semconv.HTTPResponseStatusCode(statusCode))
}
if route != "" {
attributes = append(attributes, semconv.HTTPRoute(route))
}
return attributes
}

View File

@@ -0,0 +1,210 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/server_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/attribute"
)
func TestHTTPServer_MetricAttributes(t *testing.T) {
defaultRequest, err := http.NewRequest("GET", "http://example.com/path?query=test", http.NoBody)
require.NoError(t, err)
tests := []struct {
name string
server string
req *http.Request
statusCode int
route string
additionalAttributes []attribute.KeyValue
wantFunc func(t *testing.T, attrs []attribute.KeyValue)
}{
{
name: "routine testing",
server: "",
req: defaultRequest,
statusCode: 200,
route: "",
additionalAttributes: []attribute.KeyValue{attribute.String("test", "test")},
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 7)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("url.scheme", "http"),
attribute.String("server.address", "example.com"),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
attribute.String("test", "test"),
}, attrs)
},
},
{
name: "use server address",
server: "example.com:9999",
req: defaultRequest,
statusCode: 200,
route: "/path/${id}",
additionalAttributes: nil,
wantFunc: func(t *testing.T, attrs []attribute.KeyValue) {
require.Len(t, attrs, 8)
assert.ElementsMatch(t, []attribute.KeyValue{
attribute.String("http.request.method", "GET"),
attribute.String("url.scheme", "http"),
attribute.String("server.address", "example.com"),
attribute.Int("server.port", 9999),
attribute.String("network.protocol.name", "http"),
attribute.String("network.protocol.version", "1.1"),
attribute.Int64("http.response.status_code", 200),
attribute.String("http.route", "/path/${id}"),
}, attrs)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := HTTPServer{}.MetricAttributes(tt.server, tt.req, tt.statusCode, tt.route, tt.additionalAttributes)
tt.wantFunc(t, got)
})
}
}
func TestNewMethod(t *testing.T) {
testCases := []struct {
method string
n int
want attribute.KeyValue
wantOrig attribute.KeyValue
}{
{
method: http.MethodPost,
n: 1,
want: attribute.String("http.request.method", "POST"),
},
{
method: "Put",
n: 2,
want: attribute.String("http.request.method", "PUT"),
wantOrig: attribute.String("http.request.method_original", "Put"),
},
{
method: "Unknown",
n: 2,
want: attribute.String("http.request.method", "GET"),
wantOrig: attribute.String("http.request.method_original", "Unknown"),
},
}
for _, tt := range testCases {
t.Run(tt.method, func(t *testing.T) {
got, gotOrig := HTTPServer{}.method(tt.method)
assert.Equal(t, tt.want, got)
assert.Equal(t, tt.wantOrig, gotOrig)
})
}
}
func TestRequestTraceAttrs_HTTPRoute(t *testing.T) {
tests := []struct {
name string
pattern string
wantRoute string
}{
{
name: "only path",
pattern: "/path/{id}",
wantRoute: "/path/{id}",
},
{
name: "with method",
pattern: "GET /path/{id}",
wantRoute: "/path/{id}",
},
{
name: "with domain",
pattern: "example.com/path/{id}",
wantRoute: "/path/{id}",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/path/abc123", http.NoBody)
req.Pattern = tt.pattern
attrs := (HTTPServer{}).RequestTraceAttrs("", req, RequestTraceAttrsOpts{})
var gotRoute string
for _, attr := range attrs {
if attr.Key == "http.route" {
gotRoute = attr.Value.AsString()
break
}
}
require.Equal(t, tt.wantRoute, gotRoute)
})
}
}
func TestRequestTraceAttrs_ClientIP(t *testing.T) {
for _, tt := range []struct {
name string
requestModifierFn func(r *http.Request)
requestTraceOpts RequestTraceAttrsOpts
wantClientIP string
}{
{
name: "with a client IP from the network",
wantClientIP: "1.2.3.4",
},
{
name: "with a client IP from x-forwarded-for header",
requestModifierFn: func(r *http.Request) {
r.Header.Add("X-Forwarded-For", "5.6.7.8")
},
wantClientIP: "5.6.7.8",
},
{
name: "with a client IP in options",
requestModifierFn: func(r *http.Request) {
r.Header.Add("X-Forwarded-For", "5.6.7.8")
},
requestTraceOpts: RequestTraceAttrsOpts{
HTTPClientIP: "9.8.7.6",
},
wantClientIP: "9.8.7.6",
},
} {
t.Run(tt.name, func(t *testing.T) {
req := httptest.NewRequest(http.MethodGet, "/example", http.NoBody)
req.RemoteAddr = "1.2.3.4:5678"
if tt.requestModifierFn != nil {
tt.requestModifierFn(req)
}
var found bool
for _, attr := range (HTTPServer{}).RequestTraceAttrs("", req, tt.requestTraceOpts) {
if attr.Key != "client.address" {
continue
}
found = true
assert.Equal(t, tt.wantClientIP, attr.Value.AsString())
}
require.True(t, found)
})
}
}

View File

@@ -0,0 +1,127 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/util.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv // import "go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp/internal/semconv"
import (
"net"
"net/http"
"strconv"
"strings"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
semconvNew "go.opentelemetry.io/otel/semconv/v1.39.0"
)
// SplitHostPort splits a network address hostport of the form "host",
// "host%zone", "[host]", "[host%zone], "host:port", "host%zone:port",
// "[host]:port", "[host%zone]:port", or ":port" into host or host%zone and
// port.
//
// An empty host is returned if it is not provided or unparsable. A negative
// port is returned if it is not provided or unparsable.
func SplitHostPort(hostport string) (host string, port int) {
port = -1
if strings.HasPrefix(hostport, "[") {
addrEnd := strings.LastIndexByte(hostport, ']')
if addrEnd < 0 {
// Invalid hostport.
return
}
if i := strings.LastIndexByte(hostport[addrEnd:], ':'); i < 0 {
host = hostport[1:addrEnd]
return
}
} else {
if i := strings.LastIndexByte(hostport, ':'); i < 0 {
host = hostport
return
}
}
host, pStr, err := net.SplitHostPort(hostport)
if err != nil {
return
}
p, err := strconv.ParseUint(pStr, 10, 16)
if err != nil {
return
}
return host, int(p) //nolint:gosec // Byte size checked 16 above.
}
func requiredHTTPPort(https bool, port int) int { //nolint:revive // ignore linter
if https {
if port > 0 && port != 443 {
return port
}
} else {
if port > 0 && port != 80 {
return port
}
}
return -1
}
func serverClientIP(xForwardedFor string) string {
if idx := strings.IndexByte(xForwardedFor, ','); idx >= 0 {
xForwardedFor = xForwardedFor[:idx]
}
return xForwardedFor
}
func httpRoute(pattern string) string {
if idx := strings.IndexByte(pattern, '/'); idx >= 0 {
return pattern[idx:]
}
return ""
}
func netProtocol(proto string) (name string, version string) {
name, version, _ = strings.Cut(proto, "/")
switch name {
case "HTTP":
name = "http"
case "QUIC":
name = "quic"
case "SPDY":
name = "spdy"
default:
name = strings.ToLower(name)
}
return name, version
}
var methodLookup = map[string]attribute.KeyValue{
http.MethodConnect: semconvNew.HTTPRequestMethodConnect,
http.MethodDelete: semconvNew.HTTPRequestMethodDelete,
http.MethodGet: semconvNew.HTTPRequestMethodGet,
http.MethodHead: semconvNew.HTTPRequestMethodHead,
http.MethodOptions: semconvNew.HTTPRequestMethodOptions,
http.MethodPatch: semconvNew.HTTPRequestMethodPatch,
http.MethodPost: semconvNew.HTTPRequestMethodPost,
http.MethodPut: semconvNew.HTTPRequestMethodPut,
http.MethodTrace: semconvNew.HTTPRequestMethodTrace,
}
func handleErr(err error) {
if err != nil {
otel.Handle(err)
}
}
func standardizeHTTPMethod(method string) string {
method = strings.ToUpper(method)
switch method {
case http.MethodConnect, http.MethodDelete, http.MethodGet, http.MethodHead, http.MethodOptions, http.MethodPatch, http.MethodPost, http.MethodPut, http.MethodTrace:
default:
method = "_OTHER"
}
return method
}

View File

@@ -0,0 +1,75 @@
// Code generated by gotmpl. DO NOT MODIFY.
// source: internal/shared/semconv/util_test.go.tmpl
// Copyright The OpenTelemetry Authors
// SPDX-License-Identifier: Apache-2.0
package semconv
import (
"testing"
"github.com/stretchr/testify/assert"
)
func TestSplitHostPort(t *testing.T) {
tests := []struct {
hostport string
host string
port int
}{
{"", "", -1},
{":8080", "", 8080},
{"127.0.0.1", "127.0.0.1", -1},
{"www.example.com", "www.example.com", -1},
{"127.0.0.1%25en0", "127.0.0.1%25en0", -1},
{"[]", "", -1}, // Ensure this doesn't panic.
{"[fe80::1", "", -1},
{"[fe80::1]", "fe80::1", -1},
{"[fe80::1%25en0]", "fe80::1%25en0", -1},
{"[fe80::1]:8080", "fe80::1", 8080},
{"[fe80::1]::", "", -1}, // Too many colons.
{"127.0.0.1:", "127.0.0.1", -1},
{"127.0.0.1:port", "127.0.0.1", -1},
{"127.0.0.1:8080", "127.0.0.1", 8080},
{"www.example.com:8080", "www.example.com", 8080},
{"127.0.0.1%25en0:8080", "127.0.0.1%25en0", 8080},
}
for _, test := range tests {
h, p := SplitHostPort(test.hostport)
assert.Equal(t, test.host, h, test.hostport)
assert.Equal(t, test.port, p, test.hostport)
}
}
func TestStandardizeHTTPMethod(t *testing.T) {
tests := []struct {
method string
want string
}{
{"GET", "GET"},
{"get", "GET"},
{"POST", "POST"},
{"post", "POST"},
{"PUT", "PUT"},
{"put", "PUT"},
{"DELETE", "DELETE"},
{"delete", "DELETE"},
{"HEAD", "HEAD"},
{"head", "HEAD"},
{"OPTIONS", "OPTIONS"},
{"options", "OPTIONS"},
{"CONNECT", "CONNECT"},
{"connect", "CONNECT"},
{"TRACE", "TRACE"},
{"trace", "TRACE"},
{"PATCH", "PATCH"},
{"patch", "PATCH"},
{"unknown", "_OTHER"},
{"", "_OTHER"},
}
for _, test := range tests {
assert.Equal(t, test.want, standardizeHTTPMethod(test.method))
}
}

View File

@@ -4,10 +4,9 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"sync/atomic" "sync/atomic"
"go.uber.org/zap"
) )
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying // ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
@@ -27,15 +26,15 @@ var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
type Process interface { type Process interface {
// Start initiates the process and returns an error if the process cannot // Start initiates the process and returns an error if the process cannot
// be started. // be started.
Start(logger *zap.Logger) error Start(logger *slog.Logger) error
// Stop terminates the process and returns an error if the process cannot // Stop terminates the process and returns an error if the process cannot
// be stopped. // be stopped.
Stop(logger *zap.Logger) error Stop(logger *slog.Logger) error
// Healthy checks the health of the process. It returns true if the process // Healthy checks the health of the process. It returns true if the process
// is healthy; otherwise, it returns false. // is healthy; otherwise, it returns false.
Healthy(logger *zap.Logger) bool Healthy(logger *slog.Logger) bool
} }
// ProcessSupervisor provides methods to manage a [Process], including // ProcessSupervisor provides methods to manage a [Process], including
@@ -66,17 +65,20 @@ type ProcessSupervisor interface {
// //
// It returns an error if the task cannot be run or if the process state // It returns an error if the task cannot be run or if the process state
// cannot be managed properly. // cannot be managed properly.
Run(ctx context.Context, logger *zap.Logger, task func() error) error Run(ctx context.Context, logger *slog.Logger, task func() error) error
// ReqQueueSize returns the current size of the request queue. // ReqQueueSize returns the current size of the request queue.
ReqQueueSize() int64 ReqQueueSize() int64
// ActiveTasksCount returns the current number of active tasks.
ActiveTasksCount() int64
// RestartsCount returns the current number of restart. // RestartsCount returns the current number of restart.
RestartsCount() int64 RestartsCount() int64
} }
type processSupervisor struct { type processSupervisor struct {
logger *zap.Logger logger *slog.Logger
process Process process Process
maxReqLimit int64 maxReqLimit int64
maxQueueSize int64 maxQueueSize int64
@@ -94,7 +96,7 @@ type processSupervisor struct {
} }
// NewProcessSupervisor initializes a new [ProcessSupervisor]. // NewProcessSupervisor initializes a new [ProcessSupervisor].
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor { func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
if maxConcurrency < 1 { if maxConcurrency < 1 {
maxConcurrency = 1 maxConcurrency = 1
} }
@@ -117,37 +119,37 @@ func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQ
} }
func (s *processSupervisor) Launch() error { func (s *processSupervisor) Launch() error {
s.logger.Debug("start process") s.logger.DebugContext(context.Background(), "start process")
err := s.process.Start(s.logger) err := s.process.Start(s.logger)
if err != nil { if err != nil {
return fmt.Errorf("start process: %w", err) return fmt.Errorf("start process: %w", err)
} }
s.firstStart.Store(true) s.firstStart.Store(true)
s.logger.Debug("process successfully started") s.logger.DebugContext(context.Background(), "process successfully started")
return nil return nil
} }
func (s *processSupervisor) Shutdown() error { func (s *processSupervisor) Shutdown() error {
s.logger.Debug("shutdown process") s.logger.DebugContext(context.Background(), "shutdown process")
err := s.process.Stop(s.logger) err := s.process.Stop(s.logger)
if err != nil { if err != nil {
return fmt.Errorf("shutdown process: %w", err) return fmt.Errorf("shutdown process: %w", err)
} }
s.logger.Debug("process successfully shutdown") s.logger.DebugContext(context.Background(), "process successfully shutdown")
return nil return nil
} }
func (s *processSupervisor) restart() error { func (s *processSupervisor) restart() error {
s.logger.Debug("restart process") s.logger.DebugContext(context.Background(), "restart process")
err := s.Shutdown() err := s.Shutdown()
if err != nil { if err != nil {
// No big deal? Chances are it's already stopped. // No big deal? Chances are it's already stopped.
s.logger.Debug(fmt.Sprintf("stop process before restart: %s", err)) s.logger.DebugContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
} }
err = s.Launch() err = s.Launch()
@@ -157,7 +159,7 @@ func (s *processSupervisor) restart() error {
s.reqCounter.Store(0) s.reqCounter.Store(0)
s.restartsCounter.Add(1) s.restartsCounter.Add(1)
s.logger.Debug("process successfully restarted") s.logger.DebugContext(context.Background(), "process successfully restarted")
return nil return nil
} }
@@ -176,7 +178,7 @@ func (s *processSupervisor) Healthy() bool {
return s.process.Healthy(s.logger) return s.process.Healthy(s.logger)
} }
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error { func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
// A user reported a potential issue: // A user reported a potential issue:
// //
// "Although the counting operation is atomic, nothing prevent 2 concurrent // "Although the counting operation is atomic, nothing prevent 2 concurrent
@@ -202,7 +204,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
err := func() error { err := func() error {
select { select {
case s.semaphore <- struct{}{}: case s.semaphore <- struct{}{}:
logger.Debug("process lock acquired") logger.DebugContext(ctx, "process lock acquired")
// If a restart drain is in progress, release the slot // If a restart drain is in progress, release the slot
// immediately so the drain can acquire it instead. // immediately so the drain can acquire it instead.
@@ -219,7 +221,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
defer func() { defer func() {
s.activeTasks.Add(-1) s.activeTasks.Add(-1)
if releaseSemaphore { if releaseSemaphore {
logger.Debug("process lock released") logger.DebugContext(ctx, "process lock released")
<-s.semaphore <-s.semaphore
} }
}() }()
@@ -236,7 +238,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
} }
if !s.Healthy() { if !s.Healthy() {
s.logger.Debug("process is unhealthy, cannot handle task, restarting...") s.logger.DebugContext(ctx, "process is unhealthy, cannot handle task, restarting...")
err := s.doRestart(ctx) err := s.doRestart(ctx)
if err != nil { if err != nil {
return fmt.Errorf("process restart before task: %w", err) return fmt.Errorf("process restart before task: %w", err)
@@ -248,16 +250,16 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit { if s.maxReqLimit > 0 && s.reqCounter.Load() >= s.maxReqLimit {
// Only one goroutine should trigger the restart. // Only one goroutine should trigger the restart.
if s.restartMutex.TryLock() { if s.restartMutex.TryLock() {
s.logger.Debug("max request limit reached, restarting eagerly...") s.logger.DebugContext(ctx, "max request limit reached, restarting eagerly...")
releaseSemaphore = false releaseSemaphore = false
go func() { go func() {
restartErr := s.doRestartLocked(context.Background()) restartErr := s.doRestartLocked(context.Background())
s.restartMutex.Unlock() s.restartMutex.Unlock()
if restartErr != nil { if restartErr != nil {
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr)) s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
} }
logger.Debug("process lock released") logger.DebugContext(context.Background(), "process lock released")
<-s.semaphore <-s.semaphore
}() }()
} }
@@ -266,7 +268,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
// Note: no error wrapping because it leaks on Chromium console exceptions output. // Note: no error wrapping because it leaks on Chromium console exceptions output.
return err return err
case <-ctx.Done(): case <-ctx.Done():
logger.Debug("failed to acquire process lock before deadline") logger.DebugContext(ctx, "failed to acquire process lock before deadline")
s.reqQueueSize.Add(-1) s.reqQueueSize.Add(-1)
return fmt.Errorf("acquire process lock: %w", ctx.Err()) return fmt.Errorf("acquire process lock: %w", ctx.Err())
@@ -274,7 +276,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
}() }()
if errors.Is(err, ErrProcessAlreadyRestarting) { if errors.Is(err, ErrProcessAlreadyRestarting) {
logger.Debug("process is already restarting, trying to acquire process lock again...") logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
continue continue
} }
@@ -344,6 +346,10 @@ func (s *processSupervisor) ReqQueueSize() int64 {
return s.reqQueueSize.Load() return s.reqQueueSize.Load()
} }
func (s *processSupervisor) ActiveTasksCount() int64 {
return s.activeTasks.Load()
}
func (s *processSupervisor) RestartsCount() int64 { func (s *processSupervisor) RestartsCount() int64 {
return s.restartsCounter.Load() return s.restartsCounter.Load()
} }

View File

@@ -3,12 +3,12 @@ package gotenberg
import ( import (
"context" "context"
"errors" "errors"
"log/slog"
"os"
"sync" "sync"
"sync/atomic" "sync/atomic"
"testing" "testing"
"time" "time"
"go.uber.org/zap"
) )
func TestProcessSupervisor_Launch(t *testing.T) { func TestProcessSupervisor_Launch(t *testing.T) {
@@ -38,10 +38,10 @@ func TestProcessSupervisor_Launch(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return tc.startError return tc.startError
}, },
} }
@@ -86,10 +86,10 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return tc.stopError return tc.stopError
}, },
} }
@@ -135,13 +135,13 @@ func TestProcessSupervisor_restart(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return tc.startError return tc.startError
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return tc.stopError return tc.stopError
}, },
} }
@@ -194,10 +194,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return tc.processHealthy return tc.processHealthy
}, },
} }
@@ -365,7 +365,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
var startCalls, healthyCalls, stopCalls atomic.Int64 var startCalls, healthyCalls, stopCalls atomic.Int64
startCalls.Store(0) startCalls.Store(0)
@@ -373,15 +373,15 @@ func TestProcessSupervisor_Run(t *testing.T) {
stopCalls.Store(0) stopCalls.Store(0)
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
startCalls.Add(1) startCalls.Add(1)
return tc.startError return tc.startError
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
stopCalls.Add(1) stopCalls.Add(1)
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
healthyCalls.Add(1) healthyCalls.Add(1)
return tc.processHealthy return tc.processHealthy
}, },
@@ -471,7 +471,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor) ps := NewProcessSupervisor(slog.New(slog.NewJSONHandler(os.Stdout, nil)), new(ProcessMock), 0, 0, 1).(*processSupervisor)
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second) ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
if tc.ctxDone { if tc.ctxDone {
@@ -496,12 +496,12 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
} }
func TestProcessSupervisor_ReqQueueSize(t *testing.T) { func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }
@@ -593,13 +593,13 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
}, },
} { } {
t.Run(tc.scenario, func(t *testing.T) { t.Run(tc.scenario, func(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return tc.startError return tc.startError
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return tc.stopError return tc.stopError
}, },
} }
@@ -620,18 +620,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
} }
func TestProcessSupervisor_ConcurrentRun(t *testing.T) { func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
var startCalls atomic.Int64 var startCalls atomic.Int64
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
startCalls.Add(1) startCalls.Add(1)
return nil return nil
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }
@@ -684,16 +684,16 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
} }
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) { func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
logger := zap.NewNop() logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
process := &ProcessMock{ process := &ProcessMock{
StartMock: func(logger *zap.Logger) error { StartMock: func(logger *slog.Logger) error {
return nil return nil
}, },
StopMock: func(logger *zap.Logger) error { StopMock: func(logger *slog.Logger) error {
return nil return nil
}, },
HealthyMock: func(logger *zap.Logger) bool { HealthyMock: func(logger *slog.Logger) bool {
return true return true
}, },
} }

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

@@ -0,0 +1,238 @@
package gotenberg
import (
"context"
"errors"
"fmt"
"log/slog"
"sync"
"github.com/hashicorp/go-retryablehttp"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/metric"
"go.opentelemetry.io/otel/trace"
"go.uber.org/multierr"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/log"
internalotel "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/internal/otel"
)
const (
AutoLoggingFormat = "auto"
JsonLoggingFormat = "json"
TextLoggingFormat = "text"
)
const (
ErrorLoggingLevel = "error"
WarnLoggingLevel = "warn"
InfoLoggingLevel = "info"
DebugLoggingLevel = "debug"
)
// TelemetryConfig gathers the configuration data for Gotenberg's telemetry.
type TelemetryConfig struct {
ServiceName string
ServiceVersion string
LogLevel string
LogFieldsPrefix string
LogStdFormat string
LogStdEnableGcpFields bool
}
func (cfg TelemetryConfig) slogLevel() slog.Level {
var level slog.Level
err := level.UnmarshalText([]byte(cfg.LogLevel))
if err != nil {
return slog.LevelInfo
}
return level
}
// Validate validates the telemetry configuration.
func (cfg TelemetryConfig) Validate() error {
var err error
if cfg.ServiceName == "" {
err = multierr.Append(err,
errors.New("service name must not be empty"),
)
}
if cfg.ServiceVersion == "" {
err = multierr.Append(err,
errors.New("service version must not be empty"),
)
}
switch cfg.LogLevel {
case ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel:
break
default:
err = multierr.Append(
err,
fmt.Errorf("log level must be either %s, %s, %s or %s", ErrorLoggingLevel, WarnLoggingLevel, InfoLoggingLevel, DebugLoggingLevel),
)
}
switch cfg.LogStdFormat {
case AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat:
break
default:
err = multierr.Append(
err,
fmt.Errorf("standard log format must be either %s, %s or %s", AutoLoggingFormat, JsonLoggingFormat, TextLoggingFormat),
)
}
return err
}
// StartTelemetry starts the telemetry utilities.
func StartTelemetry(cfg TelemetryConfig) (shutdown func(context.Context) error, err error) {
var handlers []slog.Handler
stdHandler, err := log.NewStdHandler(cfg.slogLevel(), cfg.LogStdFormat, cfg.LogFieldsPrefix, cfg.LogStdEnableGcpFields)
if err != nil {
return nil, fmt.Errorf("get standard logger handler: %w", err)
}
handlers = append(handlers, stdHandler)
// We need a logger for the other providers.
// We'll use the stdHandler for now.
bootstrapLogger := slog.New(stdHandler)
// OpenTelemetry.
var shutdowns []func(context.Context) error
shutdownFn, err := internalotel.InitTracerProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion)
if err != nil {
return nil, fmt.Errorf("initialize OpenTelemetry tracer provider: %w", err)
}
shutdowns = append(shutdowns, shutdownFn)
shutdownFn, err = internalotel.InitMeterProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion)
if err != nil {
return nil, fmt.Errorf("initialize OpenTelemetry meter provider: %w", err)
}
shutdowns = append(shutdowns, shutdownFn)
shutdownFn, otelHandler, err := internalotel.InitLoggerProvider(bootstrapLogger, cfg.ServiceName, cfg.ServiceVersion)
if err != nil {
return nil, fmt.Errorf("initialize OpenTelemetry logger provider: %w", err)
}
handlers = append(handlers, log.LevelFilter(otelHandler, cfg.slogLevel()))
shutdowns = append(shutdowns, shutdownFn)
// Global logger.
log.InitLogger(log.NewGotenbergHandler(log.FanOut(handlers...), cfg.LogFieldsPrefix))
return func(ctx context.Context) error {
filterErr := func(err error) error {
if errors.Is(err, context.Canceled) {
return nil
}
return err
}
var wg sync.WaitGroup
var errs error
var mu sync.Mutex
for _, fn := range shutdowns {
wg.Add(1)
go func(shutdownFn func(context.Context) error) {
defer wg.Done()
shutdownErr := shutdownFn(ctx)
if filterErr(shutdownErr) != nil {
mu.Lock()
errs = errors.Join(errs, shutdownErr)
mu.Unlock()
}
}(fn)
}
wg.Wait()
return errs
}, nil
}
// Logger returns the global logger.
func Logger(mod Module) *slog.Logger {
return log.Logger().With(slog.String("logger", mod.Descriptor().ID))
}
const (
// instrumentationName is the name of the OpenTelemetry instrumentation
// library.
instrumentationName = "github.com/gotenberg/gotenberg"
)
// Tracer returns a [trace.Tracer] with the instrumentation name and version
// already set.
func Tracer() trace.Tracer {
return otel.GetTracerProvider().Tracer(
instrumentationName,
trace.WithInstrumentationVersion(Version),
)
}
// Meter returns a [metric.Meter] with the instrumentation name and version
// already set.
func Meter() metric.Meter {
return otel.GetMeterProvider().Meter(
instrumentationName,
metric.WithInstrumentationVersion(Version),
)
}
// LeveledLogger is a wrapper around a [slog.Logger] so that it may be used by a
// [retryablehttp.Client].
type LeveledLogger struct {
logger *slog.Logger
ctx context.Context
}
// NewLeveledLogger instantiates a [LeveledLogger].
func NewLeveledLogger(logger *slog.Logger) *LeveledLogger {
return &LeveledLogger{
logger: logger,
ctx: context.Background(),
}
}
// WithContext returns a new [LeveledLogger] with the given context.
func (leveled LeveledLogger) WithContext(ctx context.Context) *LeveledLogger {
return &LeveledLogger{
logger: leveled.logger,
ctx: ctx,
}
}
// Error logs a message at the error level using the wrapped slog.Logger.
func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
leveled.logger.ErrorContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Warn logs a message at the warning level using the wrapped slog.Logger.
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
leveled.logger.WarnContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Info logs a message at the info level using the wrapped slog.Logger.
func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
leveled.logger.InfoContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Debug logs a message at the debug level using the wrapped slog.Logger.
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
leveled.logger.DebugContext(leveled.ctx, fmt.Sprintf("%s: %+v", msg, keysAndValues))
}
// Interface guards.
var (
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
)

View File

@@ -4,8 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net" "net"
"net/http" "net/http"
"os"
"sort" "sort"
"strings" "strings"
"time" "time"
@@ -15,7 +17,6 @@ import (
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/net/http2" "golang.org/x/net/http2"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
@@ -27,22 +28,25 @@ func init() {
} }
// Api is a module that provides an HTTP server. Other modules may add routes, // Api is a module that provides an HTTP server. Other modules may add routes,
// middlewares or health checks. // middlewares, or health checks.
type Api struct { type Api struct {
port int port int
bindIp string bindIp string
tlsCertFile string tlsCertFile string
tlsKeyFile string tlsKeyFile string
startTimeout time.Duration startTimeout time.Duration
bodyLimit int64 bodyLimit int64
timeout time.Duration timeout time.Duration
rootPath string rootPath string
traceHeader string basicAuthUsername string
basicAuthUsername string basicAuthPassword string
basicAuthPassword string correlationIdHeader string
downloadFromCfg downloadFromConfig downloadFromCfg downloadFromConfig
disableHealthCheckLogging bool disableHealthCheckRouteTelemetry bool
enableDebugRoute bool disableRootRouteTelemetry bool
disableVersionRouteTelemetry bool
disableDebugRouteTelemetry bool
enableDebugRoute bool
routes []Route routes []Route
externalMiddlewares []Middleware externalMiddlewares []Middleware
@@ -50,7 +54,7 @@ type Api struct {
readyFn []func() error readyFn []func() error
asyncCounters []AsynchronousCounter asyncCounters []AsynchronousCounter
fs *gotenberg.FileSystem fs *gotenberg.FileSystem
logger *zap.Logger logger *slog.Logger
srv *echo.Echo srv *echo.Echo
} }
@@ -80,9 +84,9 @@ type Route struct {
// Optional. // Optional.
IsMultipart bool IsMultipart bool
// DisableLogging disables the logging for this route. // DisableTelemetry disables the telemetry and logging for this route.
// Optional. // Optional.
DisableLogging bool DisableTelemetry bool
// Handler is the function that handles the request. // Handler is the function that handles the request.
// Required. // Required.
@@ -118,29 +122,6 @@ const (
// Middleware is a middleware that can be added to the [Api]'s middlewares // Middleware is a middleware that can be added to the [Api]'s middlewares
// chain. // chain.
//
// middleware := Middleware{
// Handler: func() echo.MiddlewareFunc {
// return func(next echo.HandlerFunc) echo.HandlerFunc {
// return func(c echo.Context) error {
// rootPath := c.Get("rootPath").(string)
// healthURI := fmt.Sprintf("%shealth", rootPath)
//
// // Skip the middleware if health check URI.
// if c.Request().RequestURI == healthURI {
// // Call the next middleware in the chain.
// return next(c)
// }
//
// // Your middleware process.
// // ...
//
// // Call the next middleware in the chain.
// return next(c)
// }
// }
// }(),
// }
type Middleware struct { type Middleware struct {
// Stack tells in which stack the middleware should be located. // Stack tells in which stack the middleware should be located.
// Default to [DefaultStack]. // Default to [DefaultStack].
@@ -190,14 +171,18 @@ func (a *Api) Descriptor() gotenberg.ModuleDescriptor {
fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests") fs.Duration("api-timeout", time.Duration(30)*time.Second, "Set the time limit for requests")
fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc") fs.String("api-body-limit", "", "Set the body limit for multipart/form-data requests - it accepts values like 5MB, 1GB, etc")
fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths") fs.String("api-root-path", "/", "Set the root path of the API - for service discovery via URL paths")
fs.String("api-trace-header", "Gotenberg-Trace", "Set the header name to use for identifying requests")
fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables") fs.Bool("api-enable-basic-auth", false, "Enable basic authentication - will look for the GOTENBERG_API_BASIC_AUTH_USERNAME and GOTENBERG_API_BASIC_AUTH_PASSWORD environment variables")
fs.String("api-correlation-id-header", "X-Correlation-ID", "Set the header name to use to set the correlation id in the logs")
fs.String("api-download-from-allow-list", "", "Set the allowed URLs for the download from feature using a regular expression") fs.String("api-download-from-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.String("api-download-from-deny-list", "", "Set the denied URLs for the download from feature using a regular expression")
fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature") fs.Int("api-download-from-max-retry", 4, "Set the maximum number of retries for the download from feature")
fs.Bool("api-disable-download-from", false, "Disable the download from feature") fs.Bool("api-disable-download-from", false, "Disable the download from feature")
fs.Bool("api-disable-health-check-logging", false, "Disable health check logging") fs.Bool("api-disable-health-check-route-telemetry", false, "Disable the health check route telemetry")
fs.Bool("api-disable-root-route-telemetry", false, "Disable the root route telemetry")
fs.Bool("api-disable-version-route-telemetry", false, "Disable the version route telemetry")
fs.Bool("api-disable-debug-route-telemetry", false, "Disable the debug route telemetry")
fs.Bool("api-enable-debug-route", false, "Enable the debug route") fs.Bool("api-enable-debug-route", false, "Enable the debug route")
return fs return fs
}(), }(),
New: func() gotenberg.Module { return new(Api) }, New: func() gotenberg.Module { return new(Api) },
@@ -215,14 +200,17 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
a.timeout = flags.MustDuration("api-timeout") a.timeout = flags.MustDuration("api-timeout")
a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit") a.bodyLimit = flags.MustHumanReadableBytes("api-body-limit")
a.rootPath = flags.MustString("api-root-path") a.rootPath = flags.MustString("api-root-path")
a.traceHeader = flags.MustString("api-trace-header") a.correlationIdHeader = flags.MustString("api-correlation-id-header")
a.downloadFromCfg = downloadFromConfig{ a.downloadFromCfg = downloadFromConfig{
allowList: flags.MustRegexp("api-download-from-allow-list"), allowList: flags.MustRegexp("api-download-from-allow-list"),
denyList: flags.MustRegexp("api-download-from-deny-list"), denyList: flags.MustRegexp("api-download-from-deny-list"),
maxRetry: flags.MustInt("api-download-from-max-retry"), maxRetry: flags.MustInt("api-download-from-max-retry"),
disable: flags.MustBool("api-disable-download-from"), disable: flags.MustBool("api-disable-download-from"),
} }
a.disableHealthCheckLogging = flags.MustBool("api-disable-health-check-logging") a.disableHealthCheckRouteTelemetry = flags.MustBool("api-disable-health-check-route-telemetry")
a.disableRootRouteTelemetry = flags.MustBool("api-disable-root-route-telemetry")
a.disableVersionRouteTelemetry = flags.MustBool("api-disable-version-route-telemetry")
a.disableDebugRouteTelemetry = flags.MustBool("api-disable-debug-route-telemetry")
a.enableDebugRoute = flags.MustBool("api-enable-debug-route") a.enableDebugRoute = flags.MustBool("api-enable-debug-route")
// Port from env? // Port from env?
@@ -328,17 +316,7 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
} }
// Logger. // Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) a.logger = gotenberg.Logger(a)
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger
// File system. // File system.
a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll)) a.fs = gotenberg.NewFileSystem(new(gotenberg.OsMkdirAll))
@@ -378,12 +356,6 @@ func (a *Api) Validate() error {
) )
} }
if len(strings.TrimSpace(a.traceHeader)) == 0 {
err = multierr.Append(err,
errors.New("trace header must not be empty"),
)
}
if err != nil { if err != nil {
return err return err
} }
@@ -442,28 +414,48 @@ func (a *Api) Start() error {
a.srv.HTTPErrorHandler = httpErrorHandler() a.srv.HTTPErrorHandler = httpErrorHandler()
// Let's prepare the modules' routes. // Let's prepare the modules' routes.
var disableLoggingForPaths []string var disableTelemetryForPaths []string
for i, route := range a.routes { for i, route := range a.routes {
a.routes[i].Path = strings.TrimPrefix(route.Path, "/") a.routes[i].Path = strings.TrimPrefix(route.Path, "/")
if route.DisableLogging { if route.DisableTelemetry {
disableLoggingForPaths = append(disableLoggingForPaths, strings.TrimPrefix(route.Path, "/")) disableTelemetryForPaths = append(disableTelemetryForPaths, strings.TrimPrefix(route.Path, "/"))
} }
} }
// Check if the user wishes to disable telemetry for system routes.
// Check if the user wishes to add logging entries related to the health // Note: root path will be used as prefix in the underlying middlewares.
// check route. if a.disableHealthCheckRouteTelemetry {
if a.disableHealthCheckLogging { disableTelemetryForPaths = append(disableTelemetryForPaths, "health")
disableLoggingForPaths = append(disableLoggingForPaths, "health")
} }
if a.disableRootRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "")
}
if a.disableVersionRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "version")
}
if a.disableDebugRouteTelemetry {
disableTelemetryForPaths = append(disableTelemetryForPaths, "debug")
}
// Always disable telemetry for favicon.
disableTelemetryForPaths = append(disableTelemetryForPaths,
"favicon.ico",
)
// Add the API middlewares. // Add the API middlewares.
hostname, err := os.Hostname()
if err != nil {
return fmt.Errorf("get hostname: %w", err)
}
a.srv.Pre( a.srv.Pre(
latencyMiddleware(), latencyMiddleware(),
rootPathMiddleware(a.rootPath), rootPathMiddleware(a.rootPath),
traceMiddleware(a.traceHeader),
outputFilenameMiddleware(), outputFilenameMiddleware(),
loggerMiddleware(a.logger, disableLoggingForPaths), telemetryMiddleware(a.logger, hostname, a.correlationIdHeader, disableTelemetryForPaths),
) )
// Add the modules' middlewares in their respective stacks. // Add the modules' middlewares in their respective stacks.
@@ -583,7 +575,7 @@ func (a *Api) Start() error {
eg.Go(f) eg.Go(f)
} }
err := eg.Wait() err = eg.Wait()
if err != nil { if err != nil {
return fmt.Errorf("waiting for modules readiness: %w", err) return fmt.Errorf("waiting for modules readiness: %w", err)
} }
@@ -600,7 +592,8 @@ func (a *Api) Start() error {
err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server) err = a.srv.StartH2CServer(fmt.Sprintf("%s:%d", a.bindIp, a.port), server)
} }
if !errors.Is(err, http.ErrServerClosed) { if !errors.Is(err, http.ErrServerClosed) {
a.logger.Fatal(err.Error()) a.logger.ErrorContext(context.Background(), err.Error())
os.Exit(1)
} }
}() }()
@@ -627,12 +620,12 @@ func (a *Api) Stop(ctx context.Context) error {
case <-ctx.Done(): case <-ctx.Done():
return a.srv.Shutdown(ctx) return a.srv.Shutdown(ctx)
default: default:
a.logger.Debug(fmt.Sprintf("%d asynchronous requests", count)) a.logger.DebugContext(ctx, fmt.Sprintf("%d asynchronous requests", count))
if count > 0 { if count > 0 {
time.Sleep(1 * time.Second) time.Sleep(1 * time.Second)
continue continue
} }
a.logger.Debug("no more asynchronous requests, continue with shutdown") a.logger.DebugContext(ctx, "no more asynchronous requests, continue with shutdown")
err := a.srv.Shutdown(ctx) err := a.srv.Shutdown(ctx)
if err != nil { if err != nil {
return fmt.Errorf("shutdown: %w", err) return fmt.Errorf("shutdown: %w", err)

View File

@@ -6,6 +6,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"io" "io"
"log/slog"
"mime" "mime"
"mime/multipart" "mime/multipart"
"net/http" "net/http"
@@ -19,11 +20,14 @@ import (
"github.com/hashicorp/go-retryablehttp" "github.com/hashicorp/go-retryablehttp"
"github.com/labstack/echo/v4" "github.com/labstack/echo/v4"
"github.com/mholt/archives" "github.com/mholt/archives"
"go.uber.org/zap" "go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"golang.org/x/text/unicode/norm" "golang.org/x/text/unicode/norm"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
semconvutil "github.com/gotenberg/gotenberg/v8/pkg/gotenberg/semconv"
) )
var ( var (
@@ -36,7 +40,7 @@ var (
ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory") ErrOutOfBoundsOutputPath = errors.New("output path is not within context's working directory")
) )
// Context is the request context for a "multipart/form-data" requests. // Context is the request context for a "multipart/form-data" request.
type Context struct { type Context struct {
dirPath string dirPath string
values map[string][]string values map[string][]string
@@ -45,7 +49,7 @@ type Context struct {
outputPaths []string outputPaths []string
cancelled bool cancelled bool
logger *zap.Logger logger *slog.Logger
echoCtx echo.Context echoCtx echo.Context
mkdirAll gotenberg.MkdirAll mkdirAll gotenberg.MkdirAll
pathRename gotenberg.PathRename pathRename gotenberg.PathRename
@@ -81,18 +85,12 @@ type downloadFrom struct {
// ExtraHttpHeaders are the HTTP headers to send alongside. // ExtraHttpHeaders are the HTTP headers to send alongside.
ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"` ExtraHttpHeaders map[string]string `json:"extraHttpHeaders"`
// Embedded routes the downloaded file as an embed. Deprecated: use // Download as an attachment file.
// Field instead. Kept for backward compatibility. Attachment bool `json:"attachment"`
Embedded bool `json:"embedded"`
// Field routes the downloaded file to a specific form field bucket.
// Supported values: "watermark", "stamp". For embeds, prefer the
// Embedded flag or set Field to "embedded".
Field string `json:"field"`
} }
// newContext returns a [Context] by parsing a "multipart/form-data" request. // newContext returns a [Context] by parsing a "multipart/form-data" request.
func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig, traceHeader, trace string) (*Context, context.CancelFunc, error) { func newContext(echoCtx echo.Context, logger *slog.Logger, fs *gotenberg.FileSystem, timeout time.Duration, bodyLimit int64, downloadFromCfg downloadFromConfig) (*Context, context.CancelFunc, error) {
processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout) processCtx, processCancel := context.WithTimeout(echoCtx.Request().Context(), timeout)
// We want to make sure the multipart/form-data does not exceed a given // We want to make sure the multipart/form-data does not exceed a given
@@ -137,12 +135,12 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
err := os.RemoveAll(ctx.dirPath) err := os.RemoveAll(ctx.dirPath)
if err != nil { if err != nil {
ctx.logger.Error(fmt.Sprintf("remove context's working directory: %s", err)) ctx.logger.ErrorContext(ctx, fmt.Sprintf("remove context's working directory: %s", err))
return return
} }
ctx.logger.Debug(fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath)) ctx.logger.DebugContext(ctx, fmt.Sprintf("'%s' context's working directory removed", ctx.dirPath))
ctx.cancelled = true ctx.cancelled = true
} }
}() }()
@@ -230,9 +228,9 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
return fmt.Errorf("filter URL: %w", err) return fmt.Errorf("filter URL: %w", err)
} }
logger.Debug(fmt.Sprintf("download file from '%s'", dl.Url)) logger.DebugContext(ctx, fmt.Sprintf("download file from '%s'", dl.Url))
req, err := retryablehttp.NewRequest(http.MethodGet, dl.Url, nil) req, err := retryablehttp.NewRequestWithContext(ctx, http.MethodGet, dl.Url, nil)
if err != nil { if err != nil {
return fmt.Errorf("create request to '%s': %w", dl.Url, err) return fmt.Errorf("create request to '%s': %w", dl.Url, err)
} }
@@ -241,7 +239,20 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
for key, value := range dl.ExtraHttpHeaders { for key, value := range dl.ExtraHttpHeaders {
req.Header.Set(key, value) req.Header.Set(key, value)
} }
req.Header.Set(traceHeader, trace) req.Header.Set(echoCtx.Get("correlationIdHeader").(string), echoCtx.Get("correlationId").(string))
// OpenTelemetry.
meter := gotenberg.Meter()
semconvClient := semconvutil.NewHTTPClient(meter)
tracer := gotenberg.Tracer()
traceCtx, span := tracer.Start(ctx, fmt.Sprintf("%s Download From", req.Method),
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(semconvClient.RequestTraceAttrs(req.Request)...),
)
defer span.End()
otel.GetTextMapPropagator().Inject(traceCtx, propagation.HeaderCarrier(req.Header))
client := &retryablehttp.Client{ client := &retryablehttp.Client{
HTTPClient: &http.Client{ HTTPClient: &http.Client{
@@ -250,22 +261,27 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
RetryMax: downloadFromCfg.maxRetry, RetryMax: downloadFromCfg.maxRetry,
RetryWaitMin: time.Duration(1) * time.Second, RetryWaitMin: time.Duration(1) * time.Second,
RetryWaitMax: time.Until(deadline), RetryWaitMax: time.Until(deadline),
Logger: gotenberg.NewLeveledLogger(logger), Logger: gotenberg.NewLeveledLogger(logger).WithContext(ctx),
CheckRetry: retryablehttp.DefaultRetryPolicy, CheckRetry: retryablehttp.DefaultRetryPolicy,
Backoff: retryablehttp.DefaultBackoff, Backoff: retryablehttp.DefaultBackoff,
} }
resp, err := client.Do(req) resp, err := client.Do(req)
if err != nil { if err != nil {
span.RecordError(err)
span.SetStatus(semconvClient.Status(0))
return WrapError( return WrapError(
fmt.Errorf("download file from to '%s': %w", dl.Url, err), 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)), NewSentinelHttpError(http.StatusBadRequest, fmt.Sprintf("Unable to download file from '%s': %s", dl.Url, err)),
) )
} }
span.SetAttributes(semconvClient.ResponseTraceAttrs(resp)...)
span.SetStatus(semconvClient.Status(resp.StatusCode))
defer func() { defer func() {
err := resp.Body.Close() err := resp.Body.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close response body from '%s': %s", dl.Url, err)) logger.ErrorContext(ctx, fmt.Sprintf("close response body from '%s': %s", dl.Url, err))
} }
}() }()
@@ -307,7 +323,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
// normalized. // normalized.
// See: https://github.com/gotenberg/gotenberg/issues/662. // See: https://github.com/gotenberg/gotenberg/issues/662.
filename = norm.NFC.String(filepath.Base(filename)) filename = norm.NFC.String(filepath.Base(filename))
path := fmt.Sprintf("%s/%s", ctx.dirPath, filename) path := fmt.Sprintf("%s/%s", dirPath, filename)
out, err := os.Create(path) out, err := os.Create(path)
if err != nil { if err != nil {
@@ -316,7 +332,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() { defer func() {
err := out.Close() err := out.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err))
} }
}() }()
@@ -329,15 +345,8 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
ctx.files[filename] = path ctx.files[filename] = path
if dl.Attachment {
// Route the downloaded file to the appropriate field bucket. ctx.filesByField[AttachmentsFormField] = append(ctx.filesByField[AttachmentsFormField], path)
switch {
case dl.Field == "embedded" || dl.Embedded:
ctx.filesByField[EmbedsFormField] = append(ctx.filesByField[EmbedsFormField], path)
case dl.Field == "watermark":
ctx.filesByField[WatermarkFormField] = append(ctx.filesByField[WatermarkFormField], path)
case dl.Field == "stamp":
ctx.filesByField[StampFormField] = append(ctx.filesByField[StampFormField], path)
} }
return nil return nil
@@ -359,7 +368,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() { defer func() {
err := in.Close() err := in.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close file header: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("close file header: %s", err))
} }
}() }()
@@ -379,7 +388,7 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
defer func() { defer func() {
err := out.Close() err := out.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close local file: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("close local file: %s", err))
} }
}() }()
@@ -407,10 +416,10 @@ func newContext(echoCtx echo.Context, logger *zap.Logger, fs *gotenberg.FileSyst
} }
} }
ctx.Log().Debug(fmt.Sprintf("form fields: %+v", ctx.values)) ctx.Log().DebugContext(ctx, fmt.Sprintf("form fields: %+v", ctx.values))
ctx.Log().Debug(fmt.Sprintf("form files: %+v", ctx.files)) ctx.Log().DebugContext(ctx, fmt.Sprintf("form files: %+v", ctx.files))
ctx.Log().Debug(fmt.Sprintf("form files by field: %+v", ctx.filesByField)) ctx.Log().DebugContext(ctx, fmt.Sprintf("form files by field: %+v", ctx.filesByField))
ctx.Log().Debug(fmt.Sprintf("total bytes: %d", totalBytesRead.Load())) ctx.Log().DebugContext(ctx, fmt.Sprintf("total bytes: %d", totalBytesRead.Load()))
return ctx, cancel, err return ctx, cancel, err
} }
@@ -457,7 +466,7 @@ func (ctx *Context) CreateSubDirectory(dirName string) (string, error) {
// Rename is just a wrapper around [os.Rename], as we need to mock this // Rename is just a wrapper around [os.Rename], as we need to mock this
// behavior in our tests. // behavior in our tests.
func (ctx *Context) Rename(oldpath, newpath string) error { func (ctx *Context) Rename(oldpath, newpath string) error {
ctx.Log().Debug(fmt.Sprintf("rename %s to %s", oldpath, newpath)) ctx.Log().DebugContext(ctx, fmt.Sprintf("rename %s to %s", oldpath, newpath))
err := ctx.pathRename.Rename(oldpath, newpath) err := ctx.pathRename.Rename(oldpath, newpath)
if err != nil { if err != nil {
return fmt.Errorf("rename path: %w", err) return fmt.Errorf("rename path: %w", err)
@@ -483,8 +492,8 @@ func (ctx *Context) AddOutputPaths(paths ...string) error {
return nil return nil
} }
// Log returns the context [zap.Logger]. // Log returns the context [slog.Logger].
func (ctx *Context) Log() *zap.Logger { func (ctx *Context) Log() *slog.Logger {
return ctx.logger return ctx.logger
} }
@@ -500,7 +509,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
} }
if len(ctx.outputPaths) == 1 { if len(ctx.outputPaths) == 1 {
ctx.logger.Debug(fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0])) ctx.logger.DebugContext(ctx, fmt.Sprintf("only one output file '%s', skip archive creation", ctx.outputPaths[0]))
return ctx.outputPaths[0], nil return ctx.outputPaths[0], nil
} }
@@ -523,7 +532,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
defer func(out *os.File) { defer func(out *os.File) {
err := out.Close() err := out.Close()
if err != nil { if err != nil {
ctx.logger.Error(fmt.Sprintf("close zip file: %s", err)) ctx.logger.ErrorContext(ctx, fmt.Sprintf("close zip file: %s", err))
} }
}(out) }(out)
@@ -532,7 +541,7 @@ func (ctx *Context) BuildOutputFile() (string, error) {
return "", fmt.Errorf("archive output files: %w", err) return "", fmt.Errorf("archive output files: %w", err)
} }
ctx.logger.Debug(fmt.Sprintf("archive '%s' created", archivePath)) ctx.logger.DebugContext(ctx, fmt.Sprintf("archive '%s' created", archivePath))
return archivePath, nil return archivePath, nil
} }

View File

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

View File

@@ -17,15 +17,9 @@ import (
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
// AttachmentsFormField represents the form field name for attaching files.
const ( const (
// EmbedsFormField represents the form field name for embedding files. AttachmentsFormField string = "attachments"
EmbedsFormField string = "embeds"
// WatermarkFormField represents the form field name for the watermark file.
WatermarkFormField string = "watermark"
// StampFormField represents the form field name for the stamp file.
StampFormField string = "stamp"
) )
// FormData is a helper for validating and hydrating values from a // FormData is a helper for validating and hydrating values from a
@@ -371,20 +365,20 @@ func (form *FormData) Paths(extensions []string, target *[]string) *FormData {
return form.paths(extensions, target) return form.paths(extensions, target)
} }
// Embeds binds the absolute paths of form data files that should be // Attachments binds the absolute paths of form data files that should be
// embedded in the PDF. Only files uploaded with the "embeds" field name // attached in the PDF. Only files uploaded with the "attachments" field name
// will be included. // will be included.
// //
// var embeds []string // var attachments []string
// //
// ctx.FormData().Embeds(&embeds) // ctx.FormData().Attachments(&attachments)
func (form *FormData) Embeds(target *[]string) *FormData { func (form *FormData) Attachments(target *[]string) *FormData {
if form.errors != nil { if form.errors != nil {
return form return form
} }
// Get files from the "embeds" field // Get files from the "attachments" field
if paths, ok := form.filesByField[EmbedsFormField]; ok { if paths, ok := form.filesByField[AttachmentsFormField]; ok {
*target = append(*target, paths...) *target = append(*target, paths...)
} }
@@ -412,54 +406,14 @@ func (form *FormData) MandatoryPaths(extensions []string, target *[]string) *For
return form return form
} }
// Watermark binds the absolute path of the form data file that should be
// used as a watermark source. Only a file uploaded with the "watermark"
// field name will be included.
func (form *FormData) Watermark(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[WatermarkFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// Stamp binds the absolute path of the form data file that should be
// used as a stamp source. Only a file uploaded with the "stamp"
// field name will be included.
func (form *FormData) Stamp(target *string) *FormData {
if form.errors != nil {
return form
}
if paths, ok := form.filesByField[StampFormField]; ok && len(paths) > 0 {
*target = paths[0]
}
return form
}
// paths bind the absolute paths of form data files, according to a list of // paths bind the absolute paths of form data files, according to a list of
// file extensions, to a string slice variable. // file extensions, to a string slice variable.
// embeds, watermark, and stamp files are excluded. // attachments are excluded.
func (form *FormData) paths(extensions []string, target *[]string) *FormData { func (form *FormData) paths(extensions []string, target *[]string) *FormData {
embeds, ok := form.filesByField[EmbedsFormField] attachments, ok := form.filesByField[AttachmentsFormField]
watermarks, wmOk := form.filesByField[WatermarkFormField]
stamps, stOk := form.filesByField[StampFormField]
for filename, path := range form.files { for filename, path := range form.files {
if ok && slices.Contains(embeds, path) { if ok && slices.Contains(attachments, path) {
continue
}
if wmOk && slices.Contains(watermarks, path) {
continue
}
if stOk && slices.Contains(stamps, path) {
continue continue
} }

View File

@@ -1613,15 +1613,15 @@ func TestFormData_Paths(t *testing.T) {
expectCount: 2, expectCount: 2,
}, },
{ {
scenario: "files except embeds", scenario: "files except attachments",
form: &FormData{ form: &FormData{
files: map[string]string{ files: map[string]string{
"foo.pdf": "/foo.pdf", "foo.pdf": "/foo.pdf",
"embed_1.pdf": "/embed_1.pdf", "attachments_1.pdf": "/attachments_1.pdf",
"embed_2.xml": "/embed_2.xml", "attachments_2.xml": "/attachments_2.xml",
}, },
filesByField: map[string][]string{ filesByField: map[string][]string{
"embeds": {"/embed_1.pdf", "/embed_2.xml"}, "attachments": {"/attachments_1.pdf", "/attachments_2.xml"},
}, },
}, },
extensions: []string{".pdf"}, extensions: []string{".pdf"},
@@ -1759,7 +1759,7 @@ func TestFormData_mustAssign(t *testing.T) {
form.mustAssign("foo", "foo", &target) form.mustAssign("foo", "foo", &target)
} }
func TestFormData_Embeds(t *testing.T) { func TestFormData_Attachments(t *testing.T) {
expected := []string{"/bar.xml", "/baz.xml"} expected := []string{"/bar.xml", "/baz.xml"}
var actual []string var actual []string
@@ -1770,13 +1770,13 @@ func TestFormData_Embeds(t *testing.T) {
"baz.xml": "/baz.xml", "baz.xml": "/baz.xml",
}, },
filesByField: map[string][]string{ filesByField: map[string][]string{
"embeds": {"/bar.xml", "/baz.xml"}, "attachments": {"/bar.xml", "/baz.xml"},
}, },
} }
form.Embeds(&actual) form.Attachments(&actual)
if len(actual) != len(expected) { if len(actual) != len(expected) {
t.Errorf("expected %d embeds but got %d", len(expected), len(actual)) t.Errorf("expected %d attachments but got %d", len(expected), len(actual))
} }
if !reflect.DeepEqual(actual, expected) { if !reflect.DeepEqual(actual, expected) {

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"strings" "strings"
"sync" "sync"
@@ -18,15 +19,18 @@ import (
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"github.com/shirou/gopsutil/v4/process" "github.com/shirou/gopsutil/v4/process"
"go.uber.org/zap" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type browser interface { type browser interface {
gotenberg.Process gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
} }
type browserArguments struct { type browserArguments struct {
@@ -72,12 +76,15 @@ func newChromiumBrowser(arguments browserArguments) browser {
return b return b
} }
func (b *chromiumBrowser) Start(logger *zap.Logger) error { func (b *chromiumBrowser) Start(logger *slog.Logger) error {
if b.isStarted.Load() { if b.isStarted.Load() {
return errors.New("browser is already started") return errors.New("browser is already started")
} }
debug := &debugLogger{logger: logger} debug := &debugLogger{
ctx: b.initialCtx,
logger: logger,
}
b.userProfileDirPath = b.fs.NewDirPath() b.userProfileDirPath = b.fs.NewDirPath()
// See https://github.com/gotenberg/gotenberg/issues/1293. // See https://github.com/gotenberg/gotenberg/issues/1293.
@@ -164,7 +171,7 @@ func (b *chromiumBrowser) Start(logger *zap.Logger) error {
return nil return nil
} }
func (b *chromiumBrowser) Stop(logger *zap.Logger) error { func (b *chromiumBrowser) Stop(logger *slog.Logger) error {
if !b.isStarted.Load() { if !b.isStarted.Load() {
// No big deal? Like calling cancel twice. // No big deal? Like calling cancel twice.
return nil return nil
@@ -181,7 +188,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
// Clean up stuck processes. // Clean up stuck processes.
ps, err := process.Processes() ps, err := process.Processes()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("list processes: %v", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("list processes: %v", err))
} else { } else {
for _, p := range ps { for _, p := range ps {
func() { func() {
@@ -199,9 +206,9 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = p.KillWithContext(killCtx) err = p.KillWithContext(killCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("kill process: %v", err)) logger.ErrorContext(killCtx, fmt.Sprintf("kill process: %v", err))
} else { } else {
logger.Debug(fmt.Sprintf("Chromium process %d killed", p.Pid)) logger.DebugContext(killCtx, fmt.Sprintf("Chromium process %d killed", p.Pid))
} }
}() }()
} }
@@ -215,15 +222,15 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
err = os.RemoveAll(userProfileDirPath) err = os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove Chromium's user profile directory: %s", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("remove Chromium's user profile directory: %s", err))
} else { } else {
logger.Debug(fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath)) logger.DebugContext(context.Background(), fmt.Sprintf("'%s' Chromium's user profile directory removed", userProfileDirPath))
} }
// Also, remove Chromium-specific files in the temporary directory. // Also, remove Chromium-specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime) err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{".org.chromium.Chromium", ".com.google.Chrome"}, expirationTime)
if err != nil { if err != nil {
logger.Error(err.Error()) logger.ErrorContext(context.Background(), err.Error())
} }
}() }()
}(copyUserProfileDirPath, expirationTime) }(copyUserProfileDirPath, expirationTime)
@@ -239,7 +246,7 @@ func (b *chromiumBrowser) Stop(logger *zap.Logger) error {
return nil return nil
} }
func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool { func (b *chromiumBrowser) Healthy(logger *slog.Logger) bool {
// Good to know: the supervisor does not call this method if no first start // Good to know: the supervisor does not call this method if no first start
// or if the process is restarting. // or if the process is restarting.
@@ -266,14 +273,14 @@ func (b *chromiumBrowser) Healthy(logger *zap.Logger) bool {
return err return err
})) }))
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("browser health check failed: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("browser health check failed: %s", err))
return false return false
} }
return true return true
} }
func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { func (b *chromiumBrowser) pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to // Note: no error wrapping because it leaks on errors we want to display to
// the end user. // the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
@@ -285,7 +292,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies), setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent), userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, options.PrintBackground),
forceExactColorsActionFunc(logger, options.PrintBackground), forceExactColorsActionFunc(logger, options.PrintBackground),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
@@ -299,7 +306,7 @@ func (b *chromiumBrowser) pdf(ctx context.Context, logger *zap.Logger, url, outp
}) })
} }
func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { func (b *chromiumBrowser) screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to // Note: no error wrapping because it leaks on errors we want to display to
// the end user. // the end user.
return b.do(ctx, logger, url, options.Options, chromedp.Tasks{ return b.do(ctx, logger, url, options.Options, chromedp.Tasks{
@@ -311,7 +318,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript), disableJavaScriptActionFunc(logger, b.arguments.disableJavaScript),
setCookiesActionFunc(logger, options.Cookies), setCookiesActionFunc(logger, options.Cookies),
userAgentOverride(logger, options.UserAgent), userAgentOverride(logger, options.UserAgent),
navigateActionFunc(logger, url, options.SkipNetworkIdleEvent), navigateActionFunc(logger, url, options.SkipNetworkIdleEvent, options.SkipNetworkAlmostIdleEvent),
hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true), hideDefaultWhiteBackgroundActionFunc(logger, options.OmitBackground, true),
forceExactColorsActionFunc(logger, true), forceExactColorsActionFunc(logger, true),
emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures), emulateMediaTypeActionFunc(logger, options.EmulatedMediaType, options.EmulatedMediaFeatures),
@@ -326,7 +333,7 @@ func (b *chromiumBrowser) screenshot(ctx context.Context, logger *zap.Logger, ur
}) })
} }
func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string, options Options, tasks chromedp.Tasks) error { func (b *chromiumBrowser) do(ctx context.Context, logger *slog.Logger, url string, options Options, tasks chromedp.Tasks) error {
if !b.isStarted.Load() { if !b.isStarted.Load() {
return errors.New("browser not started, cannot handle tasks") return errors.New("browser not started, cannot handle tasks")
} }
@@ -412,7 +419,26 @@ func (b *chromiumBrowser) do(ctx context.Context, logger *zap.Logger, url string
resourceLoadingFailedMu: &resourceLoadingFailedMu, resourceLoadingFailedMu: &resourceLoadingFailedMu,
}) })
err = chromedp.Run(taskCtx, tasks...) clientCtx, clientSpan := gotenberg.Tracer().Start(taskCtx, "cdp.execute",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
semconv.ServerAddress("127.0.0.1"),
semconv.ServicePeerName("chromium"),
semconv.RPCSystemNameKey.String("cdp"),
// Legacy attribute for older APMs (Datadog, Jaeger) to draw the
// dependency graph.
attribute.String("peer.service", "chromium"),
),
)
err = chromedp.Run(clientCtx, tasks...)
if err != nil {
clientSpan.RecordError(err)
clientSpan.SetStatus(codes.Error, err.Error())
}
clientSpan.End()
if err != nil { if err != nil {
errMessage := err.Error() errMessage := err.Error()

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -14,7 +15,9 @@ import (
"github.com/chromedp/cdproto/network" "github.com/chromedp/cdproto/network"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.uber.org/zap" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -91,10 +94,17 @@ type Chromium struct {
maxConcurrency int64 maxConcurrency int64
args browserArguments args browserArguments
logger *zap.Logger logger *slog.Logger
browser browser browser browser
supervisor gotenberg.ProcessSupervisor supervisor gotenberg.ProcessSupervisor
engine gotenberg.PdfEngine engine gotenberg.PdfEngine
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
imageOutputSizeCounter metric.Int64Histogram
} }
// Options are the common options for all conversions. // Options are the common options for all conversions.
@@ -105,6 +115,10 @@ type Options struct {
// rendered until this event is fired. // rendered until this event is fired.
SkipNetworkIdleEvent bool 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 // FailOnHttpStatusCodes sets if the conversion should fail if the status
// code from the main page matches with one of its entries. // code from the main page matches with one of its entries.
FailOnHttpStatusCodes []int64 FailOnHttpStatusCodes []int64
@@ -191,6 +205,7 @@ type EmulatedMediaFeature struct {
func DefaultOptions() Options { func DefaultOptions() Options {
return Options{ return Options{
SkipNetworkIdleEvent: true, SkipNetworkIdleEvent: true,
SkipNetworkAlmostIdleEvent: true,
FailOnHttpStatusCodes: []int64{499, 599}, FailOnHttpStatusCodes: []int64{499, 599},
FailOnResourceHttpStatusCodes: nil, FailOnResourceHttpStatusCodes: nil,
IgnoreResourceHttpStatusDomains: nil, IgnoreResourceHttpStatusDomains: nil,
@@ -268,7 +283,7 @@ type PdfOptions struct {
PreferCssPageSize bool PreferCssPageSize bool
// GenerateDocumentOutline defines whether the document outline should be // GenerateDocumentOutline defines whether the document outline should be
// embedded into the PDF. // attached into the PDF.
GenerateDocumentOutline bool GenerateDocumentOutline bool
// GenerateTaggedPdf defines whether to generate tagged (accessible) // GenerateTaggedPdf defines whether to generate tagged (accessible)
@@ -389,8 +404,8 @@ type ExtraHttpHeader struct {
// Api helps to interact with Chromium for converting HTML documents to PDF. // Api helps to interact with Chromium for converting HTML documents to PDF.
type Api interface { type Api interface {
Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error
Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error
} }
// Provider is a module interface that exposes a method for creating an [Api] // Provider is a module interface that exposes a method for creating an [Api]
@@ -428,13 +443,6 @@ func (mod *Chromium) Descriptor() gotenberg.ModuleDescriptor {
fs.Bool("chromium-disable-javascript", false, "Disable JavaScript") fs.Bool("chromium-disable-javascript", false, "Disable JavaScript")
fs.Bool("chromium-disable-routes", false, "Disable the routes") fs.Bool("chromium-disable-routes", false, "Disable the routes")
// Deprecated flags.
fs.Bool("chromium-incognito", false, "Start Chromium with incognito mode")
err := fs.MarkDeprecated("chromium-incognito", "this flag is ignored as it provides no benefits")
if err != nil {
panic(err)
}
return fs return fs
}(), }(),
New: func() gotenberg.Module { return new(Chromium) }, New: func() gotenberg.Module { return new(Chromium) },
@@ -477,15 +485,7 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
} }
// Logger. // Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) mod.logger = gotenberg.Logger(mod).With(slog.String("logger", "browser"))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(mod)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
mod.logger = logger.Named("browser")
// Process. // Process.
mod.browser = newChromiumBrowser(mod.args) mod.browser = newChromiumBrowser(mod.args)
@@ -502,6 +502,106 @@ func (mod *Chromium) Provision(ctx *gotenberg.Context) error {
} }
mod.engine = engine mod.engine = engine
// OpenTelemetry.
meter := gotenberg.Meter()
_, err = meter.Int64ObservableCounter(
"chromium.process.restarts.total",
metric.WithDescription("Current number of Chromium restarts."),
metric.WithUnit("{restart}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := mod.supervisor.RestartsCount()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create process restarts observable counter: %w", err)
}
_, err = meter.Int64ObservableGauge(
"chromium.requests.queue_size",
metric.WithDescription("Current number of Chromium conversion requests waiting to be treated."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := mod.supervisor.ReqQueueSize()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create requests queue size observable gauge: %w", err)
}
_, err = meter.Int64ObservableGauge(
"chromium.requests.active",
metric.WithDescription("Current number of Chromium conversion requests actively being processed."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := mod.supervisor.ActiveTasksCount()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create requests active observable gauge: %w", err)
}
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 requests total counter: %w", err)
}
mod.errsCounter, err = meter.Int64Counter(
"chromium.errors.total",
metric.WithDescription("Total number of Chromium errors."),
metric.WithUnit("{error}"),
)
if err != nil {
return fmt.Errorf("create errors total counter: %w", err)
}
mod.conversionDurationCounter, err = meter.Float64Histogram(
"chromium.conversion.duration",
metric.WithDescription("Duration of each HTML-to-PDF conversion."),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
)
if err != nil {
return fmt.Errorf("create conversion duration histogram: %w", err)
}
mod.queueWaitDurationCounter, err = meter.Float64Histogram(
"chromium.queue.wait.duration",
metric.WithDescription("Time a request spends waiting in the queue before processing starts."),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
)
if err != nil {
return fmt.Errorf("create queue wait duration histogram: %w", err)
}
mod.pdfOutputSizeCounter, err = meter.Int64Histogram(
"chromium.pdf.output.size",
metric.WithDescription("Size of the generated PDF files."),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create pdf output size histogram: %w", err)
}
mod.imageOutputSizeCounter, err = meter.Int64Histogram(
"chromium.image.output.size",
metric.WithDescription("Size of the generated image files."),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create image output size histogram: %w", err)
}
return nil return nil
} }
@@ -552,7 +652,7 @@ func (mod *Chromium) StartupMessage() string {
func (mod *Chromium) Stop(ctx context.Context) error { func (mod *Chromium) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully // Block until the context is done so that another module may gracefully
// stop before we do a shutdown. // stop before we do a shutdown.
mod.logger.Debug("wait for the end of grace duration") mod.logger.DebugContext(ctx, "wait for the end of grace duration")
<-ctx.Done() <-ctx.Done()
@@ -581,26 +681,6 @@ func (mod *Chromium) Debug() map[string]any {
return debug return debug
} }
// Metrics returns the metrics.
func (mod *Chromium) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "chromium_requests_queue_size",
Description: "Current number of Chromium conversion requests waiting to be treated.",
Read: func() float64 {
return float64(mod.supervisor.ReqQueueSize())
},
},
{
Name: "chromium_restarts_count",
Description: "Current number of Chromium restarts.",
Read: func() float64 {
return float64(mod.supervisor.RestartsCount())
},
},
}, nil
}
// Checks adds a health check that verifies if Chromium is healthy. // Checks adds a health check that verifies if Chromium is healthy.
func (mod *Chromium) Checks() ([]health.CheckerOption, error) { func (mod *Chromium) Checks() ([]health.CheckerOption, error) {
return []health.CheckerOption{ return []health.CheckerOption{
@@ -668,32 +748,163 @@ func (mod *Chromium) Routes() ([]api.Route, error) {
} }
// Pdf converts a URL to PDF. // Pdf converts a URL to PDF.
func (mod *Chromium) Pdf(ctx context.Context, logger *zap.Logger, url, outputPath string, options PdfOptions) error { func (mod *Chromium) Pdf(ctx context.Context, logger *slog.Logger, url, outputPath string, options PdfOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to // Note: no error wrapping because it leaks on errors we want to display to
// the end user. // the end user.
return mod.supervisor.Run(ctx, logger, func() error { ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Pdf")
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) 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"
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
reason := "unknown"
if errors.Is(err, context.DeadlineExceeded) {
reason = "timeout"
} else if errors.Is(err, context.Canceled) {
reason = "context_cancelled"
} else if 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"
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) || errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
reason = "chromium_unavailable"
}
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
mod.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
}
}
return err
} }
func (mod *Chromium) Screenshot(ctx context.Context, logger *zap.Logger, url, outputPath string, options ScreenshotOptions) error { func (mod *Chromium) Screenshot(ctx context.Context, logger *slog.Logger, url, outputPath string, options ScreenshotOptions) error {
// Note: no error wrapping because it leaks on errors we want to display to // Note: no error wrapping because it leaks on errors we want to display to
// the end user. // the end user.
return mod.supervisor.Run(ctx, logger, func() error { ctx, span := gotenberg.Tracer().Start(ctx, "Chromium.Screenshot")
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) 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"
}
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
reason := "unknown"
if errors.Is(err, context.DeadlineExceeded) {
reason = "timeout"
} else if errors.Is(err, context.Canceled) {
reason = "context_cancelled"
} else if 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"
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
reason = "chromium_maximum_queue_size_exceeded"
} else if errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
reason = "chromium_unavailable"
}
mod.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
mod.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
mod.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
mod.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
mod.imageOutputSizeCounter.Record(ctx, fileInfo.Size())
}
}
return err
} }
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*Chromium)(nil) _ gotenberg.Module = (*Chromium)(nil)
_ gotenberg.Provisioner = (*Chromium)(nil) _ gotenberg.Provisioner = (*Chromium)(nil)
_ gotenberg.Validator = (*Chromium)(nil) _ gotenberg.Validator = (*Chromium)(nil)
_ gotenberg.App = (*Chromium)(nil) _ gotenberg.App = (*Chromium)(nil)
_ gotenberg.Debuggable = (*Chromium)(nil) _ gotenberg.Debuggable = (*Chromium)(nil)
_ gotenberg.MetricsProvider = (*Chromium)(nil) _ api.HealthChecker = (*Chromium)(nil)
_ api.HealthChecker = (*Chromium)(nil) _ api.Router = (*Chromium)(nil)
_ api.Router = (*Chromium)(nil) _ Api = (*Chromium)(nil)
_ Api = (*Chromium)(nil) _ Provider = (*Chromium)(nil)
_ Provider = (*Chromium)(nil)
) )

View File

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

View File

@@ -3,6 +3,7 @@ package chromium
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"net/http" "net/http"
"net/url" "net/url"
"slices" "slices"
@@ -17,7 +18,6 @@ import (
"github.com/chromedp/chromedp" "github.com/chromedp/chromedp"
"github.com/dlclark/regexp2" "github.com/dlclark/regexp2"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"golang.org/x/sync/errgroup" "golang.org/x/sync/errgroup"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
@@ -32,29 +32,29 @@ type eventRequestPausedOptions struct {
// allowed or not. It also set the extra HTTP headers, if any. // allowed or not. It also set the extra HTTP headers, if any.
// See https://github.com/gotenberg/gotenberg/issues/1011. // See https://github.com/gotenberg/gotenberg/issues/1011.
// TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now). // TODO: https://chromedevtools.github.io/devtools-protocol/tot/Network/#method-setBlockedURLs (experimental for now).
func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, options eventRequestPausedOptions) { func listenForEventRequestPaused(ctx context.Context, logger *slog.Logger, options eventRequestPausedOptions) {
if len(options.extraHttpHeaders) == 0 { if len(options.extraHttpHeaders) == 0 {
logger.Debug("no extra HTTP headers") logger.DebugContext(ctx, "no extra HTTP headers")
} else { } else {
logger.Debug(fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders)) logger.DebugContext(ctx, fmt.Sprintf("extra HTTP headers: %+v", options.extraHttpHeaders))
} }
chromedp.ListenTarget(ctx, func(ev any) { chromedp.ListenTarget(ctx, func(ev any) {
switch e := ev.(type) { switch e := ev.(type) {
case *fetch.EventRequestPaused: case *fetch.EventRequestPaused:
go func() { go func() {
logger.Debug(fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL)) logger.DebugContext(ctx, fmt.Sprintf("event EventRequestPaused fired for '%s'", e.Request.URL))
allow := true allow := true
deadline, ok := ctx.Deadline() deadline, ok := ctx.Deadline()
if !ok { if !ok {
logger.Error("context has no deadline, cannot filter URL") logger.ErrorContext(ctx, "context has no deadline, cannot filter URL")
return return
} }
err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline) err := gotenberg.FilterDeadline(options.allowList, options.denyList, e.Request.URL, deadline)
if err != nil { if err != nil {
logger.Warn(err.Error()) logger.WarnContext(ctx, err.Error())
allow = false allow = false
} }
@@ -65,7 +65,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied) req := fetch.FailRequest(e.RequestID, network.ErrorReasonAccessDenied)
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("fail request: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("fail request: %s", err))
} }
return return
} }
@@ -81,25 +81,25 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
for _, header := range options.extraHttpHeaders { for _, header := range options.extraHttpHeaders {
if header.Scope == nil { if header.Scope == nil {
// Non-scoped header. // Non-scoped header.
logger.Debug(fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL)) logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header) extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
continue continue
} }
ok, err := header.Scope.MatchString(e.Request.URL) ok, err := header.Scope.MatchString(e.Request.URL)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err)) logger.ErrorContext(ctx, fmt.Sprintf("fail to match extra HTTP header '%s' scope with URL '%s': %s", header.Name, e.Request.URL, err))
} else if ok { } else if ok {
logger.Debug(fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL)) logger.DebugContext(ctx, fmt.Sprintf("extra HTTP header '%s' (scoped) will be set for request URL '%s'", header.Name, e.Request.URL))
extraHttpHeadersToSet = append(extraHttpHeadersToSet, header) extraHttpHeadersToSet = append(extraHttpHeadersToSet, header)
} else { } else {
logger.Debug(fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL)) logger.DebugContext(ctx, fmt.Sprintf("scoped extra HTTP header '%s' (scoped) will not be set for request URL '%s'", header.Name, e.Request.URL))
} }
} }
} }
if len(extraHttpHeadersToSet) > 0 { if len(extraHttpHeadersToSet) > 0 {
logger.Debug(fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet)) logger.DebugContext(ctx, fmt.Sprintf("setting extra HTTP headers for request URL '%s': %+v", e.Request.URL, extraHttpHeadersToSet))
originalHeaders := e.Request.Headers originalHeaders := e.Request.Headers
headers := make(map[string]string) headers := make(map[string]string)
@@ -109,7 +109,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
if ok { if ok {
headers[key] = strValue headers[key] = strValue
} else { } else {
logger.Error(fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL)) logger.ErrorContext(ctx, fmt.Sprintf("ignoring header '%s' for URL '%s' since it cannot be cast to a string", key, e.Request.URL))
} }
} }
@@ -132,7 +132,7 @@ func listenForEventRequestPaused(ctx context.Context, logger *zap.Logger, option
err = req.Do(executorCtx) err = req.Do(executorCtx)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("continue request: %s", err)) logger.ErrorContext(ctx, fmt.Sprintf("continue request: %s", err))
} }
}() }()
} }
@@ -157,7 +157,7 @@ type eventResponseReceivedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/1021. // https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventResponseReceived( func listenForEventResponseReceived(
ctx context.Context, ctx context.Context,
logger *zap.Logger, logger *slog.Logger,
options eventResponseReceivedOptions, options eventResponseReceivedOptions,
) { ) {
normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains) normalizedIgnoreDomains := normalizeDomains(options.ignoreResourceHttpStatusDomains)
@@ -180,7 +180,7 @@ func listenForEventResponseReceived(
switch ev := ev.(type) { switch ev := ev.(type) {
case *network.EventResponseReceived: case *network.EventResponseReceived:
if ev.Response.URL == options.mainPageUrl { if ev.Response.URL == options.mainPageUrl {
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response)) logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for main page: %+v", ev.Response))
if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) { if slices.Contains(options.failOnHttpStatusCodes, ev.Response.Status) {
options.invalidHttpStatusCodeMu.Lock() options.invalidHttpStatusCodeMu.Lock()
@@ -192,11 +192,11 @@ func listenForEventResponseReceived(
return return
} }
logger.Debug(fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response)) logger.DebugContext(ctx, fmt.Sprintf("event EventResponseReceived fired for a resource: %+v", ev.Response))
if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) { if slices.Contains(options.failOnResourceOnHttpStatusCode, ev.Response.Status) {
if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) { if !shouldCheckResourceHttpStatusCode(ev.Response.URL, normalizedIgnoreDomains) {
logger.Debug(fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL)) logger.DebugContext(ctx, fmt.Sprintf("skip resource HTTP status code check for '%s' due to domain filtering", ev.Response.URL))
return return
} }
@@ -298,11 +298,11 @@ type eventLoadingFailedOptions struct {
// https://github.com/gotenberg/gotenberg/issues/913. // https://github.com/gotenberg/gotenberg/issues/913.
// https://github.com/gotenberg/gotenberg/issues/959. // https://github.com/gotenberg/gotenberg/issues/959.
// https://github.com/gotenberg/gotenberg/issues/1021. // https://github.com/gotenberg/gotenberg/issues/1021.
func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, options eventLoadingFailedOptions) { func listenForEventLoadingFailed(ctx context.Context, logger *slog.Logger, options eventLoadingFailedOptions) {
chromedp.ListenTarget(ctx, func(ev any) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *network.EventLoadingFailed: case *network.EventLoadingFailed:
logger.Debug(fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText)) logger.DebugContext(ctx, fmt.Sprintf("event EventLoadingFailed fired: %+v", ev.ErrorText))
// We are looking for common errors. // We are looking for common errors.
// TODO: sufficient? // TODO: sufficient?
@@ -321,14 +321,14 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
"net::ERR_HTTP2_PROTOCOL_ERROR", "net::ERR_HTTP2_PROTOCOL_ERROR",
} }
if !slices.Contains(errors, ev.ErrorText) { if !slices.Contains(errors, ev.ErrorText) {
logger.Debug(fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors)) logger.DebugContext(ctx, fmt.Sprintf("skip EventLoadingFailed: '%s' is not part of %+v", ev.ErrorText, errors))
return return
} }
if ev.Type == network.ResourceTypeDocument { if ev.Type == network.ResourceTypeDocument {
// Supposition: except iframe, an event loading failed with a // Supposition: except iframe, an event loading failed with a
// resource type Document is about the main page. // resource type Document is about the main page.
logger.Debug("event EventLoadingFailed fired for main page") logger.DebugContext(ctx, "event EventLoadingFailed fired for main page")
options.loadingFailedMu.Lock() options.loadingFailedMu.Lock()
defer options.loadingFailedMu.Unlock() defer options.loadingFailedMu.Unlock()
@@ -338,7 +338,7 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
return return
} }
logger.Debug("event EventLoadingFailed fired for a resource") logger.DebugContext(ctx, "event EventLoadingFailed fired for a resource")
options.resourceLoadingFailedMu.Lock() options.resourceLoadingFailedMu.Lock()
defer options.resourceLoadingFailedMu.Unlock() defer options.resourceLoadingFailedMu.Unlock()
@@ -354,11 +354,11 @@ func listenForEventLoadingFailed(ctx context.Context, logger *zap.Logger, option
// listenForEventExceptionThrown listens for exceptions in the console and // listenForEventExceptionThrown listens for exceptions in the console and
// appends those exceptions to the given error pointer. // appends those exceptions to the given error pointer.
// See https://github.com/gotenberg/gotenberg/issues/262. // See https://github.com/gotenberg/gotenberg/issues/262.
func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) { func listenForEventExceptionThrown(ctx context.Context, logger *slog.Logger, consoleExceptions *error, consoleExceptionsMu *sync.RWMutex) {
chromedp.ListenTarget(ctx, func(ev any) { chromedp.ListenTarget(ctx, func(ev any) {
switch ev := ev.(type) { switch ev := ev.(type) {
case *runtime.EventExceptionThrown: case *runtime.EventExceptionThrown:
logger.Debug(fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails)) logger.DebugContext(ctx, fmt.Sprintf("event EventExceptionThrown fired: %+v", ev.ExceptionDetails))
consoleExceptionsMu.Lock() consoleExceptionsMu.Lock()
defer consoleExceptionsMu.Unlock() defer consoleExceptionsMu.Unlock()
@@ -370,7 +370,7 @@ func listenForEventExceptionThrown(ctx context.Context, logger *zap.Logger, cons
// waitForEventDomContentEventFired waits until the event DomContentEventFired // waitForEventDomContentEventFired waits until the event DomContentEventFired
// is fired or the context timeout. // is fired or the context timeout.
func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) func() error { func waitForEventDomContentEventFired(ctx context.Context, logger *slog.Logger) func() error {
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
@@ -384,7 +384,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
select { select {
case <-ch: case <-ch:
logger.Debug("event DomContentEventFired fired") logger.DebugContext(ctx, "event DomContentEventFired fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err()) return fmt.Errorf("wait for event DomContentEventFired: %w", ctx.Err())
@@ -394,7 +394,7 @@ func waitForEventDomContentEventFired(ctx context.Context, logger *zap.Logger) f
// waitForEventLoadEventFired waits until the event LoadEventFired is fired or // waitForEventLoadEventFired waits until the event LoadEventFired is fired or
// the context timeout. // the context timeout.
func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func() error { func waitForEventLoadEventFired(ctx context.Context, logger *slog.Logger) func() error {
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
@@ -408,7 +408,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
select { select {
case <-ch: case <-ch:
logger.Debug("event LoadEventFired fired") logger.DebugContext(ctx, "event LoadEventFired fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err()) return fmt.Errorf("wait for event LoadEventFired: %w", ctx.Err())
@@ -418,7 +418,7 @@ func waitForEventLoadEventFired(ctx context.Context, logger *zap.Logger) func()
// waitForEventNetworkIdle waits until the event networkIdle is fired or the // waitForEventNetworkIdle waits until the event networkIdle is fired or the
// context timeout. // context timeout.
func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() error { func waitForEventNetworkIdle(ctx context.Context, logger *slog.Logger) func() error {
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
@@ -434,7 +434,7 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() err
select { select {
case <-ch: case <-ch:
logger.Debug("event networkIdle fired") logger.DebugContext(ctx, "event networkIdle fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event networkIdle: %w", ctx.Err()) return fmt.Errorf("wait for event networkIdle: %w", ctx.Err())
@@ -442,9 +442,35 @@ func waitForEventNetworkIdle(ctx context.Context, logger *zap.Logger) func() 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) {
switch e := ev.(type) {
case *page.EventLifecycleEvent:
if 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 // waitForEventLoadingFinished waits until the event LoadingFinished is fired
// or the context timeout. // or the context timeout.
func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func() error { func waitForEventLoadingFinished(ctx context.Context, logger *slog.Logger) func() error {
return func() error { return func() error {
ch := make(chan struct{}) ch := make(chan struct{})
cctx, cancel := context.WithCancel(ctx) cctx, cancel := context.WithCancel(ctx)
@@ -458,7 +484,7 @@ func waitForEventLoadingFinished(ctx context.Context, logger *zap.Logger) func()
select { select {
case <-ch: case <-ch:
logger.Debug("event LoadingFinished fired") logger.DebugContext(ctx, "event LoadingFinished fired")
return nil return nil
case <-ctx.Done(): case <-ctx.Done():
return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err()) return fmt.Errorf("wait for event LoadingFinished: %w", ctx.Err())

View File

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

View File

@@ -48,6 +48,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
var ( var (
skipNetworkIdleEvent bool skipNetworkIdleEvent bool
skipNetworkAlmostIdleEvent bool
failOnHttpStatusCodes []int64 failOnHttpStatusCodes []int64
failOnResourceHttpStatusCodes []int64 failOnResourceHttpStatusCodes []int64
ignoreResourceHttpStatusDomains []string ignoreResourceHttpStatusDomains []string
@@ -67,6 +68,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
form := ctx.FormData(). form := ctx.FormData().
Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent). Bool("skipNetworkIdleEvent", &skipNetworkIdleEvent, defaultOptions.SkipNetworkIdleEvent).
Bool("skipNetworkAlmostIdleEvent", &skipNetworkAlmostIdleEvent, defaultOptions.SkipNetworkAlmostIdleEvent).
Custom("failOnHttpStatusCodes", func(value string) error { Custom("failOnHttpStatusCodes", func(value string) error {
if value == "" { if value == "" {
failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes failOnHttpStatusCodes = defaultOptions.FailOnHttpStatusCodes
@@ -254,6 +256,7 @@ func FormDataChromiumOptions(ctx *api.Context) (*api.FormData, Options) {
options := Options{ options := Options{
SkipNetworkIdleEvent: skipNetworkIdleEvent, SkipNetworkIdleEvent: skipNetworkIdleEvent,
SkipNetworkAlmostIdleEvent: skipNetworkAlmostIdleEvent,
FailOnHttpStatusCodes: failOnHttpStatusCodes, FailOnHttpStatusCodes: failOnHttpStatusCodes,
FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes, FailOnResourceHttpStatusCodes: failOnResourceHttpStatusCodes,
IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains, IgnoreResourceHttpStatusDomains: ignoreResourceHttpStatusDomains,
@@ -414,12 +417,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var url string var url string
err := form. err := form.
@@ -429,14 +427,7 @@ func convertUrlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" { err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages)
if err != nil { if err != nil {
return fmt.Errorf("convert URL to PDF: %w", err) return fmt.Errorf("convert URL to PDF: %w", err)
} }
@@ -489,12 +480,7 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var inputPath string var inputPath string
err := form. err := form.
@@ -504,15 +490,8 @@ func convertHtmlRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
url := fmt.Sprintf("file://%s", inputPath) url := fmt.Sprintf("file://%s", inputPath)
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert HTML to PDF: %w", err) return fmt.Errorf("convert HTML to PDF: %w", err)
} }
@@ -566,12 +545,7 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
rotateAngle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
var ( var (
inputPath string inputPath string
@@ -586,19 +560,12 @@ func convertMarkdownRoute(chromium Api, engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
url, err := markdownToHtml(ctx, inputPath, markdownPaths) url, err := markdownToHtml(ctx, inputPath, markdownPaths)
if err != nil { if err != nil {
return fmt.Errorf("transform markdown file(s) to HTML: %w", err) return fmt.Errorf("transform markdown file(s) to HTML: %w", err)
} }
err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, embedPaths, watermark, stamp, rotateAngle, rotatePages) err = convertUrl(ctx, chromium, engine, url, options, mode, pdfFormats, metadata, userPassword, ownerPassword, attachmentsPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert markdown to PDF: %w", err) return fmt.Errorf("convert markdown to PDF: %w", err)
} }
@@ -722,7 +689,7 @@ func markdownToHtml(ctx *api.Context, inputPath string, markdownPaths []string)
return fmt.Sprintf("file://%s", inputPath), nil return fmt.Sprintf("file://%s", inputPath), nil
} }
func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, embedPaths []string, watermark, stamp gotenberg.Stamp, rotateAngle int, rotatePages string) error { func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url string, options PdfOptions, mode gotenberg.SplitMode, pdfFormats gotenberg.PdfFormats, metadata map[string]any, userPassword, ownerPassword string, attachmentsPaths []string) error {
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
// See https://github.com/gotenberg/gotenberg/issues/1130. // See https://github.com/gotenberg/gotenberg/issues/1130.
filename := ctx.OutputFilename(outputPath) filename := ctx.OutputFilename(outputPath)
@@ -784,48 +751,26 @@ func convertUrl(ctx *api.Context, chromium Api, engine gotenberg.PdfEngine, url
return fmt.Errorf("convert to PDF: %w", err) return fmt.Errorf("convert to PDF: %w", err)
} }
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath}) outputPaths, err := pdfengines.SplitPdfStub(ctx, engine, mode, []string{outputPath})
if err != nil { if err != nil {
return fmt.Errorf("split PDF: %w", err) return fmt.Errorf("split PDF: %w", err)
} }
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = pdfengines.RotateStub(ctx, engine, rotateAngle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert PDF(s): %w", err) return fmt.Errorf("convert PDF(s): %w", err)
} }
// Metadata, embeds are written after Convert, as LibreOffice err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths)
// strips them during PDF/A conversion. if err != nil {
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) err = pdfengines.WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("write metadata: %w", err) return fmt.Errorf("write metadata: %w", err)
} }
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths)
if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err)
}
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("encrypt PDFs: %w", err) return fmt.Errorf("encrypt PDFs: %w", err)

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"reflect" "reflect"
@@ -11,7 +12,8 @@ import (
"syscall" "syscall"
"github.com/barasher/go-exiftool" "github.com/barasher/go-exiftool"
"go.uber.org/zap" "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -74,64 +76,118 @@ func (engine *ExifTool) Debug() map[string]any {
} }
// Merge is not available in this implementation. // Merge is not available in this implementation.
func (engine *ExifTool) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *ExifTool) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("merge PDFs with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// Split is not available in this implementation. // Split is not available in this implementation.
func (engine *ExifTool) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { func (engine *ExifTool) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("split PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return nil, err
} }
// Flatten is not available in this implementation. // Flatten is not available in this implementation.
func (engine *ExifTool) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (engine *ExifTool) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("flatten PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *ExifTool) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *ExifTool) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with ExifTool: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// ReadMetadata extracts the metadata of a given PDF file. // ReadMetadata extracts the metadata of a given PDF file.
func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { func (engine *ExifTool) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil { 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) { defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close() err := exifTool.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err)) logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
} }
}(exifTool) }(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath) fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil { if fileMetadata[0].Err != nil {
return nil, fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err) err = fmt.Errorf("read metadata with ExitfTool: %w", fileMetadata[0].Err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
} }
return fileMetadata[0].Fields, nil return fileMetadata[0].Fields, nil
} }
// WriteMetadata writes the metadata into a given PDF file. // WriteMetadata writes the metadata into a given PDF file.
func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
_, span := gotenberg.Tracer().Start(ctx, "ExifTool.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath)) exifTool, err := exiftool.NewExiftool(exiftool.SetExiftoolBinaryPath(engine.binPath))
if err != nil { if err != nil {
return fmt.Errorf("new ExifTool: %w", err) err = fmt.Errorf("new ExifTool: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
defer func(exifTool *exiftool.Exiftool) { defer func(exifTool *exiftool.Exiftool) {
err := exifTool.Close() err := exifTool.Close()
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("close ExifTool: %v", err)) logger.ErrorContext(ctx, fmt.Sprintf("close ExifTool: %v", err))
} }
}(exifTool) }(exifTool)
fileMetadata := exifTool.ExtractMetadata(inputPath) fileMetadata := exifTool.ExtractMetadata(inputPath)
if fileMetadata[0].Err != nil { 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 // Define a list of derived, system, or computed tags that ExifTool
@@ -175,7 +231,10 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
strs[i] = str strs[i] = str
continue 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) fileMetadata[0].SetStrings(key, strs)
case bool: case bool:
@@ -191,82 +250,50 @@ func (engine *ExifTool) WriteMetadata(ctx context.Context, logger *zap.Logger, m
// TODO: support more complex cases, e.g., arrays and nested objects // TODO: support more complex cases, e.g., arrays and nested objects
// (limitations in underlying library). // (limitations in underlying library).
default: 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) exifTool.WriteMetadata(fileMetadata)
if fileMetadata[0].Err != nil { if fileMetadata[0].Err != nil {
return fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err) err = fmt.Errorf("write PDF metadata with ExifTool: %w", fileMetadata[0].Err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
return nil 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) {
metadata, err := engine.ReadMetadata(ctx, logger, inputPath)
if err != nil {
return 0, fmt.Errorf("read metadata with ExifTool: %w", err)
}
pageCountValue, ok := metadata["PageCount"]
if !ok {
return 0, errors.New("PageCount not found in metadata")
}
switch val := pageCountValue.(type) {
case int:
return val, nil
case int64:
return int(val), nil
case float64:
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)
}
return res, nil
default:
return 0, fmt.Errorf("unexpected PageCount type '%T'", pageCountValue)
}
}
// WriteBookmarks is not available in this implementation.
func (engine *ExifTool) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *ExifTool) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// Encrypt is not available in this implementation. // Encrypt is not available in this implementation.
func (engine *ExifTool) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *ExifTool) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported) _, span := gotenberg.Tracer().Start(ctx, "ExifTool.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("encrypt PDF using ExifTool: %w", gotenberg.ErrPdfEncryptionNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// EmbedFiles is not available in this implementation. // AddAttachments is not available in this implementation.
func (engine *ExifTool) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { func (engine *ExifTool) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "ExifTool.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
} defer span.End()
// Watermark is not available in this implementation. err := fmt.Errorf("add attachments with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *ExifTool) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { if err != nil {
return fmt.Errorf("watermark PDF with ExifTool: %w", gotenberg.ErrPdfEngineMethodNotSupported) span.RecordError(err)
} span.SetStatus(codes.Error, err.Error())
}
// Stamp is not available in this implementation. return err
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)
}
// 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)
} }
// Interface guards. // Interface guards.

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"strings" "strings"
@@ -12,8 +13,10 @@ import (
"github.com/alexliesenfeld/health" "github.com/alexliesenfeld/health"
flag "github.com/spf13/pflag" flag "github.com/spf13/pflag"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/metric"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/api"
@@ -45,9 +48,15 @@ type Api struct {
autoStart bool autoStart bool
args libreOfficeArguments args libreOfficeArguments
logger *zap.Logger logger *slog.Logger
libreOffice libreOffice libreOffice libreOffice
supervisor gotenberg.ProcessSupervisor supervisor gotenberg.ProcessSupervisor
reqsCounter metric.Int64Counter
errsCounter metric.Int64Counter
conversionDurationCounter metric.Float64Histogram
queueWaitDurationCounter metric.Float64Histogram
pdfOutputSizeCounter metric.Int64Histogram
} }
// Options gathers available options when converting a document to PDF. // Options gathers available options when converting a document to PDF.
@@ -147,29 +156,6 @@ type Options struct {
// Possible values are: 75, 150, 300, 600 and 1200. // Possible values are: 75, 150, 300, 600 and 1200.
MaxImageResolution int MaxImageResolution int
// NativeWatermarkText specifies the text for a watermark to be drawn on
// every page of the exported PDF file.
// See https://help.libreoffice.org/latest/en-US/text/shared/guide/pdf_params.html.
NativeWatermarkText string
// NativeWatermarkColor specifies the color for the watermark text as a
// decimal long value. Default is 8388223 (light green).
NativeWatermarkColor int
// NativeWatermarkFontHeight specifies the font size for the watermark text.
NativeWatermarkFontHeight int
// NativeWatermarkRotateAngle specifies the rotation angle for the watermark
// text in tenths of a degree (e.g., 450 = 45°).
NativeWatermarkRotateAngle int
// NativeWatermarkFontName specifies the font name for the watermark text.
// Default is "Helvetica".
NativeWatermarkFontName string
// NativeTiledWatermarkText specifies the tiled watermark text.
NativeTiledWatermarkText string
// PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b, // PdfFormats allows to convert the resulting PDF to PDF/A-1b, PDF/A-2b,
// PDF/A-3b and PDF/UA. // PDF/A-3b and PDF/UA.
PdfFormats gotenberg.PdfFormats PdfFormats gotenberg.PdfFormats
@@ -201,12 +187,6 @@ func DefaultOptions() Options {
Quality: 90, Quality: 90,
ReduceImageResolution: false, ReduceImageResolution: false,
MaxImageResolution: 300, MaxImageResolution: 300,
NativeWatermarkText: "",
NativeWatermarkColor: 8388223,
NativeWatermarkFontHeight: 0,
NativeWatermarkRotateAngle: 0,
NativeWatermarkFontName: "Helvetica",
NativeTiledWatermarkText: "",
PdfFormats: gotenberg.PdfFormats{ PdfFormats: gotenberg.PdfFormats{
PdfA: "", PdfA: "",
PdfUa: false, PdfUa: false,
@@ -216,7 +196,7 @@ func DefaultOptions() Options {
// Uno is an abstraction on top of the Universal Network Objects API. // Uno is an abstraction on top of the Universal Network Objects API.
type Uno interface { type Uno interface {
Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
Extensions() []string Extensions() []string
} }
@@ -270,20 +250,103 @@ func (a *Api) Provision(ctx *gotenberg.Context) error {
} }
// Logger. // Logger.
loggerProvider, err := ctx.Module(new(gotenberg.LoggerProvider)) a.logger = gotenberg.Logger(a).With(slog.String("logger", "libreoffice"))
if err != nil {
return fmt.Errorf("get logger provider: %w", err)
}
logger, err := loggerProvider.(gotenberg.LoggerProvider).Logger(a)
if err != nil {
return fmt.Errorf("get logger: %w", err)
}
a.logger = logger.Named("libreoffice")
// Process. // Process.
a.libreOffice = newLibreOfficeProcess(a.args) a.libreOffice = newLibreOfficeProcess(a.args)
a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1) a.supervisor = gotenberg.NewProcessSupervisor(a.logger, a.libreOffice, flags.MustInt64("libreoffice-restart-after"), flags.MustInt64("libreoffice-max-queue-size"), 1)
// OpenTelemetry.
meter := gotenberg.Meter()
_, err := meter.Int64ObservableCounter(
"libreoffice.process.restarts.total",
metric.WithDescription("Current number of LibreOffice restarts."),
metric.WithUnit("{restart}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := a.supervisor.RestartsCount()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create process restarts observable counter: %w", err)
}
_, err = meter.Int64ObservableGauge(
"libreoffice.requests.queue_size",
metric.WithDescription("Current number of LibreOffice conversion requests waiting to be treated."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := a.supervisor.ReqQueueSize()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create requests queue size observable gauge: %w", err)
}
_, err = meter.Int64ObservableGauge(
"libreoffice.requests.active",
metric.WithDescription("Current number of LibreOffice conversion requests actively being processed."),
metric.WithUnit("{request}"),
metric.WithInt64Callback(func(_ context.Context, o metric.Int64Observer) error {
val := a.supervisor.ActiveTasksCount()
o.Observe(val)
return nil
}),
)
if err != nil {
return fmt.Errorf("create requests active observable gauge: %w", err)
}
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 requests total counter: %w", err)
}
a.errsCounter, err = meter.Int64Counter(
"libreoffice.errors.total",
metric.WithDescription("Total number of LibreOffice errors."),
metric.WithUnit("{error}"),
)
if err != nil {
return fmt.Errorf("create errors total counter: %w", err)
}
a.conversionDurationCounter, err = meter.Float64Histogram(
"libreoffice.conversion.duration",
metric.WithDescription("Duration of each PDF conversion."),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
)
if err != nil {
return fmt.Errorf("create conversion duration histogram: %w", err)
}
a.queueWaitDurationCounter, err = meter.Float64Histogram(
"libreoffice.queue.wait.duration",
metric.WithDescription("Time a request spends waiting in the queue before processing starts."),
metric.WithUnit("s"),
metric.WithExplicitBucketBoundaries(0.5, 1, 2, 5, 10, 30, 60),
)
if err != nil {
return fmt.Errorf("create queue wait duration histogram: %w", err)
}
a.pdfOutputSizeCounter, err = meter.Int64Histogram(
"libreoffice.pdf.output.size",
metric.WithDescription("Size of the generated PDF files."),
metric.WithUnit("By"),
)
if err != nil {
return fmt.Errorf("create pdf output size histogram: %w", err)
}
return nil return nil
} }
@@ -332,7 +395,7 @@ func (a *Api) StartupMessage() string {
func (a *Api) Stop(ctx context.Context) error { func (a *Api) Stop(ctx context.Context) error {
// Block until the context is done so that another module may gracefully // Block until the context is done so that another module may gracefully
// stop before we do a shutdown. // stop before we do a shutdown.
a.logger.Debug("wait for the end of grace duration") a.logger.DebugContext(ctx, "wait for the end of grace duration")
<-ctx.Done() <-ctx.Done()
@@ -361,26 +424,6 @@ func (a *Api) Debug() map[string]any {
return debug return debug
} }
// Metrics returns the metrics.
func (a *Api) Metrics() ([]gotenberg.Metric, error) {
return []gotenberg.Metric{
{
Name: "libreoffice_requests_queue_size",
Description: "Current number of LibreOffice conversion requests waiting to be treated.",
Read: func() float64 {
return float64(a.supervisor.ReqQueueSize())
},
},
{
Name: "libreoffice_restarts_count",
Description: "Current number of LibreOffice restarts.",
Read: func() float64 {
return float64(a.supervisor.RestartsCount())
},
},
}, nil
}
// Checks adds a health check that verifies if LibreOffice is healthy. // Checks adds a health check that verifies if LibreOffice is healthy.
func (a *Api) Checks() ([]health.CheckerOption, error) { func (a *Api) Checks() ([]health.CheckerOption, error) {
return []health.CheckerOption{ return []health.CheckerOption{
@@ -431,22 +474,90 @@ func (a *Api) LibreOffice() (Uno, error) {
} }
// Pdf converts a document to PDF. // Pdf converts a document to PDF.
func (a *Api) Pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { func (a *Api) Pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
ctx, span := gotenberg.Tracer().Start(ctx, "LibreOffice.Pdf")
defer span.End()
start := time.Now()
var conversionStart time.Time
err := a.supervisor.Run(ctx, logger, func() error { err := a.supervisor.Run(ctx, logger, func() error {
conversionStart = time.Now()
return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options) return a.libreOffice.pdf(ctx, logger, inputPath, outputPath, options)
}) })
end := time.Now()
status := "success"
if err != nil {
if errors.Is(err, context.DeadlineExceeded) || errors.Is(err, context.Canceled) {
status = "timeout"
} else {
status = "error"
}
}
if !conversionStart.IsZero() {
waitDuration := conversionStart.Sub(start).Seconds()
conversionDuration := end.Sub(conversionStart).Seconds()
a.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
a.conversionDurationCounter.Record(ctx, conversionDuration, metric.WithAttributes(
attribute.String("status", status),
))
} else {
waitDuration := end.Sub(start).Seconds()
a.queueWaitDurationCounter.Record(ctx, waitDuration, metric.WithAttributes(
attribute.String("status", status),
))
}
a.reqsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("status", status),
))
if err == nil { if err == nil {
if fileInfo, statErr := os.Stat(outputPath); statErr == nil {
a.pdfOutputSizeCounter.Record(ctx, fileInfo.Size())
}
return nil return nil
} }
// See https://github.com/gotenberg/gotenberg/issues/639. reason := "unknown"
if errors.Is(err, ErrCoreDumped) { if errors.Is(err, context.DeadlineExceeded) {
logger.Debug(fmt.Sprintf("got a '%s' error, retry conversion", err)) reason = "timeout"
return a.Pdf(ctx, logger, inputPath, outputPath, options) } else if errors.Is(err, context.Canceled) {
reason = "context_cancelled"
} else if errors.Is(err, ErrInvalidPdfFormats) {
reason = "invalid_input"
} else if errors.Is(err, gotenberg.ErrMaximumQueueSizeExceeded) {
reason = "libreoffice_maximum_queue_size_exceeded"
} else if errors.Is(err, gotenberg.ErrProcessAlreadyRestarting) {
reason = "libreoffice_unavailable"
} }
return fmt.Errorf("supervisor run task: %w", err) a.errsCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("reason", reason),
))
// See https://github.com/gotenberg/gotenberg/issues/639.
if errors.Is(err, ErrCoreDumped) {
logger.DebugContext(ctx, fmt.Sprintf("got a '%s' error, retry conversion", err))
err = a.Pdf(ctx, logger, inputPath, outputPath, options)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
}
err = fmt.Errorf("supervisor run task: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
// Extensions returns the file extensions available for conversions. // Extensions returns the file extensions available for conversions.
@@ -588,13 +699,12 @@ func (a *Api) Extensions() []string {
// Interface guards. // Interface guards.
var ( var (
_ gotenberg.Module = (*Api)(nil) _ gotenberg.Module = (*Api)(nil)
_ gotenberg.Provisioner = (*Api)(nil) _ gotenberg.Provisioner = (*Api)(nil)
_ gotenberg.Validator = (*Api)(nil) _ gotenberg.Validator = (*Api)(nil)
_ gotenberg.App = (*Api)(nil) _ gotenberg.App = (*Api)(nil)
_ gotenberg.Debuggable = (*Api)(nil) _ gotenberg.Debuggable = (*Api)(nil)
_ gotenberg.MetricsProvider = (*Api)(nil) _ api.HealthChecker = (*Api)(nil)
_ api.HealthChecker = (*Api)(nil) _ Uno = (*Api)(nil)
_ Uno = (*Api)(nil) _ Provider = (*Api)(nil)
_ Provider = (*Api)(nil)
) )

View File

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

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"net" "net"
"os" "os"
"strings" "strings"
@@ -11,14 +12,17 @@ import (
"sync/atomic" "sync/atomic"
"time" "time"
"go.uber.org/zap" "go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
semconv "go.opentelemetry.io/otel/semconv/v1.39.0"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type libreOffice interface { type libreOffice interface {
gotenberg.Process gotenberg.Process
pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error
} }
type libreOfficeArguments struct { type libreOfficeArguments struct {
@@ -48,7 +52,7 @@ func newLibreOfficeProcess(arguments libreOfficeArguments) libreOffice {
return p return p
} }
func (p *libreOfficeProcess) Start(logger *zap.Logger) error { func (p *libreOfficeProcess) Start(logger *slog.Logger) error {
if p.isStarted.Load() { if p.isStarted.Load() {
return errors.New("LibreOffice is already started") return errors.New("LibreOffice is already started")
} }
@@ -86,7 +90,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("execute LibreOffice: %w", err) return fmt.Errorf("execute LibreOffice: %w", err)
} }
logger.Debug("got exit code 81, e.g., LibreOffice first start") logger.DebugContext(ctx, "got exit code 81, e.g., LibreOffice first start")
// Second start (daemon). // Second start (daemon).
cmd = gotenberg.Command(logger, p.arguments.binPath, args...) cmd = gotenberg.Command(logger, p.arguments.binPath, args...)
@@ -123,7 +127,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
connChan <- nil connChan <- nil
err = conn.Close() err = conn.Close()
if err != nil { if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking the LibreOffice: %v", err)) logger.DebugContext(ctx, fmt.Sprintf("close connection after health checking the LibreOffice: %v", err))
} }
break break
@@ -148,19 +152,19 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
// Let's make sure the process is killed. // Let's make sure the process is killed.
err = cmd.Kill() err = cmd.Kill()
if err != nil { if err != nil {
logger.Debug(fmt.Sprintf("kill LibreOffice process: %v", err)) logger.DebugContext(context.Background(), fmt.Sprintf("kill LibreOffice process: %v", err))
} }
// And the user profile directory is deleted. // And the user profile directory is deleted.
err = os.RemoveAll(userProfileDirPath) err = os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} }
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath)) logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
}() }()
logger.Debug("waiting for the LibreOffice socket to be available...") logger.DebugContext(ctx, "waiting for the LibreOffice socket to be available...")
for { for {
select { select {
@@ -169,7 +173,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
return fmt.Errorf("LibreOffice socket not available: %w", err) return fmt.Errorf("LibreOffice socket not available: %w", err)
} }
logger.Debug("LibreOffice socket available") logger.DebugContext(ctx, "LibreOffice socket available")
success = true success = true
return nil return nil
@@ -179,7 +183,7 @@ func (p *libreOfficeProcess) Start(logger *zap.Logger) error {
} }
} }
func (p *libreOfficeProcess) Stop(logger *zap.Logger) error { func (p *libreOfficeProcess) Stop(logger *slog.Logger) error {
if !p.isStarted.Load() { if !p.isStarted.Load() {
// No big deal? Like calling cancel twice. // No big deal? Like calling cancel twice.
return nil return nil
@@ -192,15 +196,15 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
go func() { go func() {
err := os.RemoveAll(userProfileDirPath) err := os.RemoveAll(userProfileDirPath)
if err != nil { if err != nil {
logger.Error(fmt.Sprintf("remove LibreOffice's user profile directory: %v", err)) logger.ErrorContext(context.Background(), fmt.Sprintf("remove LibreOffice's user profile directory: %v", err))
} else { } else {
logger.Debug(fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath)) logger.DebugContext(context.Background(), fmt.Sprintf("'%s' LibreOffice's user profile directory removed", userProfileDirPath))
} }
// Also, remove LibreOffice specific files in the temporary directory. // Also, remove LibreOffice specific files in the temporary directory.
err = gotenberg.GarbageCollect(logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime) err = gotenberg.GarbageCollect(context.Background(), logger, os.TempDir(), []string{"OSL_PIPE", ".tmp"}, expirationTime)
if err != nil { if err != nil {
logger.Error(err.Error()) logger.ErrorContext(context.Background(), err.Error())
} }
}() }()
}(copyUserProfileDirPath, expirationTime) }(copyUserProfileDirPath, expirationTime)
@@ -221,7 +225,7 @@ func (p *libreOfficeProcess) Stop(logger *zap.Logger) error {
return nil return nil
} }
func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool { func (p *libreOfficeProcess) Healthy(logger *slog.Logger) bool {
// Good to know: the supervisor does not call this method if no first start // Good to know: the supervisor does not call this method if no first start
// or if the process is restarting. // or if the process is restarting.
@@ -237,7 +241,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
if err == nil { if err == nil {
err = conn.Close() err = conn.Close()
if err != nil { if err != nil {
logger.Debug(fmt.Sprintf("close connection after health checking LibreOffice: %v", err)) logger.DebugContext(context.Background(), fmt.Sprintf("close connection after health checking LibreOffice: %v", err))
} }
return true return true
@@ -246,7 +250,7 @@ func (p *libreOfficeProcess) Healthy(logger *zap.Logger) bool {
return false return false
} }
func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputPath, outputPath string, options Options) error { func (p *libreOfficeProcess) pdf(ctx context.Context, logger *slog.Logger, inputPath, outputPath string, options Options) error {
if !p.isStarted.Load() { if !p.isStarted.Load() {
return errors.New("LibreOffice not started, cannot handle PDF conversion") return errors.New("LibreOffice not started, cannot handle PDF conversion")
} }
@@ -259,8 +263,7 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--port", fmt.Sprintf("%d", p.socketPort)) args = append(args, "--port", fmt.Sprintf("%d", p.socketPort))
checkedEntry := logger.Check(zap.DebugLevel, "check for debug level before setting high verbosity") if logger.Enabled(ctx, slog.LevelDebug) {
if checkedEntry != nil {
args = append(args, "-vvv") args = append(args, "-vvv")
} }
@@ -302,38 +305,14 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution)) args = append(args, "--export", fmt.Sprintf("ReduceImageResolution=%t", options.ReduceImageResolution))
args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution)) args = append(args, "--export", fmt.Sprintf("MaxImageResolution=%d", options.MaxImageResolution))
if options.NativeWatermarkText != "" {
args = append(args, "--export", fmt.Sprintf("Watermark=%s", options.NativeWatermarkText))
}
if options.NativeWatermarkColor != 0 {
args = append(args, "--export", fmt.Sprintf("WatermarkColor=%d", options.NativeWatermarkColor))
}
if options.NativeWatermarkFontHeight > 0 {
args = append(args, "--export", fmt.Sprintf("WatermarkFontHeight=%d", options.NativeWatermarkFontHeight))
}
if options.NativeWatermarkRotateAngle != 0 {
args = append(args, "--export", fmt.Sprintf("WatermarkRotateAngle=%d", options.NativeWatermarkRotateAngle))
}
if options.NativeWatermarkFontName != "" && options.NativeWatermarkFontName != "Helvetica" {
args = append(args, "--export", fmt.Sprintf("WatermarkFontName=%s", options.NativeWatermarkFontName))
}
if options.NativeTiledWatermarkText != "" {
args = append(args, "--export", fmt.Sprintf("TiledWatermark=%s", options.NativeTiledWatermarkText))
}
switch options.PdfFormats.PdfA { switch options.PdfFormats.PdfA {
case "": case "":
case gotenberg.PdfA1b: case gotenberg.PdfA1b:
args = append(args, "--export", "SelectPdfVersion=1", "--export", "EmbedStandardFonts=true") args = append(args, "--export", "SelectPdfVersion=1")
case gotenberg.PdfA2b: case gotenberg.PdfA2b:
args = append(args, "--export", "SelectPdfVersion=2", "--export", "EmbedStandardFonts=true") args = append(args, "--export", "SelectPdfVersion=2")
case gotenberg.PdfA3b: case gotenberg.PdfA3b:
args = append(args, "--export", "SelectPdfVersion=3", "--export", "EmbedStandardFonts=true") args = append(args, "--export", "SelectPdfVersion=3")
default: default:
return ErrInvalidPdfFormats return ErrInvalidPdfFormats
} }
@@ -344,7 +323,6 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
"--export", "PDFUACompliance=true", "--export", "PDFUACompliance=true",
"--export", "UseTaggedPDF=true", "--export", "UseTaggedPDF=true",
"--export", "EnableTextAccessForAccessibilityTools=true", "--export", "EnableTextAccessForAccessibilityTools=true",
"--export", "EmbedStandardFonts=true",
) )
} else { } else {
args = append( args = append(
@@ -357,14 +335,35 @@ func (p *libreOfficeProcess) pdf(ctx context.Context, logger *zap.Logger, inputP
args = append(args, "--output", outputPath, inputPath) args = append(args, "--output", outputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, p.arguments.unoBinPath, args...) p.cfgMu.RLock()
clientCtx, clientSpan := gotenberg.Tracer().Start(ctx, "uno.execute",
trace.WithSpanKind(trace.SpanKindClient),
trace.WithAttributes(
semconv.ServerAddress("127.0.0.1"),
semconv.ServerPort(p.socketPort),
semconv.ServicePeerName("libreoffice"),
// Legacy attribute for older APMs (Datadog, Jaeger) to draw the
// dependency graph.
attribute.String("peer.service", "libreoffice"),
),
)
p.cfgMu.RUnlock()
cmd, err := gotenberg.CommandContext(clientCtx, logger, p.arguments.unoBinPath, args...)
if err != nil { if err != nil {
return fmt.Errorf("create uno command: %w", err) return fmt.Errorf("create uno command: %w", err)
} }
logger.Debug(fmt.Sprintf("print to PDF with: %+v", options)) logger.DebugContext(clientCtx, fmt.Sprintf("print to PDF with: %+v", options))
exitCode, err := cmd.Exec() exitCode, err := cmd.Exec()
if err != nil {
clientSpan.RecordError(err)
clientSpan.SetStatus(codes.Error, err.Error())
}
clientSpan.End()
if err == nil { if err == nil {
return nil return nil
} }

View File

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

View File

@@ -4,8 +4,10 @@ import (
"context" "context"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"go.uber.org/zap" "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
"github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api" "github.com/gotenberg/gotenberg/v8/pkg/modules/libreoffice/api"
@@ -47,25 +49,55 @@ func (engine *LibreOfficePdfEngine) Provision(ctx *gotenberg.Context) error {
} }
// Merge is not available in this implementation. // 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) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("merge PDFs with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// Split is not available in this implementation. // 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) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("split PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return nil, err
} }
// Flatten is not available in this implementation. // 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) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("flatten PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// Convert converts the given PDF to a specific PDF format. Currently, only the // Convert converts the given PDF to a specific PDF format. Currently, only the
// PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another // PDF/A-1b, PDF/A-2b, PDF/A-3b and PDF/UA formats are available. If another
// PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported] // PDF format is requested, it returns a [gotenberg.ErrPdfFormatNotSupported]
// error. // error.
func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
opts := api.DefaultOptions() opts := api.DefaultOptions()
opts.PdfFormats = formats opts.PdfFormats = formats
err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts) err := engine.unoApi.Pdf(ctx, logger, inputPath, outputPath, opts)
@@ -75,60 +107,73 @@ func (engine *LibreOfficePdfEngine) Convert(ctx context.Context, logger *zap.Log
} }
if errors.Is(err, api.ErrInvalidPdfFormats) { if errors.Is(err, api.ErrInvalidPdfFormats) {
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported) err = fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, gotenberg.ErrPdfFormatNotSupported)
} else {
err = fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err)
} }
return fmt.Errorf("convert PDF to '%+v' with LibreOffice: %w", formats, err) if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// ReadMetadata is not available in this implementation. // 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) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("read PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return nil, err
} }
// WriteMetadata is not available in this implementation. // 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) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
} defer span.End()
// PageCount is not available in this implementation. err := fmt.Errorf("write PDF metadata with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *LibreOfficePdfEngine) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { if err != nil {
return 0, fmt.Errorf("page count with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) span.RecordError(err)
} span.SetStatus(codes.Error, err.Error())
}
// WriteBookmarks is not available in this implementation. return err
func (engine *LibreOfficePdfEngine) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
return fmt.Errorf("write PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
}
// ReadBookmarks is not available in this implementation.
func (engine *LibreOfficePdfEngine) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
return nil, fmt.Errorf("read PDF bookmarks with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Encrypt is not available in this implementation. // Encrypt is not available in this implementation.
func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *LibreOfficePdfEngine) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("encrypt PDF using LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// EmbedFiles is not available in this implementation. // AddAttachments is not available in this implementation.
func (engine *LibreOfficePdfEngine) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { func (engine *LibreOfficePdfEngine) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
return fmt.Errorf("embed files with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "LibreOfficePdfEngine.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
} defer span.End()
// Watermark is not available in this implementation. err := fmt.Errorf("add attachments with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
func (engine *LibreOfficePdfEngine) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error { if err != nil {
return fmt.Errorf("watermark PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported) span.RecordError(err)
} span.SetStatus(codes.Error, err.Error())
}
// Stamp is not available in this implementation. return err
func (engine *LibreOfficePdfEngine) Stamp(ctx context.Context, logger *zap.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 {
return fmt.Errorf("rotate PDF with LibreOffice: %w", gotenberg.ErrPdfEngineMethodNotSupported)
} }
// Interface guards. // Interface guards.

View File

@@ -31,12 +31,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
pdfFormats := pdfengines.FormDataPdfFormats(form) pdfFormats := pdfengines.FormDataPdfFormats(form)
metadata := pdfengines.FormDataPdfMetadata(form, false) metadata := pdfengines.FormDataPdfMetadata(form, false)
userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form) userPassword, ownerPassword := pdfengines.FormDataPdfEncrypt(form)
embedPaths := pdfengines.FormDataPdfEmbeds(form) attachmentsPaths := pdfengines.FormDataPdfAttachments(form)
watermark := pdfengines.FormDataPdfWatermark(form, false)
watermarkFile := pdfengines.FormDataPdfWatermarkFile(form)
stamp := pdfengines.FormDataPdfStamp(form, false)
stampFile := pdfengines.FormDataPdfStampFile(form)
angle, rotatePages := pdfengines.FormDataPdfRotate(form, false)
zeroValuedSplitMode := gotenberg.SplitMode{} zeroValuedSplitMode := gotenberg.SplitMode{}
@@ -65,12 +60,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
quality int quality int
reduceImageResolution bool reduceImageResolution bool
maxImageResolution int maxImageResolution int
nativeWatermarkText string
nativeWatermarkColor int
nativeWatermarkFontHeight int
nativeWatermarkRotateAngle int
nativeWatermarkFontName string
nativeTiledWatermarkText string
nativePdfFormats bool nativePdfFormats bool
merge bool merge bool
flatten bool flatten bool
@@ -139,48 +128,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
maxImageResolution = intValue maxImageResolution = intValue
return nil return nil
}). }).
String("nativeWatermarkText", &nativeWatermarkText, defaultOptions.NativeWatermarkText).
Custom("nativeWatermarkColor", func(value string) error {
if value == "" {
nativeWatermarkColor = defaultOptions.NativeWatermarkColor
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
nativeWatermarkColor = intValue
return nil
}).
Custom("nativeWatermarkFontHeight", func(value string) error {
if value == "" {
nativeWatermarkFontHeight = defaultOptions.NativeWatermarkFontHeight
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
if intValue < 0 {
return errors.New("value is inferior to 0")
}
nativeWatermarkFontHeight = intValue
return nil
}).
Custom("nativeWatermarkRotateAngle", func(value string) error {
if value == "" {
nativeWatermarkRotateAngle = defaultOptions.NativeWatermarkRotateAngle
return nil
}
intValue, err := strconv.Atoi(value)
if err != nil {
return err
}
nativeWatermarkRotateAngle = intValue
return nil
}).
String("nativeWatermarkFontName", &nativeWatermarkFontName, defaultOptions.NativeWatermarkFontName).
String("nativeTiledWatermarkText", &nativeTiledWatermarkText, defaultOptions.NativeTiledWatermarkText).
Bool("nativePdfFormats", &nativePdfFormats, true). Bool("nativePdfFormats", &nativePdfFormats, true).
Bool("merge", &merge, false). Bool("merge", &merge, false).
Bool("flatten", &flatten, false). Bool("flatten", &flatten, false).
@@ -189,21 +136,6 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = pdfengines.ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
hasPostProcessing := watermark.Source != "" || stamp.Source != "" || angle != 0 ||
len(embedPaths) > 0 || len(metadata) > 0 || flatten
outputPaths := make([]string, len(inputPaths)) outputPaths := make([]string, len(inputPaths))
for i, inputPath := range inputPaths { for i, inputPath := range inputPaths {
outputPaths[i] = ctx.GeneratePath(".pdf") outputPaths[i] = ctx.GeneratePath(".pdf")
@@ -231,18 +163,11 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
Quality: quality, Quality: quality,
ReduceImageResolution: reduceImageResolution, ReduceImageResolution: reduceImageResolution,
MaxImageResolution: maxImageResolution, MaxImageResolution: maxImageResolution,
NativeWatermarkText: nativeWatermarkText,
NativeWatermarkColor: nativeWatermarkColor,
NativeWatermarkFontHeight: nativeWatermarkFontHeight,
NativeWatermarkRotateAngle: nativeWatermarkRotateAngle,
NativeWatermarkFontName: nativeWatermarkFontName,
NativeTiledWatermarkText: nativeTiledWatermarkText,
} }
if nativePdfFormats && splitMode == zeroValuedSplitMode && !hasPostProcessing { if nativePdfFormats && splitMode == zeroValuedSplitMode {
// Only natively apply given PDF formats if we're not // Only natively apply given PDF formats if we're not
// splitting the PDF later and no post-processing features // splitting the PDF later.
// are enabled (as they would degrade compliance).
options.PdfFormats = pdfFormats options.PdfFormats = pdfFormats
} }
@@ -308,32 +233,7 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
err = pdfengines.WatermarkStub(ctx, engine, watermark, outputPaths) if !nativePdfFormats || (nativePdfFormats && splitMode != zeroValuedSplitMode) {
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = pdfengines.StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = pdfengines.RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten {
err = pdfengines.FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
needsConvertStub := !nativePdfFormats ||
(nativePdfFormats && splitMode != zeroValuedSplitMode) ||
(nativePdfFormats && hasPostProcessing)
if needsConvertStub {
convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths) convertOutputPaths, err := pdfengines.ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert PDFs: %w", err) return fmt.Errorf("convert PDFs: %w", err)
@@ -353,16 +253,21 @@ func convertRoute(libreOffice libreofficeapi.Uno, engine gotenberg.PdfEngine) ap
} }
} }
// Metadata, embeds are written after Convert, as LibreOffice err = pdfengines.AddAttachmentsStub(ctx, engine, attachmentsPaths, outputPaths)
// strips them during PDF/A conversion. if err != nil {
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths) err = pdfengines.WriteMetadataStub(ctx, engine, metadata, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("write metadata: %w", err) return fmt.Errorf("write metadata: %w", err)
} }
err = pdfengines.EmbedFilesStub(ctx, engine, embedPaths, outputPaths) if flatten {
if err != nil { err = pdfengines.FlattenStub(ctx, engine, outputPaths)
return fmt.Errorf("embed files into PDFs: %w", err) if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
} }
err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) err = pdfengines.EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)

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

@@ -1,20 +1,19 @@
package pdfcpu package pdfcpu
import ( import (
"bytes"
"context" "context"
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog"
"os" "os"
"os/exec" "os/exec"
"path/filepath" "path/filepath"
"sort" "sort"
"strconv"
"strings" "strings"
"syscall" "syscall"
"go.uber.org/zap" "go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
@@ -29,16 +28,6 @@ type PdfCpu struct {
binPath string binPath string
} }
type pdfcpuBookmark struct {
Title string `json:"title"`
Page int `json:"page"`
Children []pdfcpuBookmark `json:"kids,omitempty"`
}
type pdfcpuBookmarks struct {
Bookmarks []pdfcpuBookmark `json:"bookmarks"`
}
// Descriptor returns a [PdfCpu]'s module descriptor. // Descriptor returns a [PdfCpu]'s module descriptor.
func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor { func (engine *PdfCpu) Descriptor() gotenberg.ModuleDescriptor {
return gotenberg.ModuleDescriptor{ return gotenberg.ModuleDescriptor{
@@ -96,14 +85,20 @@ func (engine *PdfCpu) Debug() map[string]any {
} }
// Merge combines multiple PDFs into a single PDF. // Merge combines multiple PDFs into a single PDF.
func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (engine *PdfCpu) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
args := make([]string, 0, 2+len(inputPaths)) args := make([]string, 0, 2+len(inputPaths))
args = append(args, "merge", outputPath) args = append(args, "merge", outputPath)
args = append(args, inputPaths...) args = append(args, inputPaths...)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { 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() _, err = cmd.Exec()
@@ -111,11 +106,19 @@ func (engine *PdfCpu) Merge(ctx context.Context, logger *zap.Logger, inputPaths
return nil return nil
} }
return fmt.Errorf("merge PDFs with pdfcpu: %w", err) err = fmt.Errorf("merge PDFs with pdfcpu: %w", err)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// Split splits a given PDF file. // Split splits a given PDF file.
func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { func (engine *PdfCpu) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var args []string var args []string
switch mode.Mode { switch mode.Mode {
@@ -129,17 +132,26 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
} }
args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath) args = append(args, "extract", "-mode", "page", "-pages", mode.Span, inputPath, outputDirPath)
default: 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...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { 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() _, err = cmd.Exec()
if err != nil { 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 var outputPaths []string
@@ -156,7 +168,10 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
return nil return nil
}) })
if err != 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)) sort.Sort(digitSuffixSort(outputPaths))
@@ -165,172 +180,72 @@ func (engine *PdfCpu) Split(ctx context.Context, logger *zap.Logger, mode gotenb
} }
// Flatten is not available in this implementation. // Flatten is not available in this implementation.
func (engine *PdfCpu) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (engine *PdfCpu) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
return fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("flatten PDF with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// Convert is not available in this implementation. // Convert is not available in this implementation.
func (engine *PdfCpu) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (engine *PdfCpu) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("convert PDF to '%+v' with pdfcpu: %w", formats, gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// ReadMetadata is not available in this implementation. // ReadMetadata is not available in this implementation.
func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { func (engine *PdfCpu) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "PdfCpu.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("read PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return nil, err
} }
// WriteMetadata is not available in this implementation. // WriteMetadata is not available in this implementation.
func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { func (engine *PdfCpu) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) _, span := gotenberg.Tracer().Start(ctx, "PdfCpu.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
err := fmt.Errorf("write PDF metadata with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
}
return err
} }
// PageCount is not available in this implementation. // AddAttachments adds attachments into a PDF. All files are attached as
func (engine *PdfCpu) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) { // file attachments without modifying the main PDF content.
return 0, fmt.Errorf("page count with pdfcpu: %w", gotenberg.ErrPdfEngineMethodNotSupported) func (engine *PdfCpu) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
} ctx, span := gotenberg.Tracer().Start(ctx, "PdfCpu.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
// ReadBookmarks reads the document outline (bookmarks) of a PDF file using pdfcpu.
func (engine *PdfCpu) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
tmpPath := fmt.Sprintf("%s.read.json", inputPath)
args := []string{"bookmarks", "export", inputPath, tmpPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return nil, fmt.Errorf("create command: %w", err)
}
defer func() {
err := os.Remove(tmpPath)
if err != nil && !os.IsNotExist(err) {
logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
}
}()
_, cmdErr := cmd.Exec()
// Check file existence and size.
info, statErr := os.Stat(tmpPath)
if cmdErr != nil {
// If the file wasn't created, or it was created but is 0 bytes,
// it means pdfcpu had no bookmarks to write.
if os.IsNotExist(statErr) || (statErr == nil && info.Size() == 0) {
return make([]gotenberg.Bookmark, 0), nil
}
// Fallback: Check the error string just in case pdfcpu failed without
// touching the file.
if strings.Contains(strings.ToLower(cmdErr.Error()), "no bookmarks") {
return make([]gotenberg.Bookmark, 0), nil
}
return nil, fmt.Errorf("read bookmarks with pdfcpu: %w", cmdErr)
}
// If cmd succeeded, but output a 0-byte file anyway.
if info != nil && info.Size() == 0 {
return make([]gotenberg.Bookmark, 0), nil
}
// Read the file content.
jsonBytes, err := os.ReadFile(tmpPath)
if err != nil {
return nil, fmt.Errorf("read temporary bookmarks JSON file: %w", err)
}
// Check if the content is just empty whitespace.
if len(bytes.TrimSpace(jsonBytes)) == 0 {
return make([]gotenberg.Bookmark, 0), nil
}
var data pdfcpuBookmarks
err = json.Unmarshal(jsonBytes, &data)
if err != nil {
return nil, fmt.Errorf("unmarshal bookmarks: %w", err)
}
// Safety check: Does the parsed JSON actually contain bookmarks?
if len(data.Bookmarks) == 0 {
return make([]gotenberg.Bookmark, 0), nil
}
var mapBookmarks func(bookmarks []pdfcpuBookmark) []gotenberg.Bookmark
mapBookmarks = func(bookmarks []pdfcpuBookmark) []gotenberg.Bookmark {
res := make([]gotenberg.Bookmark, len(bookmarks))
for i, b := range bookmarks {
res[i] = gotenberg.Bookmark{
Title: b.Title,
Page: b.Page,
Children: mapBookmarks(b.Children),
}
}
return res
}
return mapBookmarks(data.Bookmarks), nil
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using pdfcpu.
func (engine *PdfCpu) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
if len(bookmarks) == 0 {
return nil
}
var mapBookmarks func(bookmarks []gotenberg.Bookmark) []pdfcpuBookmark
mapBookmarks = func(bookmarks []gotenberg.Bookmark) []pdfcpuBookmark {
res := make([]pdfcpuBookmark, len(bookmarks))
for i, b := range bookmarks {
res[i] = pdfcpuBookmark{
Title: b.Title,
Page: b.Page,
Children: mapBookmarks(b.Children),
}
}
return res
}
data := pdfcpuBookmarks{
Bookmarks: mapBookmarks(bookmarks),
}
jsonBytes, err := json.Marshal(data)
if err != nil {
return fmt.Errorf("marshal bookmarks: %w", err)
}
tmpPath := fmt.Sprintf("%s.json", inputPath)
err = os.WriteFile(tmpPath, jsonBytes, 0o600)
if err != nil {
return fmt.Errorf("write temporary bookmarks JSON file: %w", err)
}
defer func() {
err := os.Remove(tmpPath)
if err != nil {
logger.Error(fmt.Sprintf("remove temporary bookmarks JSON file: %v", err))
}
}()
args := []string{"bookmarks", "import", "-replace", inputPath, tmpPath, inputPath}
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("write bookmarks with pdfcpu: %w", err)
}
return nil
}
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
// without modifying the main PDF content.
func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
if len(filePaths) == 0 { if len(filePaths) == 0 {
return nil return nil
} }
logger.Debug(fmt.Sprintf("embedding %d file(s) to %s: %v", len(filePaths), inputPath, filePaths)) logger.DebugContext(ctx, fmt.Sprintf("attaching %d file(s) to %s: %v", len(filePaths), inputPath, filePaths))
args := make([]string, 0, 3+len(filePaths)) args := make([]string, 0, 3+len(filePaths))
args = append(args, "attachments", "add", inputPath) args = append(args, "attachments", "add", inputPath)
@@ -338,21 +253,33 @@ func (engine *PdfCpu) EmbedFiles(ctx context.Context, logger *zap.Logger, filePa
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { 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() _, err = cmd.Exec()
if err != nil { if err != nil {
return fmt.Errorf("attach files with pdfcpu: %w", err) err = fmt.Errorf("attach files with pdfcpu: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
return nil return nil
} }
// Encrypt adds password protection to a PDF file using pdfcpu. // Encrypt adds password protection to a PDF file using pdfcpu.
func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { func (engine *PdfCpu) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "PdfCpu.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
if userPassword == "" { 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 == "" { if ownerPassword == "" {
@@ -369,84 +296,18 @@ func (engine *PdfCpu) Encrypt(ctx context.Context, logger *zap.Logger, inputPath
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...) cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil { 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() _, err = cmd.Exec()
if err != nil { 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 nil return err
}
// 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)
}
// 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)
}
// 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 {
args := []string{"rotate"}
if pages != "" {
args = append(args, "-pages", pages)
}
args = append(args, "--", inputPath, strconv.Itoa(angle), inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("rotate PDF with pdfcpu: %w", err)
}
return nil
}
func (engine *PdfCpu) applyStampOrWatermark(ctx context.Context, logger *zap.Logger, command string, inputPath string, stamp gotenberg.Stamp) error {
var mode string
switch stamp.Source {
case gotenberg.StampSourceText:
mode = "text"
case gotenberg.StampSourceImage:
mode = "image"
case gotenberg.StampSourcePDF:
mode = "pdf"
default:
return fmt.Errorf("%s PDF with pdfcpu: %w", command, gotenberg.ErrPdfStampSourceNotSupported)
}
// Build description from Options map.
var descParts []string
for k, v := range stamp.Options {
descParts = append(descParts, fmt.Sprintf("%s:%s", k, v))
}
description := strings.Join(descParts, ", ")
args := []string{command, "add", "-mode", mode}
if stamp.Pages != "" {
args = append(args, "-pages", stamp.Pages)
}
args = append(args, "--", stamp.Expression, description, inputPath, inputPath)
cmd, err := gotenberg.CommandContext(ctx, logger, engine.binPath, args...)
if err != nil {
return fmt.Errorf("create command: %w", err)
}
_, err = cmd.Exec()
if err != nil {
return fmt.Errorf("%s PDF with pdfcpu: %w", command, err)
} }
return nil return nil

View File

@@ -1,30 +1,28 @@
//nolint:dupl
package pdfengines package pdfengines
import ( import (
"context" "context"
"fmt" "fmt"
"log/slog"
"sync" "sync"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
"go.uber.org/multierr" "go.uber.org/multierr"
"go.uber.org/zap"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg" "github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
) )
type multiPdfEngines struct { type multiPdfEngines struct {
mergeEngines []gotenberg.PdfEngine mergeEngines []gotenberg.PdfEngine
splitEngines []gotenberg.PdfEngine splitEngines []gotenberg.PdfEngine
flattenEngines []gotenberg.PdfEngine flattenEngines []gotenberg.PdfEngine
convertEngines []gotenberg.PdfEngine convertEngines []gotenberg.PdfEngine
readMetadataEngines []gotenberg.PdfEngine readMetadataEngines []gotenberg.PdfEngine
writeMetadataEngines []gotenberg.PdfEngine writeMetadataEngines []gotenberg.PdfEngine
passwordEngines []gotenberg.PdfEngine passwordEngines []gotenberg.PdfEngine
embedEngines []gotenberg.PdfEngine attachmentsEngines []gotenberg.PdfEngine
readBookmarksEngines []gotenberg.PdfEngine
writeBookmarksEngines []gotenberg.PdfEngine
watermarkEngines []gotenberg.PdfEngine
stampEngines []gotenberg.PdfEngine
rotateEngines []gotenberg.PdfEngine
} }
func newMultiPdfEngines( func newMultiPdfEngines(
@@ -35,33 +33,26 @@ func newMultiPdfEngines(
readMetadataEngines, readMetadataEngines,
writeMetadataEngines, writeMetadataEngines,
passwordEngines, passwordEngines,
embedEngines, attachmentsEngines []gotenberg.PdfEngine,
readBookmarksEngines,
writeBookmarksEngines,
watermarkEngines,
stampEngines,
rotateEngines []gotenberg.PdfEngine,
) *multiPdfEngines { ) *multiPdfEngines {
return &multiPdfEngines{ return &multiPdfEngines{
mergeEngines: mergeEngines, mergeEngines: mergeEngines,
splitEngines: splitEngines, splitEngines: splitEngines,
flattenEngines: flattenEngines, flattenEngines: flattenEngines,
convertEngines: convertEngines, convertEngines: convertEngines,
readMetadataEngines: readMetadataEngines, readMetadataEngines: readMetadataEngines,
writeMetadataEngines: writeMetadataEngines, writeMetadataEngines: writeMetadataEngines,
passwordEngines: passwordEngines, passwordEngines: passwordEngines,
embedEngines: embedEngines, attachmentsEngines: attachmentsEngines,
readBookmarksEngines: readBookmarksEngines,
writeBookmarksEngines: writeBookmarksEngines,
watermarkEngines: watermarkEngines,
stampEngines: stampEngines,
rotateEngines: rotateEngines,
} }
} }
// Merge combines multiple PDF files into a single document using the first // Merge combines multiple PDF files into a single document using the first
// available engine that supports PDF merging. // available engine that supports PDF merging.
func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error { func (multi *multiPdfEngines) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Merge", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -81,7 +72,10 @@ func (multi *multiPdfEngines) Merge(ctx context.Context, logger *zap.Logger, inp
} }
} }
return fmt.Errorf("merge PDFs with multi PDF engines: %w", err) err = fmt.Errorf("merge PDFs with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
type splitResult struct { type splitResult struct {
@@ -91,7 +85,10 @@ type splitResult struct {
// Split divides the PDF into separate pages using the first available engine // Split divides the PDF into separate pages using the first available engine
// that supports PDF splitting. // that supports PDF splitting.
func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) { func (multi *multiPdfEngines) Split(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Split", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
var mu sync.Mutex // to safely append errors. var mu sync.Mutex // to safely append errors.
@@ -117,12 +114,18 @@ func (multi *multiPdfEngines) Split(ctx context.Context, logger *zap.Logger, mod
} }
} }
return nil, fmt.Errorf("split PDF with multi PDF engines: %w", err) err = fmt.Errorf("split PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
} }
// Flatten merges existing annotation appearances with page content using the // Flatten merges existing annotation appearances with page content using the
// first available engine that supports flattening. // first available engine that supports flattening.
func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error { func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Flatten", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -142,12 +145,18 @@ func (multi *multiPdfEngines) Flatten(ctx context.Context, logger *zap.Logger, i
} }
} }
return fmt.Errorf("flatten PDF with multi PDF engines: %w", err) err = fmt.Errorf("flatten PDF with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
// Convert transforms the given PDF to a specific PDF format using the first // Convert transforms the given PDF to a specific PDF format using the first
// available engine that supports PDF conversion. // available engine that supports PDF conversion.
func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error { func (multi *multiPdfEngines) Convert(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Convert", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -167,7 +176,10 @@ func (multi *multiPdfEngines) Convert(ctx context.Context, logger *zap.Logger, f
} }
} }
return fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err) err = fmt.Errorf("convert PDF to '%+v' with multi PDF engines: %w", formats, err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
type readMetadataResult struct { type readMetadataResult struct {
@@ -177,7 +189,10 @@ type readMetadataResult struct {
// ReadMetadata extracts metadata from a PDF file using the first available // ReadMetadata extracts metadata from a PDF file using the first available
// engine that supports metadata reading. // engine that supports metadata reading.
func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) { func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.ReadMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
var mu sync.Mutex // to safely append errors. var mu sync.Mutex // to safely append errors.
@@ -203,12 +218,18 @@ func (multi *multiPdfEngines) ReadMetadata(ctx context.Context, logger *zap.Logg
} }
} }
return nil, fmt.Errorf("read PDF metadata with multi PDF engines: %w", err) err = fmt.Errorf("read PDF metadata with multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return nil, err
} }
// WriteMetadata embeds metadata into a PDF file using the first available // WriteMetadata attachments metadata into a PDF file using the first available
// engine that supports metadata writing. // engine that supports metadata writing.
func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error { func (multi *multiPdfEngines) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.WriteMetadata", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -228,109 +249,18 @@ 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())
type pageCountResult struct { return err
pageCount int
err error
}
// 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) {
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readMetadataEngines {
resultChan := make(chan pageCountResult, 1)
go func(engine gotenberg.PdfEngine) {
pageCount, err := engine.PageCount(ctx, logger, inputPath)
resultChan <- pageCountResult{pageCount: pageCount, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.pageCount, nil
}
case <-ctx.Done():
return 0, ctx.Err()
}
}
return 0, fmt.Errorf("page count with multi PDF engines: %w", err)
}
type readBookmarksResult struct {
bookmarks []gotenberg.Bookmark
err error
}
// ReadBookmarks reads bookmarks from a PDF file using the first available
// engine that supports bookmarks reading.
func (multi *multiPdfEngines) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]gotenberg.Bookmark, error) {
var err error
var mu sync.Mutex // to safely append errors.
for _, engine := range multi.readBookmarksEngines {
resultChan := make(chan readBookmarksResult, 1)
go func(engine gotenberg.PdfEngine) {
bookmarks, err := engine.ReadBookmarks(ctx, logger, inputPath)
resultChan <- readBookmarksResult{bookmarks: bookmarks, err: err}
}(engine)
select {
case result := <-resultChan:
if result.err != nil {
mu.Lock()
err = multierr.Append(err, result.err)
mu.Unlock()
} else {
return result.bookmarks, nil
}
case <-ctx.Done():
return nil, ctx.Err()
}
}
return nil, fmt.Errorf("read PDF bookmarks with multi PDF engines: %w", err)
}
// WriteBookmarks adds a document outline (bookmarks) to a PDF file using the
// first available engine that supports bookmarks writing.
func (multi *multiPdfEngines) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []gotenberg.Bookmark) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.writeBookmarksEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.WriteBookmarks(ctx, logger, inputPath, bookmarks)
}(engine)
select {
case writeBookmarksErr := <-errChan:
errored := multierr.AppendInto(&err, writeBookmarksErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("write PDF bookmarks with multi PDF engines: %w", err)
} }
// Encrypt adds password protection to a PDF file using the first available // Encrypt adds password protection to a PDF file using the first available
// engine that supports password protection. // engine that supports password protection.
func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error { func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.Encrypt", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
@@ -350,23 +280,29 @@ func (multi *multiPdfEngines) Encrypt(ctx context.Context, logger *zap.Logger, i
} }
} }
return fmt.Errorf("encrypt PDF using multi PDF engines: %w", err) err = fmt.Errorf("encrypt PDF using multi PDF engines: %w", err)
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
return err
} }
// EmbedFiles embeds files into a PDF using the first available // AddAttachments adds attachments into a PDF using the first available
// engine that supports file embedding. // engine that supports file attaching.
func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error { func (multi *multiPdfEngines) AddAttachments(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
ctx, span := gotenberg.Tracer().Start(ctx, "multiPdfEngines.AddAttachments", trace.WithSpanKind(trace.SpanKindInternal))
defer span.End()
var err error var err error
errChan := make(chan error, 1) errChan := make(chan error, 1)
for _, engine := range multi.embedEngines { for _, engine := range multi.attachmentsEngines {
go func(engine gotenberg.PdfEngine) { go func(engine gotenberg.PdfEngine) {
errChan <- engine.EmbedFiles(ctx, logger, filePaths, inputPath) errChan <- engine.AddAttachments(ctx, logger, filePaths, inputPath)
}(engine) }(engine)
select { select {
case embedErr := <-errChan: case attachmentsErr := <-errChan:
errored := multierr.AppendInto(&err, embedErr) errored := multierr.AppendInto(&err, attachmentsErr)
if !errored { if !errored {
return nil return nil
} }
@@ -375,82 +311,10 @@ func (multi *multiPdfEngines) EmbedFiles(ctx context.Context, logger *zap.Logger
} }
} }
return fmt.Errorf("embed files into PDF using multi PDF engines: %w", err) err = fmt.Errorf("add attachments into PDF using multi PDF engines: %w", err)
} span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
// Watermark applies a watermark (behind page content) to a PDF file using the return err
// first available engine that supports watermarking.
func (multi *multiPdfEngines) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp gotenberg.Stamp) error {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.watermarkEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Watermark(ctx, logger, inputPath, stamp)
}(engine)
select {
case watermarkErr := <-errChan:
errored := multierr.AppendInto(&err, watermarkErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("watermark PDF with multi PDF engines: %w", 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 {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.stampEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Stamp(ctx, logger, inputPath, stamp)
}(engine)
select {
case stampErr := <-errChan:
errored := multierr.AppendInto(&err, stampErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("stamp PDF with multi PDF engines: %w", 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 {
var err error
errChan := make(chan error, 1)
for _, engine := range multi.rotateEngines {
go func(engine gotenberg.PdfEngine) {
errChan <- engine.Rotate(ctx, logger, inputPath, angle, pages)
}(engine)
select {
case rotateErr := <-errChan:
errored := multierr.AppendInto(&err, rotateErr)
if !errored {
return nil
}
case <-ctx.Done():
return ctx.Err()
}
}
return fmt.Errorf("rotate PDF with multi PDF engines: %w", err)
} }
// Interface guards. // Interface guards.

View File

@@ -0,0 +1,650 @@
package pdfengines
import (
"context"
"errors"
"log/slog"
"os"
"testing"
"github.com/gotenberg/gotenberg/v8/pkg/gotenberg"
)
func TestMultiPdfEngines_Merge(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
expectError: false,
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
mergeEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
MergeMock: func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Merge(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), nil, "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Encrypt(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
passwordEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
EncryptMock: func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Encrypt(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "", "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Split(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
splitEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
SplitMock: func(ctx context.Context, logger *slog.Logger, mode gotenberg.SplitMode, inputPath, outputDirPath string) ([]string, error) {
return nil, nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.Split(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), gotenberg.SplitMode{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Flatten(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
flattenEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
FlattenMock: func(ctx context.Context, logger *slog.Logger, inputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Flatten(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_Convert(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
convertEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ConvertMock: func(ctx context.Context, logger *slog.Logger, formats gotenberg.PdfFormats, inputPath, outputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.Convert(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), gotenberg.PdfFormats{}, "", "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_ReadMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return make(map[string]any), nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return make(map[string]any), nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return nil, errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
readMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
ReadMetadataMock: func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
return make(map[string]any), nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
_, err := tc.engine.ReadMetadata(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}
func TestMultiPdfEngines_WriteMetadata(t *testing.T) {
for _, tc := range []struct {
scenario string
engine *multiPdfEngines
ctx context.Context
expectError bool
}{
{
scenario: "nominal behavior",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "at least one engine does not return an error",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return nil
},
},
},
},
ctx: context.Background(),
},
{
scenario: "all engines return an error",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo")
},
},
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return errors.New("foo")
},
},
},
},
ctx: context.Background(),
expectError: true,
},
{
scenario: "context expired",
engine: &multiPdfEngines{
writeMetadataEngines: []gotenberg.PdfEngine{
&gotenberg.PdfEngineMock{
WriteMetadataMock: func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
return nil
},
},
},
},
ctx: func() context.Context {
ctx, cancel := context.WithCancel(context.Background())
cancel()
return ctx
}(),
expectError: true,
},
} {
t.Run(tc.scenario, func(t *testing.T) {
err := tc.engine.WriteMetadata(tc.ctx, slog.New(slog.NewJSONHandler(os.Stdout, nil)), nil, "")
if !tc.expectError && err != nil {
t.Fatalf("expected no error but got: %v", err)
}
if tc.expectError && err == nil {
t.Fatal("expected error but got none")
}
})
}
}

View File

@@ -28,21 +28,16 @@ func init() {
// the [api.Router] interface to expose relevant PDF processing routes if // the [api.Router] interface to expose relevant PDF processing routes if
// enabled. // enabled.
type PdfEngines struct { type PdfEngines struct {
mergeNames []string mergeNames []string
splitNames []string splitNames []string
flattenNames []string flattenNames []string
convertNames []string convertNames []string
readMetadataNames []string readMetadataNames []string
writeMetadataNames []string writeMetadataNames []string
encryptNames []string encryptNames []string
embedNames []string attachmentsNames []string
readBookmarksNames []string engines []gotenberg.PdfEngine
writeBookmarksNames []string disableRoutes bool
watermarkNames []string
stampNames []string
rotateNames []string
engines []gotenberg.PdfEngine
disableRoutes bool
} }
// Descriptor returns a PdfEngines' module descriptor. // Descriptor returns a PdfEngines' module descriptor.
@@ -57,22 +52,10 @@ func (mod *PdfEngines) Descriptor() gotenberg.ModuleDescriptor {
fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all") fs.StringSlice("pdfengines-convert-engines", []string{"libreoffice-pdfengine"}, "Set the PDF engines and their order for the convert feature - empty means all")
fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all") fs.StringSlice("pdfengines-read-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the read metadata feature - empty means all")
fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all") fs.StringSlice("pdfengines-write-metadata-engines", []string{"exiftool"}, "Set the PDF engines and their order for the write metadata feature - empty means all")
fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdftk", "pdfcpu"}, "Set the PDF engines and their order for the password protection feature - empty means all") fs.StringSlice("pdfengines-encrypt-engines", []string{"qpdf", "pdfcpu", "pdftk"}, "Set the PDF engines and their order for the password protection feature - empty means all")
fs.StringSlice("pdfengines-embed-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file embedding feature - empty means all") fs.StringSlice("pdfengines-add-attachments-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the file attaching feature - empty means all")
fs.StringSlice("pdfengines-read-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the read bookmarks feature - empty means all")
fs.StringSlice("pdfengines-write-bookmarks-engines", []string{"pdfcpu"}, "Set the PDF engines and their order for the write bookmarks feature - empty means all")
fs.StringSlice("pdfengines-watermark-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the watermark feature - empty means all")
fs.StringSlice("pdfengines-stamp-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the stamp feature - empty means all")
fs.StringSlice("pdfengines-rotate-engines", []string{"pdfcpu", "pdftk"}, "Set the PDF engines and their order for the rotate feature - empty means all")
fs.Bool("pdfengines-disable-routes", false, "Disable the routes") fs.Bool("pdfengines-disable-routes", false, "Disable the routes")
// Deprecated flags.
fs.StringSlice("pdfengines-engines", make([]string, 0), "Set the default PDF engines and their default order - all by default")
err := fs.MarkDeprecated("pdfengines-engines", "use other flags for a more granular selection of PDF engines per method")
if err != nil {
panic(err)
}
return fs return fs
}(), }(),
New: func() gotenberg.Module { return new(PdfEngines) }, New: func() gotenberg.Module { return new(PdfEngines) },
@@ -90,12 +73,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines") readMetadataNames := flags.MustStringSlice("pdfengines-read-metadata-engines")
writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines") writeMetadataNames := flags.MustStringSlice("pdfengines-write-metadata-engines")
encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines") encryptNames := flags.MustStringSlice("pdfengines-encrypt-engines")
embedNames := flags.MustStringSlice("pdfengines-embed-engines") attachmentsNames := flags.MustStringSlice("pdfengines-add-attachments-engines")
readBookmarksNames := flags.MustStringSlice("pdfengines-read-bookmarks-engines")
writeBookmarksNames := flags.MustStringSlice("pdfengines-write-bookmarks-engines")
watermarkNames := flags.MustStringSlice("pdfengines-watermark-engines")
stampNames := flags.MustStringSlice("pdfengines-stamp-engines")
rotateNames := flags.MustStringSlice("pdfengines-rotate-engines")
mod.disableRoutes = flags.MustBool("pdfengines-disable-routes") mod.disableRoutes = flags.MustBool("pdfengines-disable-routes")
engines, err := ctx.Modules(new(gotenberg.PdfEngine)) engines, err := ctx.Modules(new(gotenberg.PdfEngine))
@@ -117,7 +95,7 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
// Example in the case of deprecated module name. // Example in the case of deprecated module name.
//for i, name := range defaultNames { //for i, name := range defaultNames {
// if name == "unoconv-pdfengine" || name == "uno-pdfengine" { // if name == "unoconv-pdfengine" || name == "uno-pdfengine" {
// logger.Warn(fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name)) // logger.WarnContext(ctx, fmt.Sprintf("%s is deprecated; prefer libreoffice-pdfengine instead", name))
// mod.defaultNames[i] = "libreoffice-pdfengine" // mod.defaultNames[i] = "libreoffice-pdfengine"
// } // }
//} //}
@@ -157,34 +135,9 @@ func (mod *PdfEngines) Provision(ctx *gotenberg.Context) error {
mod.encryptNames = encryptNames mod.encryptNames = encryptNames
} }
mod.embedNames = defaultNames mod.attachmentsNames = defaultNames
if len(embedNames) > 0 { if len(attachmentsNames) > 0 {
mod.embedNames = embedNames mod.attachmentsNames = attachmentsNames
}
mod.readBookmarksNames = defaultNames
if len(readBookmarksNames) > 0 {
mod.readBookmarksNames = readBookmarksNames
}
mod.writeBookmarksNames = defaultNames
if len(writeBookmarksNames) > 0 {
mod.writeBookmarksNames = writeBookmarksNames
}
mod.watermarkNames = defaultNames
if len(watermarkNames) > 0 {
mod.watermarkNames = watermarkNames
}
mod.stampNames = defaultNames
if len(stampNames) > 0 {
mod.stampNames = stampNames
}
mod.rotateNames = defaultNames
if len(rotateNames) > 0 {
mod.rotateNames = rotateNames
} }
return nil return nil
@@ -235,12 +188,7 @@ func (mod *PdfEngines) Validate() error {
findNonExistingEngines(mod.readMetadataNames) findNonExistingEngines(mod.readMetadataNames)
findNonExistingEngines(mod.writeMetadataNames) findNonExistingEngines(mod.writeMetadataNames)
findNonExistingEngines(mod.encryptNames) findNonExistingEngines(mod.encryptNames)
findNonExistingEngines(mod.embedNames) findNonExistingEngines(mod.attachmentsNames)
findNonExistingEngines(mod.readBookmarksNames)
findNonExistingEngines(mod.writeBookmarksNames)
findNonExistingEngines(mod.watermarkNames)
findNonExistingEngines(mod.stampNames)
findNonExistingEngines(mod.rotateNames)
if len(nonExistingEngines) == 0 { if len(nonExistingEngines) == 0 {
return nil return nil
@@ -260,12 +208,6 @@ func (mod *PdfEngines) SystemMessages() []string {
fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")), fmt.Sprintf("read metadata engines - %s", strings.Join(mod.readMetadataNames[:], " ")),
fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")), fmt.Sprintf("write metadata engines - %s", strings.Join(mod.writeMetadataNames[:], " ")),
fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")), fmt.Sprintf("encrypt engines - %s", strings.Join(mod.encryptNames[:], " ")),
fmt.Sprintf("embed engines - %s", strings.Join(mod.embedNames[:], " ")),
fmt.Sprintf("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[:], " ")),
} }
} }
@@ -293,12 +235,7 @@ func (mod *PdfEngines) PdfEngine() (gotenberg.PdfEngine, error) {
engines(mod.readMetadataNames), engines(mod.readMetadataNames),
engines(mod.writeMetadataNames), engines(mod.writeMetadataNames),
engines(mod.encryptNames), engines(mod.encryptNames),
engines(mod.embedNames), engines(mod.attachmentsNames),
engines(mod.readBookmarksNames),
engines(mod.writeBookmarksNames),
engines(mod.watermarkNames),
engines(mod.stampNames),
engines(mod.rotateNames),
), nil ), nil
} }
@@ -322,13 +259,8 @@ func (mod *PdfEngines) Routes() ([]api.Route, error) {
convertRoute(engine), convertRoute(engine),
readMetadataRoute(engine), readMetadataRoute(engine),
writeMetadataRoute(engine), writeMetadataRoute(engine),
readBookmarksRoute(engine),
writeBookmarksRoute(engine),
encryptRoute(engine), encryptRoute(engine),
embedRoute(engine), attachmentsRoute(engine),
watermarkRoute(engine),
stampRoute(engine),
rotateRoute(engine),
}, nil }, nil
} }

View File

@@ -84,7 +84,7 @@ func FormDataPdfSplitMode(form *api.FormData, mandatory bool) gotenberg.SplitMod
} }
// FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data. // FormDataPdfFormats creates [gotenberg.PdfFormats] from the form data.
// Fallback to the default value if the considered key is not present. // Fallback to default value if the considered key is not present.
func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats { func FormDataPdfFormats(form *api.FormData) gotenberg.PdfFormats {
var ( var (
pdfa string pdfa string
@@ -128,129 +128,6 @@ func FormDataPdfMetadata(form *api.FormData, mandatory bool) map[string]any {
return metadata return metadata
} }
// FormDataPdfBookmarks creates bookmarks from the form data.
func FormDataPdfBookmarks(form *api.FormData, mandatory bool) any {
var bookmarks any
bookmarksFunc := func(value string) error {
if len(value) > 0 {
var list []gotenberg.Bookmark
err := json.Unmarshal([]byte(value), &list)
if err == nil {
bookmarks = list
return nil
}
var m map[string][]gotenberg.Bookmark
err = json.Unmarshal([]byte(value), &m)
if err == nil {
bookmarks = m
return nil
}
return fmt.Errorf("unmarshal bookmarks: %w", err)
}
return nil
}
if mandatory {
form.MandatoryCustom("bookmarks", func(value string) error {
return bookmarksFunc(value)
})
} else {
form.Custom("bookmarks", func(value string) error {
return bookmarksFunc(value)
})
}
return bookmarks
}
// FormDataPdfRotate creates rotation parameters from the form data.
func FormDataPdfRotate(form *api.FormData, mandatory bool) (int, string) {
var angle int
var pages string
angleFunc := func(value string) error {
if value == "" {
return nil
}
v, err := strconv.Atoi(value)
if err != nil {
return err
}
if v != 90 && v != 180 && v != 270 {
return errors.New("wrong value, expected 90, 180, or 270")
}
angle = v
return nil
}
if mandatory {
form.MandatoryCustom("rotateAngle", func(value string) error {
return angleFunc(value)
})
} else {
form.Custom("rotateAngle", func(value string) error {
return angleFunc(value)
})
}
form.String("rotatePages", &pages, "")
return angle, pages
}
// RotateStub rotates pages of PDF files. If angle is 0, it does nothing.
func RotateStub(ctx *api.Context, engine gotenberg.PdfEngine, angle int, pages string, inputPaths []string) error {
if angle == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Rotate(ctx, ctx.Log(), inputPath, angle, pages)
if err != nil {
return fmt.Errorf("rotate '%s': %w", inputPath, err)
}
}
return nil
}
// ValidatePdfFormatsCompat checks for incompatible combinations of PDF formats
// with other features and returns an appropriate error if found.
func ValidatePdfFormatsCompat(pdfFormats gotenberg.PdfFormats, userPassword string, embedPaths []string) error {
zeroValued := gotenberg.PdfFormats{}
if pdfFormats == zeroValued {
return nil
}
// PDF/A forbids encryption per the standard.
if pdfFormats.PdfA != "" && userPassword != "" {
return api.WrapError(
errors.New("PDF/A format is incompatible with encryption"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: PDF/A format is incompatible with encryption",
),
)
}
// Only PDF/A-3 variants allow embedded file attachments.
if pdfFormats.PdfA != "" && len(embedPaths) > 0 {
if pdfFormats.PdfA != gotenberg.PdfA3a && pdfFormats.PdfA != gotenberg.PdfA3b && pdfFormats.PdfA != gotenberg.PdfA3u {
return api.WrapError(
fmt.Errorf("PDF format '%s' does not support embedded files", pdfFormats.PdfA),
api.NewSentinelHttpError(
http.StatusBadRequest,
fmt.Sprintf("Invalid form data: PDF format '%s' does not support embedded files; only PDF/A-3 variants allow attachments", pdfFormats.PdfA),
),
)
}
}
return nil
}
// MergeStub merges given PDFs. If only one input PDF, it does nothing and // MergeStub merges given PDFs. If only one input PDF, it does nothing and
// returns the corresponding input path. // returns the corresponding input path.
func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) { func MergeStub(ctx *api.Context, engine gotenberg.PdfEngine, inputPaths []string) (string, error) {
@@ -377,64 +254,12 @@ func WriteMetadataStub(ctx *api.Context, engine gotenberg.PdfEngine, metadata ma
return nil return nil
} }
func shiftBookmarks(bookmarks []gotenberg.Bookmark, offset int) []gotenberg.Bookmark { // FormDataPdfAttachments extracts attached file paths from form data.
if offset == 0 { // Only files uploaded with the "attachments" field name are included.
return bookmarks func FormDataPdfAttachments(form *api.FormData) []string {
} var attachmentsPaths []string
shifted := make([]gotenberg.Bookmark, len(bookmarks)) form.Attachments(&attachmentsPaths)
for i, b := range bookmarks { return attachmentsPaths
shifted[i] = gotenberg.Bookmark{
Title: b.Title,
Page: b.Page + offset,
Children: shiftBookmarks(b.Children, offset),
}
}
return shifted
}
// WriteBookmarksStub writes the bookmarks into PDF files. If no bookmarks, it
// does nothing.
func WriteBookmarksStub(ctx *api.Context, engine gotenberg.PdfEngine, bookmarks any, inputPaths []string) error {
if bookmarks == nil {
return nil
}
switch b := bookmarks.(type) {
case []gotenberg.Bookmark:
if len(b) == 0 {
return nil
}
for _, inputPath := range inputPaths {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, b)
if err != nil {
return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err)
}
}
case map[string][]gotenberg.Bookmark:
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
if specificBookmarks, ok := b[filename]; ok {
err := engine.WriteBookmarks(ctx, ctx.Log(), inputPath, specificBookmarks)
if err != nil {
return fmt.Errorf("write bookmarks into '%s': %w", inputPath, err)
}
}
}
default:
// Should not happen.
return fmt.Errorf("bookmarks type '%T' not supported", bookmarks)
}
return nil
}
// FormDataPdfEmbeds extracts embedded file paths from form data.
// Only files uploaded with the "embeds" field name are included.
func FormDataPdfEmbeds(form *api.FormData) []string {
var embedPaths []string
form.Embeds(&embedPaths)
return embedPaths
} }
// FormDataPdfEncrypt extracts encryption parameters from form data. // FormDataPdfEncrypt extracts encryption parameters from form data.
@@ -460,132 +285,16 @@ func EncryptPdfStub(ctx *api.Context, engine gotenberg.PdfEngine, userPassword,
return nil return nil
} }
// EmbedFilesStub embeds files into PDF files. // AddAttachmentsStub adds attachments into PDF files.
func EmbedFilesStub(ctx *api.Context, engine gotenberg.PdfEngine, embedPaths []string, inputPaths []string) error { func AddAttachmentsStub(ctx *api.Context, engine gotenberg.PdfEngine, attachmentsPaths []string, inputPaths []string) error {
if len(embedPaths) == 0 { if len(attachmentsPaths) == 0 {
return nil return nil
} }
for _, inputPath := range inputPaths { for _, inputPath := range inputPaths {
err := engine.EmbedFiles(ctx, ctx.Log(), embedPaths, inputPath) err := engine.AddAttachments(ctx, ctx.Log(), attachmentsPaths, inputPath)
if err != nil { if err != nil {
return fmt.Errorf("embed files into PDF '%s': %w", inputPath, err) return fmt.Errorf("add attachments into PDF '%s': %w", inputPath, err)
}
}
return nil
}
// FormDataPdfWatermark creates a [gotenberg.Stamp] for watermarking from the
// form data.
func FormDataPdfWatermark(form *api.FormData, mandatory bool) gotenberg.Stamp {
return formDataPdfStampOrWatermark(form, "watermark", mandatory)
}
// FormDataPdfStamp creates a [gotenberg.Stamp] for stamping from the form data.
func FormDataPdfStamp(form *api.FormData, mandatory bool) gotenberg.Stamp {
return formDataPdfStampOrWatermark(form, "stamp", mandatory)
}
func formDataPdfStampOrWatermark(form *api.FormData, prefix string, mandatory bool) gotenberg.Stamp {
var (
source string
expression string
pages string
options map[string]string
)
sourceFunc := func(value string) error {
if value != "" && value != gotenberg.StampSourceText && value != gotenberg.StampSourceImage && value != gotenberg.StampSourcePDF {
return fmt.Errorf("wrong value, expected either '%s', '%s' or '%s'", gotenberg.StampSourceText, gotenberg.StampSourceImage, gotenberg.StampSourcePDF)
}
source = value
return nil
}
optionsFunc := func(value string) error {
if value == "" {
return nil
}
err := json.Unmarshal([]byte(value), &options)
if err != nil {
return fmt.Errorf("unmarshal %s options: %w", prefix, err)
}
return nil
}
if mandatory {
form.
MandatoryCustom(prefix+"Source", func(value string) error {
return sourceFunc(value)
}).
String(prefix+"Expression", &expression, "").
String(prefix+"Pages", &pages, "").
Custom(prefix+"Options", func(value string) error {
return optionsFunc(value)
})
} else {
form.
Custom(prefix+"Source", func(value string) error {
return sourceFunc(value)
}).
String(prefix+"Expression", &expression, "").
String(prefix+"Pages", &pages, "").
Custom(prefix+"Options", func(value string) error {
return optionsFunc(value)
})
}
return gotenberg.Stamp{
Source: source,
Expression: expression,
Pages: pages,
Options: options,
}
}
// FormDataPdfWatermarkFile extracts the watermark file path from form data.
func FormDataPdfWatermarkFile(form *api.FormData) string {
var path string
form.Watermark(&path)
return path
}
// FormDataPdfStampFile extracts the stamp file path from form data.
func FormDataPdfStampFile(form *api.FormData) string {
var path string
form.Stamp(&path)
return path
}
// WatermarkStub applies a watermark to a list of PDF files. If the stamp has
// no source, it does nothing.
func WatermarkStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
if stamp.Source == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Watermark(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("watermark '%s': %w", inputPath, err)
}
}
return nil
}
// StampStub applies a stamp to a list of PDF files. If the stamp has
// no source, it does nothing.
func StampStub(ctx *api.Context, engine gotenberg.PdfEngine, stamp gotenberg.Stamp, inputPaths []string) error {
if stamp.Source == "" {
return nil
}
for _, inputPath := range inputPaths {
err := engine.Stamp(ctx, ctx.Log(), inputPath, stamp)
if err != nil {
return fmt.Errorf("stamp '%s': %w", inputPath, err)
} }
} }
@@ -604,118 +313,33 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
form := ctx.FormData() form := ctx.FormData()
pdfFormats := FormDataPdfFormats(form) pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
bookmarks := FormDataPdfBookmarks(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form) userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form) attachmentsPaths := FormDataPdfAttachments(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
var inputPaths []string var inputPaths []string
var flatten bool var flatten bool
var autoIndexBookmarks bool
err := form. err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths). MandatoryPaths([]string{".pdf"}, &inputPaths).
Bool("flatten", &flatten, false). Bool("flatten", &flatten, false).
Bool("autoIndexBookmarks", &autoIndexBookmarks, false).
Validate() Validate()
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPath := ctx.GeneratePath(".pdf") outputPath := ctx.GeneratePath(".pdf")
err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath) err = engine.Merge(ctx, ctx.Log(), inputPaths, outputPath)
if err != nil { if err != nil {
return fmt.Errorf("merge PDFs: %w", err) return fmt.Errorf("merge PDFs: %w", err)
} }
outputPaths := []string{outputPath} outputPaths, err := ConvertStub(ctx, engine, pdfFormats, []string{outputPath})
err = WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
outputPaths, err = ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert PDF: %w", err) return fmt.Errorf("convert PDF: %w", err)
} }
// Bookmarks, metadata, and embeds are written after Convert, err = AddAttachmentsStub(ctx, engine, attachmentsPaths, outputPaths)
// as LibreOffice strips them during PDF/A conversion. if err != nil {
return fmt.Errorf("add attachments into PDFs: %w", err)
var finalBookmarks []gotenberg.Bookmark
if b, ok := bookmarks.([]gotenberg.Bookmark); ok {
finalBookmarks = b
} else {
bMap, _ := bookmarks.(map[string][]gotenberg.Bookmark)
if bMap != nil || autoIndexBookmarks {
offset := 0
for _, inputPath := range inputPaths {
filename := filepath.Base(inputPath)
var fileBookmarks []gotenberg.Bookmark
if bMap != nil {
fileBookmarks = bMap[filename]
}
if len(fileBookmarks) == 0 && autoIndexBookmarks {
fb, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("read bookmarks of '%s': %w", filename, err)
}
fileBookmarks = fb
}
if len(fileBookmarks) > 0 {
finalBookmarks = append(finalBookmarks, shiftBookmarks(fileBookmarks, offset)...)
}
pageCount, err := engine.PageCount(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("get page count of '%s': %w", filename, err)
}
offset += pageCount
}
}
}
if len(finalBookmarks) > 0 {
err = WriteBookmarksStub(ctx, engine, finalBookmarks, outputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
} }
err = WriteMetadataStub(ctx, engine, metadata, outputPaths) err = WriteMetadataStub(ctx, engine, metadata, outputPaths)
@@ -723,9 +347,11 @@ func mergeRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("write metadata: %w", err) return fmt.Errorf("write metadata: %w", err)
} }
err = EmbedFilesStub(ctx, engine, embedPaths, outputPaths) if flatten {
if err != nil { err = FlattenStub(ctx, engine, outputPaths)
return fmt.Errorf("embed files into PDFs: %w", err) if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
} }
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths) err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, outputPaths)
@@ -757,12 +383,7 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
pdfFormats := FormDataPdfFormats(form) pdfFormats := FormDataPdfFormats(form)
metadata := FormDataPdfMetadata(form, false) metadata := FormDataPdfMetadata(form, false)
userPassword, ownerPassword := FormDataPdfEncrypt(form) userPassword, ownerPassword := FormDataPdfEncrypt(form)
embedPaths := FormDataPdfEmbeds(form) attachmentsPaths := FormDataPdfAttachments(form)
watermark := FormDataPdfWatermark(form, false)
watermarkFile := FormDataPdfWatermarkFile(form)
stamp := FormDataPdfStamp(form, false)
stampFile := FormDataPdfStampFile(form)
angle, rotatePages := FormDataPdfRotate(form, false)
var inputPaths []string var inputPaths []string
var flatten bool var flatten bool
@@ -774,60 +395,31 @@ func splitRoute(engine gotenberg.PdfEngine) api.Route {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
if (watermark.Source == gotenberg.StampSourceImage || watermark.Source == gotenberg.StampSourcePDF) && watermarkFile != "" {
watermark.Expression = watermarkFile
}
if (stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF) && stampFile != "" {
stamp.Expression = stampFile
}
err = ValidatePdfFormatsCompat(pdfFormats, userPassword, embedPaths)
if err != nil {
return err
}
outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths) outputPaths, err := SplitPdfStub(ctx, engine, mode, inputPaths)
if err != nil { if err != nil {
return fmt.Errorf("split PDFs: %w", err) return fmt.Errorf("split PDFs: %w", err)
} }
err = WatermarkStub(ctx, engine, watermark, outputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = StampStub(ctx, engine, stamp, outputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = RotateStub(ctx, engine, angle, rotatePages, outputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
}
if flatten {
err = FlattenStub(ctx, engine, outputPaths)
if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
}
convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths) convertOutputPaths, err := ConvertStub(ctx, engine, pdfFormats, outputPaths)
if err != nil { if err != nil {
return fmt.Errorf("convert PDFs: %w", err) return fmt.Errorf("convert PDFs: %w", err)
} }
// Metadata, embeds are written after Convert, as LibreOffice err = AddAttachmentsStub(ctx, engine, attachmentsPaths, convertOutputPaths)
// strips them during PDF/A conversion. if err != nil {
return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths) err = WriteMetadataStub(ctx, engine, metadata, convertOutputPaths)
if err != nil { if err != nil {
return fmt.Errorf("write metadata: %w", err) return fmt.Errorf("write metadata: %w", err)
} }
err = EmbedFilesStub(ctx, engine, embedPaths, convertOutputPaths) if flatten {
if err != nil { err = FlattenStub(ctx, engine, convertOutputPaths)
return fmt.Errorf("embed files into PDFs: %w", err) if err != nil {
return fmt.Errorf("flatten PDFs: %w", err)
}
} }
err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths) err = EncryptPdfStub(ctx, engine, userPassword, ownerPassword, convertOutputPaths)
@@ -1027,83 +619,6 @@ func writeMetadataRoute(engine gotenberg.PdfEngine) api.Route {
} }
} }
// readBookmarksRoute returns an [api.Route] which returns the bookmarks of PDFs.
func readBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/read",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
var inputPaths []string
err := ctx.FormData().
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
res := make(map[string][]gotenberg.Bookmark, len(inputPaths))
for _, inputPath := range inputPaths {
bookmarks, err := engine.ReadBookmarks(ctx, ctx.Log(), inputPath)
if err != nil {
return fmt.Errorf("read bookmarks: %w", err)
}
res[filepath.Base(inputPath)] = bookmarks
}
err = c.JSON(http.StatusOK, res)
if err != nil {
if strings.Contains(err.Error(), "request method or response status code does not allow body") {
// High probability that the user is using the webhook
// feature. It does not make sense for this route.
return api.ErrNoOutputFile
}
return fmt.Errorf("return JSON response: %w", err)
}
return api.ErrNoOutputFile
},
}
}
// writeBookmarksRoute returns an [api.Route] which can write bookmarks into PDFs.
func writeBookmarksRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/bookmarks/write",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
bookmarks := FormDataPdfBookmarks(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = WriteBookmarksStub(ctx, engine, bookmarks, inputPaths)
if err != nil {
return fmt.Errorf("write bookmarks: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// encryptRoute returns an [api.Route] which can add password protection to PDFs. // encryptRoute returns an [api.Route] which can add password protection to PDFs.
func encryptRoute(engine gotenberg.PdfEngine) api.Route { func encryptRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
@@ -1142,18 +657,17 @@ func encryptRoute(engine gotenberg.PdfEngine) api.Route {
} }
} }
// embedRoute returns an [api.Route] which can add embedded files to PDFs. // attachmentsRoute returns an [api.Route] which can add attached files to PDFs.
// TODO: attachments instead? func attachmentsRoute(engine gotenberg.PdfEngine) api.Route {
func embedRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{ return api.Route{
Method: http.MethodPost, Method: http.MethodPost,
Path: "/forms/pdfengines/embed", Path: "/forms/pdfengines/attachments/add",
IsMultipart: true, IsMultipart: true,
Handler: func(c echo.Context) error { Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context) ctx := c.Get("context").(*api.Context)
form := ctx.FormData() form := ctx.FormData()
embedPaths := FormDataPdfEmbeds(form) attachmentsPaths := FormDataPdfAttachments(form)
var inputPaths []string var inputPaths []string
err := form. err := form.
@@ -1162,146 +676,10 @@ func embedRoute(engine gotenberg.PdfEngine) api.Route {
if err != nil { if err != nil {
return fmt.Errorf("validate form data: %w", err) return fmt.Errorf("validate form data: %w", err)
} }
err = EmbedFilesStub(ctx, engine, embedPaths, inputPaths)
err = AddAttachmentsStub(ctx, engine, attachmentsPaths, inputPaths)
if err != nil { if err != nil {
return fmt.Errorf("embed files into PDFs: %w", err) return fmt.Errorf("add attachments into PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// watermarkRoute returns an [api.Route] which can add watermarks to PDFs.
//
//nolint:dupl
func watermarkRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/watermark",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
stamp := FormDataPdfWatermark(form, true)
watermarkFile := FormDataPdfWatermarkFile(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if watermarkFile == "" {
return api.WrapError(
errors.New("no watermark file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a watermark file is required for image or pdf source",
),
)
}
stamp.Expression = watermarkFile
}
err = WatermarkStub(ctx, engine, stamp, inputPaths)
if err != nil {
return fmt.Errorf("watermark PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// stampRoute returns an [api.Route] which can add stamps to PDFs.
//
//nolint:dupl
func stampRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/stamp",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
stamp := FormDataPdfStamp(form, true)
stampFile := FormDataPdfStampFile(form)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
if stamp.Source == gotenberg.StampSourceImage || stamp.Source == gotenberg.StampSourcePDF {
if stampFile == "" {
return api.WrapError(
errors.New("no stamp file provided"),
api.NewSentinelHttpError(
http.StatusBadRequest,
"Invalid form data: a stamp file is required for image or pdf source",
),
)
}
stamp.Expression = stampFile
}
err = StampStub(ctx, engine, stamp, inputPaths)
if err != nil {
return fmt.Errorf("stamp PDFs: %w", err)
}
err = ctx.AddOutputPaths(inputPaths...)
if err != nil {
return fmt.Errorf("add output paths: %w", err)
}
return nil
},
}
}
// rotateRoute returns an [api.Route] which can rotate pages of PDFs.
func rotateRoute(engine gotenberg.PdfEngine) api.Route {
return api.Route{
Method: http.MethodPost,
Path: "/forms/pdfengines/rotate",
IsMultipart: true,
Handler: func(c echo.Context) error {
ctx := c.Get("context").(*api.Context)
form := ctx.FormData()
angle, pages := FormDataPdfRotate(form, true)
var inputPaths []string
err := form.
MandatoryPaths([]string{".pdf"}, &inputPaths).
Validate()
if err != nil {
return fmt.Errorf("validate form data: %w", err)
}
err = RotateStub(ctx, engine, angle, pages, inputPaths)
if err != nil {
return fmt.Errorf("rotate PDFs: %w", err)
} }
err = ctx.AddOutputPaths(inputPaths...) err = ctx.AddOutputPaths(inputPaths...)

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