mirror of
https://github.com/gotenberg/gotenberg.git
synced 2026-10-15 00:33:19 +01:00
feat(otel): add OpenTelemetry support
This commit is contained in:
@@ -6,17 +6,16 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"strings"
|
||||
"syscall"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// Cmd wraps an [exec.Cmd].
|
||||
type Cmd struct {
|
||||
ctx context.Context
|
||||
logger *zap.Logger
|
||||
logger *slog.Logger
|
||||
process *exec.Cmd
|
||||
}
|
||||
|
||||
@@ -25,13 +24,13 @@ type Cmd struct {
|
||||
// children without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
|
||||
func Command(logger *slog.Logger, binPath string, args ...string) *Cmd {
|
||||
cmd := exec.Command(binPath, args...)
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
|
||||
return &Cmd{
|
||||
ctx: nil,
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
|
||||
process: cmd,
|
||||
}
|
||||
}
|
||||
@@ -41,7 +40,7 @@ func Command(logger *zap.Logger, binPath string, args ...string) *Cmd {
|
||||
// children without creating orphans.
|
||||
//
|
||||
// See https://medium.com/@felixge/killing-a-child-process-and-all-of-its-children-in-go-54079af94773.
|
||||
func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, args ...string) (*Cmd, error) {
|
||||
func CommandContext(ctx context.Context, logger *slog.Logger, binPath string, args ...string) (*Cmd, error) {
|
||||
if ctx == nil {
|
||||
return nil, errors.New("nil context")
|
||||
}
|
||||
@@ -51,7 +50,7 @@ func CommandContext(ctx context.Context, logger *zap.Logger, binPath string, arg
|
||||
|
||||
return &Cmd{
|
||||
ctx: ctx,
|
||||
logger: logger.Named(strings.ReplaceAll(binPath, "/", "")),
|
||||
logger: logger.With(slog.String("logger", strings.ReplaceAll(binPath, "/", ""))),
|
||||
process: cmd,
|
||||
}, nil
|
||||
}
|
||||
@@ -63,7 +62,7 @@ func (cmd *Cmd) Start() error {
|
||||
return fmt.Errorf("pipe unix process output: %w", err)
|
||||
}
|
||||
|
||||
cmd.logger.Debug(fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
||||
cmd.logger.DebugContext(context.Background(), fmt.Sprintf("start unix process: %s", strings.Join(cmd.process.Args, " ")))
|
||||
|
||||
err = cmd.process.Start()
|
||||
if err != nil {
|
||||
@@ -110,7 +109,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
case err = <-errChan:
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.Error(errProc.Error())
|
||||
cmd.logger.ErrorContext(context.Background(), errProc.Error())
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
@@ -125,7 +124,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
case <-cmd.ctx.Done():
|
||||
errProc := cmd.Kill()
|
||||
if errProc != nil {
|
||||
cmd.logger.Error(errProc.Error())
|
||||
cmd.logger.ErrorContext(context.Background(), errProc.Error())
|
||||
}
|
||||
|
||||
return 62, fmt.Errorf("context done: %w", cmd.ctx.Err())
|
||||
@@ -135,8 +134,7 @@ func (cmd *Cmd) Exec() (int, error) {
|
||||
// pipeOutput creates logs entries according to the process stdout and stderr.
|
||||
// It does nothing if the logging level is not debug.
|
||||
func (cmd *Cmd) pipeOutput() error {
|
||||
checkedEntry := cmd.logger.Check(zap.DebugLevel, "check for debug level before piping unix process output")
|
||||
if checkedEntry == nil {
|
||||
if !cmd.logger.Enabled(context.Background(), slog.LevelDebug) {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -152,12 +150,12 @@ func (cmd *Cmd) pipeOutput() error {
|
||||
|
||||
// logCommandOutput creates logs entries according to a reader
|
||||
// (either stdout or stderr).
|
||||
logCommandOutput := func(logger *zap.Logger, reader io.ReadCloser) {
|
||||
logCommandOutput := func(logger *slog.Logger, reader io.ReadCloser) {
|
||||
r := bufio.NewReader(reader)
|
||||
defer func(reader io.ReadCloser) {
|
||||
err := reader.Close()
|
||||
if err != nil && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("close reader: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("close reader: %s", err))
|
||||
}
|
||||
}(reader)
|
||||
|
||||
@@ -165,20 +163,20 @@ func (cmd *Cmd) pipeOutput() error {
|
||||
line, _, err := r.ReadLine()
|
||||
if err != nil {
|
||||
if err != io.EOF && !strings.Contains(err.Error(), "file already closed") {
|
||||
logger.Error(fmt.Sprintf("pipe unix process output error: %s", err))
|
||||
logger.ErrorContext(context.Background(), fmt.Sprintf("pipe unix process output error: %s", err))
|
||||
}
|
||||
|
||||
break
|
||||
}
|
||||
|
||||
if len(line) != 0 {
|
||||
logger.Debug(string(line))
|
||||
logger.DebugContext(context.Background(), string(line))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
go logCommandOutput(cmd.logger.Named("stdout"), stdout)
|
||||
go logCommandOutput(cmd.logger.Named("stderr"), stderr)
|
||||
go logCommandOutput(cmd.logger.With(slog.String("logger", "stdout")), stdout)
|
||||
go logCommandOutput(cmd.logger.With(slog.String("logger", "stderr")), stderr)
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -196,13 +194,13 @@ func (cmd *Cmd) Kill() error {
|
||||
|
||||
err := syscall.Kill(-cmd.process.Process.Pid, syscall.SIGKILL)
|
||||
if err == nil {
|
||||
cmd.logger.Debug("unix process killed")
|
||||
cmd.logger.DebugContext(context.Background(), "unix process killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the process does not exist anymore, the error is irrelevant.
|
||||
if strings.Contains(err.Error(), "no such process") {
|
||||
cmd.logger.Debug("unix process already killed")
|
||||
cmd.logger.DebugContext(context.Background(), "unix process already killed")
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -1,19 +1,19 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// GarbageCollect scans the root path and deletes files or directories with
|
||||
// names containing specific substrings and before a given expiration time.
|
||||
func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
|
||||
logger = logger.Named("gc")
|
||||
func GarbageCollect(ctx context.Context, logger *slog.Logger, rootPath string, includeSubstr []string, expirationTime time.Time) error {
|
||||
logger = logger.With(slog.String("logger", "gc"))
|
||||
|
||||
// To make sure that the next Walk method stays on
|
||||
// the root level of the considered path, we have to
|
||||
@@ -38,12 +38,12 @@ func GarbageCollect(logger *zap.Logger, rootPath string, includeSubstr []string,
|
||||
|
||||
for _, substr := range includeSubstr {
|
||||
if (strings.Contains(info.Name(), substr) || path == substr) && info.ModTime().Before(expirationTime) {
|
||||
err := os.RemoveAll(path)
|
||||
err := os.RemoveAll(path) //nolint:gosec // G122: rootPath is a trusted internal working directory
|
||||
if err != nil {
|
||||
return fmt.Errorf("garbage collect '%s': %w", path, err)
|
||||
}
|
||||
|
||||
logger.Debug(fmt.Sprintf("'%s' removed", path))
|
||||
logger.DebugContext(ctx, fmt.Sprintf("'%s' removed", path))
|
||||
|
||||
return skipDirOrNil(info)
|
||||
}
|
||||
|
||||
@@ -1,14 +1,15 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestGarbageCollect(t *testing.T) {
|
||||
@@ -66,7 +67,7 @@ func TestGarbageCollect(t *testing.T) {
|
||||
}
|
||||
}()
|
||||
|
||||
err := GarbageCollect(zap.NewNop(), tc.rootPath, tc.includeSubstr, time.Now())
|
||||
err := GarbageCollect(context.Background(), slog.New(slog.DiscardHandler), tc.rootPath, tc.includeSubstr, time.Now())
|
||||
|
||||
if !tc.expectError && err != nil {
|
||||
t.Fatalf("expected no error but got: %v", err)
|
||||
|
||||
40
pkg/gotenberg/internal/log/color.go
Normal file
40
pkg/gotenberg/internal/log/color.go
Normal file
@@ -0,0 +1,40 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
// Foreground colors.
|
||||
// Copy pasted from go.uber.org/zap/internal/color/color.go
|
||||
const (
|
||||
black color = iota + 30
|
||||
red
|
||||
green
|
||||
yellow
|
||||
blue
|
||||
magenta
|
||||
cyan
|
||||
white
|
||||
)
|
||||
|
||||
type color uint8
|
||||
|
||||
func (c color) Add(s string) string {
|
||||
return fmt.Sprintf("\x1b[%dm%s\x1b[0m", uint8(c), s)
|
||||
}
|
||||
|
||||
func levelToColor(l slog.Level) color {
|
||||
switch l {
|
||||
case slog.LevelDebug:
|
||||
return cyan
|
||||
case slog.LevelInfo:
|
||||
return blue
|
||||
case slog.LevelWarn:
|
||||
return yellow
|
||||
case slog.LevelError:
|
||||
return red
|
||||
default:
|
||||
return red
|
||||
}
|
||||
}
|
||||
2
pkg/gotenberg/internal/log/doc.go
Normal file
2
pkg/gotenberg/internal/log/doc.go
Normal file
@@ -0,0 +1,2 @@
|
||||
// Package log gathers internal logging utilities.
|
||||
package log
|
||||
18
pkg/gotenberg/internal/log/gcp.go
Normal file
18
pkg/gotenberg/internal/log/gcp.go
Normal 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"
|
||||
}
|
||||
}
|
||||
171
pkg/gotenberg/internal/log/handler.go
Normal file
171
pkg/gotenberg/internal/log/handler.go
Normal 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}
|
||||
}
|
||||
37
pkg/gotenberg/internal/log/log.go
Normal file
37
pkg/gotenberg/internal/log/log.go
Normal 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
|
||||
)
|
||||
128
pkg/gotenberg/internal/log/stdhandler.go
Normal file
128
pkg/gotenberg/internal/log/stdhandler.go
Normal file
@@ -0,0 +1,128 @@
|
||||
package log
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"go.opentelemetry.io/otel/trace"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
const (
|
||||
autoLoggingFormat = "auto"
|
||||
jsonLoggingFormat = "json"
|
||||
textLoggingFormat = "text"
|
||||
)
|
||||
|
||||
// traceContextHandler is an internal wrapper that specifically injects
|
||||
// trace_id and span_id into the log record before they are written to the standard output.
|
||||
type traceContextHandler struct {
|
||||
slog.Handler
|
||||
}
|
||||
|
||||
func (h traceContextHandler) Handle(ctx context.Context, r slog.Record) error {
|
||||
if spanCtx := trace.SpanContextFromContext(ctx); spanCtx.IsValid() {
|
||||
// Since slog.Record is immutable with regards to adding attributes in-place,
|
||||
// we must clone and add them.
|
||||
newR := slog.NewRecord(r.Time, r.Level, r.Message, r.PC)
|
||||
r.Attrs(func(a slog.Attr) bool {
|
||||
newR.AddAttrs(a)
|
||||
return true
|
||||
})
|
||||
newR.AddAttrs(
|
||||
slog.String("trace_id", spanCtx.TraceID().String()),
|
||||
slog.String("span_id", spanCtx.SpanID().String()),
|
||||
)
|
||||
r = newR
|
||||
}
|
||||
|
||||
return h.Handler.Handle(ctx, r)
|
||||
}
|
||||
|
||||
func (h traceContextHandler) WithAttrs(attrs []slog.Attr) slog.Handler {
|
||||
return traceContextHandler{Handler: h.Handler.WithAttrs(attrs)}
|
||||
}
|
||||
|
||||
func (h traceContextHandler) WithGroup(name string) slog.Handler {
|
||||
return traceContextHandler{Handler: h.Handler.WithGroup(name)}
|
||||
}
|
||||
|
||||
// NewStdHandler returns a [slog.Handler] instance for the standard output.
|
||||
func NewStdHandler(level slog.Level, format string, fieldsPrefix string, enableGcpFields bool) (slog.Handler, error) {
|
||||
// #nosec: G115
|
||||
isTerminal := term.IsTerminal(int(os.Stdout.Fd()))
|
||||
|
||||
// Normalize the log format based on the output device.
|
||||
if format == autoLoggingFormat {
|
||||
if isTerminal {
|
||||
format = textLoggingFormat
|
||||
} else {
|
||||
format = jsonLoggingFormat
|
||||
}
|
||||
}
|
||||
|
||||
opts := &slog.HandlerOptions{
|
||||
Level: level,
|
||||
}
|
||||
|
||||
opts.ReplaceAttr = func(groups []string, a slog.Attr) slog.Attr {
|
||||
// Configure level encoding based on format and GCP settings.
|
||||
if a.Key == slog.LevelKey {
|
||||
l := a.Value.Any().(slog.Level)
|
||||
switch {
|
||||
case format == textLoggingFormat && isTerminal:
|
||||
if enableGcpFields {
|
||||
a.Value = slog.StringValue(gcpSeverityColorEncoder(l))
|
||||
} else {
|
||||
a.Value = slog.StringValue(levelToColor(l).Add(l.String()))
|
||||
}
|
||||
case enableGcpFields && format != textLoggingFormat:
|
||||
a.Key = "severity"
|
||||
a.Value = slog.StringValue(gcpSeverity(l))
|
||||
default:
|
||||
a.Value = slog.StringValue(strings.ToLower(l.String()))
|
||||
}
|
||||
}
|
||||
|
||||
if a.Key == slog.TimeKey {
|
||||
if enableGcpFields && format != textLoggingFormat {
|
||||
a.Key = "time"
|
||||
} else {
|
||||
a.Key = "ts"
|
||||
}
|
||||
|
||||
if isTerminal {
|
||||
a.Value = slog.StringValue(a.Value.Time().Local().Format("2006/01/02 15:04:05.000"))
|
||||
} else if !enableGcpFields {
|
||||
a.Value = slog.Float64Value(float64(a.Value.Time().UnixNano()) / 1e9)
|
||||
}
|
||||
}
|
||||
|
||||
if a.Key == slog.MessageKey {
|
||||
if enableGcpFields && format != textLoggingFormat {
|
||||
a.Key = "message"
|
||||
} else {
|
||||
a.Key = "msg"
|
||||
}
|
||||
}
|
||||
|
||||
return a
|
||||
}
|
||||
|
||||
var handler slog.Handler
|
||||
if format == textLoggingFormat {
|
||||
handler = slog.NewTextHandler(os.Stderr, opts)
|
||||
} else {
|
||||
handler = slog.NewJSONHandler(os.Stderr, opts)
|
||||
}
|
||||
|
||||
return traceContextHandler{Handler: handler}, nil
|
||||
}
|
||||
|
||||
func gcpSeverityColorEncoder(l slog.Level) string {
|
||||
severity := gcpSeverity(l)
|
||||
c := levelToColor(l)
|
||||
return c.Add(severity)
|
||||
}
|
||||
8
pkg/gotenberg/internal/otel/doc.go
Normal file
8
pkg/gotenberg/internal/otel/doc.go
Normal 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
|
||||
166
pkg/gotenberg/internal/otel/otel.go
Normal file
166
pkg/gotenberg/internal/otel/otel.go
Normal 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
|
||||
@@ -1,57 +1 @@
|
||||
package gotenberg
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/hashicorp/go-retryablehttp"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// LoggerProvider is an interface for a module that supplies a method for
|
||||
// creating a [zap.Logger] instance for use by other modules.
|
||||
//
|
||||
// func (m *YourModule) Provision(ctx *gotenberg.Context) error {
|
||||
// provider, _ := ctx.Module(new(gotenberg.LoggerProvider))
|
||||
// logger, _ := provider.(gotenberg.LoggerProvider).Logger(m)
|
||||
// }
|
||||
type LoggerProvider interface {
|
||||
Logger(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
// LeveledLogger is a wrapper around a [zap.Logger] so that it may be used by a
|
||||
// [retryablehttp.Client].
|
||||
type LeveledLogger struct {
|
||||
logger *zap.Logger
|
||||
}
|
||||
|
||||
// NewLeveledLogger instantiates a [LeveledLogger].
|
||||
func NewLeveledLogger(logger *zap.Logger) *LeveledLogger {
|
||||
return &LeveledLogger{
|
||||
logger: logger,
|
||||
}
|
||||
}
|
||||
|
||||
// Error logs a message at the error level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Error(msg string, keysAndValues ...any) {
|
||||
leveled.logger.Error(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Warn logs a message at the warning level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Warn(msg string, keysAndValues ...any) {
|
||||
leveled.logger.Warn(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Info logs a message at the info level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Info(msg string, keysAndValues ...any) {
|
||||
leveled.logger.Info(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Debug logs a message at the debug level using the wrapped zap.Logger.
|
||||
func (leveled LeveledLogger) Debug(msg string, keysAndValues ...any) {
|
||||
leveled.logger.Debug(fmt.Sprintf("%s: %+v", msg, keysAndValues))
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ retryablehttp.LeveledLogger = (*LeveledLogger)(nil)
|
||||
)
|
||||
|
||||
@@ -2,9 +2,8 @@ package gotenberg
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"os"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ModuleMock is a mock for the [Module] interface.
|
||||
@@ -46,75 +45,75 @@ func (mod *DebuggableMock) Debug() map[string]any {
|
||||
//
|
||||
//nolint:dupl
|
||||
type PdfEngineMock struct {
|
||||
MergeMock func(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *zap.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
|
||||
MergeMock func(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
SplitMock func(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
FlattenMock func(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
ConvertMock func(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
ReadMetadataMock func(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
PageCountMock func(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
WriteMetadataMock func(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
ReadBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
EncryptMock func(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
EmbedFilesMock func(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
WriteBookmarksMock func(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WatermarkMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
StampMock func(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
RotateMock func(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error {
|
||||
func (engine *PdfEngineMock) Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error {
|
||||
return engine.MergeMock(ctx, logger, inputPaths, outputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
func (engine *PdfEngineMock) Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error) {
|
||||
return engine.SplitMock(ctx, logger, mode, inputPath, outputDirPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error {
|
||||
func (engine *PdfEngineMock) Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error {
|
||||
return engine.FlattenMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
func (engine *PdfEngineMock) Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error {
|
||||
return engine.ConvertMock(ctx, logger, formats, inputPath, outputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error) {
|
||||
func (engine *PdfEngineMock) ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error) {
|
||||
return engine.ReadMetadataMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error) {
|
||||
func (engine *PdfEngineMock) PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error) {
|
||||
return engine.PageCountMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error {
|
||||
func (engine *PdfEngineMock) WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error {
|
||||
return engine.WriteMetadataMock(ctx, logger, metadata, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error) {
|
||||
func (engine *PdfEngineMock) ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error) {
|
||||
return engine.ReadBookmarksMock(ctx, logger, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
func (engine *PdfEngineMock) Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error {
|
||||
return engine.EncryptMock(ctx, logger, inputPath, userPassword, ownerPassword)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error {
|
||||
func (engine *PdfEngineMock) EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error {
|
||||
return engine.EmbedFilesMock(ctx, logger, filePaths, inputPath)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error {
|
||||
func (engine *PdfEngineMock) WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error {
|
||||
return engine.WriteBookmarksMock(ctx, logger, inputPath, bookmarks)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error {
|
||||
func (engine *PdfEngineMock) Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
|
||||
return engine.WatermarkMock(ctx, logger, inputPath, stamp)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error {
|
||||
func (engine *PdfEngineMock) Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error {
|
||||
return engine.StampMock(ctx, logger, inputPath, stamp)
|
||||
}
|
||||
|
||||
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error {
|
||||
func (engine *PdfEngineMock) Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error {
|
||||
return engine.RotateMock(ctx, logger, inputPath, angle, pages)
|
||||
}
|
||||
|
||||
@@ -129,31 +128,32 @@ func (provider *PdfEngineProviderMock) PdfEngine() (PdfEngine, error) {
|
||||
|
||||
// ProcessMock is a mock for the [Process] interface.
|
||||
type ProcessMock struct {
|
||||
StartMock func(logger *zap.Logger) error
|
||||
StopMock func(logger *zap.Logger) error
|
||||
HealthyMock func(logger *zap.Logger) bool
|
||||
StartMock func(logger *slog.Logger) error
|
||||
StopMock func(logger *slog.Logger) error
|
||||
HealthyMock func(logger *slog.Logger) bool
|
||||
}
|
||||
|
||||
func (p *ProcessMock) Start(logger *zap.Logger) error {
|
||||
func (p *ProcessMock) Start(logger *slog.Logger) error {
|
||||
return p.StartMock(logger)
|
||||
}
|
||||
|
||||
func (p *ProcessMock) Stop(logger *zap.Logger) error {
|
||||
func (p *ProcessMock) Stop(logger *slog.Logger) error {
|
||||
return p.StopMock(logger)
|
||||
}
|
||||
|
||||
func (p *ProcessMock) Healthy(logger *zap.Logger) bool {
|
||||
func (p *ProcessMock) Healthy(logger *slog.Logger) bool {
|
||||
return p.HealthyMock(logger)
|
||||
}
|
||||
|
||||
// ProcessSupervisorMock is a mock for the [ProcessSupervisor] interface.
|
||||
type ProcessSupervisorMock struct {
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *zap.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
LaunchMock func() error
|
||||
ShutdownMock func() error
|
||||
HealthyMock func() bool
|
||||
RunMock func(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
ReqQueueSizeMock func() int64
|
||||
RestartsCountMock func() int64
|
||||
ActiveTasksCountMock func() int64
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Launch() error {
|
||||
@@ -168,7 +168,7 @@ func (s *ProcessSupervisorMock) Healthy() bool {
|
||||
return s.HealthyMock()
|
||||
}
|
||||
|
||||
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
func (s *ProcessSupervisorMock) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||
return s.RunMock(ctx, logger, task)
|
||||
}
|
||||
|
||||
@@ -180,13 +180,8 @@ func (s *ProcessSupervisorMock) RestartsCount() int64 {
|
||||
return s.RestartsCountMock()
|
||||
}
|
||||
|
||||
// LoggerProviderMock is a mock for the [LoggerProvider] interface.
|
||||
type LoggerProviderMock struct {
|
||||
LoggerMock func(mod Module) (*zap.Logger, error)
|
||||
}
|
||||
|
||||
func (provider *LoggerProviderMock) Logger(mod Module) (*zap.Logger, error) {
|
||||
return provider.LoggerMock(mod)
|
||||
func (s *ProcessSupervisorMock) ActiveTasksCount() int64 {
|
||||
return s.ActiveTasksCountMock()
|
||||
}
|
||||
|
||||
// MetricsProviderMock is a mock for the [MetricsProvider] interface.
|
||||
@@ -224,7 +219,6 @@ var (
|
||||
_ PdfEngineProvider = (*PdfEngineProviderMock)(nil)
|
||||
_ Process = (*ProcessMock)(nil)
|
||||
_ ProcessSupervisor = (*ProcessSupervisorMock)(nil)
|
||||
_ LoggerProvider = (*LoggerProviderMock)(nil)
|
||||
_ MetricsProvider = (*MetricsProviderMock)(nil)
|
||||
_ MkdirAll = (*MkdirAllMock)(nil)
|
||||
_ PathRename = (*PathRenameMock)(nil)
|
||||
|
||||
@@ -4,8 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"log/slog"
|
||||
)
|
||||
|
||||
var (
|
||||
@@ -160,57 +159,57 @@ type Bookmark struct {
|
||||
type PdfEngine interface {
|
||||
// Merge combines multiple PDFs into a single PDF. The resulting page order
|
||||
// is determined by the order of files provided in inputPaths.
|
||||
Merge(ctx context.Context, logger *zap.Logger, inputPaths []string, outputPath string) error
|
||||
Merge(ctx context.Context, logger *slog.Logger, inputPaths []string, outputPath string) error
|
||||
|
||||
// Split splits a given PDF file.
|
||||
Split(ctx context.Context, logger *zap.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
Split(ctx context.Context, logger *slog.Logger, mode SplitMode, inputPath, outputDirPath string) ([]string, error)
|
||||
|
||||
// Flatten merges existing annotation appearances with page content,
|
||||
// effectively deleting the original annotations. This process can flatten
|
||||
// forms as well as forms share a relationship with annotations. Note that
|
||||
// this operation is irreversible.
|
||||
Flatten(ctx context.Context, logger *zap.Logger, inputPath string) error
|
||||
Flatten(ctx context.Context, logger *slog.Logger, inputPath string) error
|
||||
|
||||
// Convert transforms a given PDF to the specified formats defined in
|
||||
// PdfFormats. If no format, it does nothing.
|
||||
Convert(ctx context.Context, logger *zap.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
Convert(ctx context.Context, logger *slog.Logger, formats PdfFormats, inputPath, outputPath string) error
|
||||
|
||||
// ReadMetadata extracts the metadata of a given PDF file.
|
||||
ReadMetadata(ctx context.Context, logger *zap.Logger, inputPath string) (map[string]any, error)
|
||||
ReadMetadata(ctx context.Context, logger *slog.Logger, inputPath string) (map[string]any, error)
|
||||
|
||||
// PageCount returns the number of pages in a PDF file.
|
||||
PageCount(ctx context.Context, logger *zap.Logger, inputPath string) (int, error)
|
||||
PageCount(ctx context.Context, logger *slog.Logger, inputPath string) (int, error)
|
||||
|
||||
// WriteMetadata writes the metadata into a given PDF file.
|
||||
WriteMetadata(ctx context.Context, logger *zap.Logger, metadata map[string]any, inputPath string) error
|
||||
WriteMetadata(ctx context.Context, logger *slog.Logger, metadata map[string]any, inputPath string) error
|
||||
|
||||
// ReadBookmarks reads the document outline (bookmarks) of a PDF file.
|
||||
ReadBookmarks(ctx context.Context, logger *zap.Logger, inputPath string) ([]Bookmark, error)
|
||||
ReadBookmarks(ctx context.Context, logger *slog.Logger, inputPath string) ([]Bookmark, error)
|
||||
|
||||
// WriteBookmarks adds a document outline (bookmarks) to a PDF file.
|
||||
// The bookmarks parameter represents the hierarchical tree of the outline.
|
||||
WriteBookmarks(ctx context.Context, logger *zap.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
WriteBookmarks(ctx context.Context, logger *slog.Logger, inputPath string, bookmarks []Bookmark) error
|
||||
|
||||
// Encrypt adds password protection to a PDF file.
|
||||
// The userPassword is required to open the document.
|
||||
// The ownerPassword provides full access to the document.
|
||||
// If the ownerPassword is empty, it defaults to the userPassword.
|
||||
Encrypt(ctx context.Context, logger *zap.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
Encrypt(ctx context.Context, logger *slog.Logger, inputPath, userPassword, ownerPassword string) error
|
||||
|
||||
// EmbedFiles embeds files into a PDF. All files are embedded as file attachments
|
||||
// without modifying the main PDF content.
|
||||
// TODO: attachments instead? Rename the route?
|
||||
EmbedFiles(ctx context.Context, logger *zap.Logger, filePaths []string, inputPath string) error
|
||||
EmbedFiles(ctx context.Context, logger *slog.Logger, filePaths []string, inputPath string) error
|
||||
|
||||
// Watermark applies a watermark (behind page content) to a PDF file.
|
||||
Watermark(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
|
||||
Watermark(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
|
||||
// Stamp applies a stamp (on top of page content) to a PDF file.
|
||||
Stamp(ctx context.Context, logger *zap.Logger, inputPath string, stamp Stamp) error
|
||||
Stamp(ctx context.Context, logger *slog.Logger, inputPath string, stamp Stamp) error
|
||||
|
||||
// Rotate rotates pages of a PDF file by the given angle (90, 180, 270).
|
||||
// If pages is empty, all pages are rotated.
|
||||
Rotate(ctx context.Context, logger *zap.Logger, inputPath string, angle int, pages string) error
|
||||
Rotate(ctx context.Context, logger *slog.Logger, inputPath string, angle int, pages string) error
|
||||
}
|
||||
|
||||
// PdfEngineProvider offers an interface to instantiate a [PdfEngine].
|
||||
|
||||
48
pkg/gotenberg/semconv/bench_test.go
Normal file
48
pkg/gotenberg/semconv/bench_test.go
Normal 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{})
|
||||
}
|
||||
}
|
||||
308
pkg/gotenberg/semconv/client.go
Normal file
308
pkg/gotenberg/semconv/client.go
Normal 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
|
||||
}
|
||||
182
pkg/gotenberg/semconv/client_test.go
Normal file
182
pkg/gotenberg/semconv/client_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
71
pkg/gotenberg/semconv/common_test.go
Normal file
71
pkg/gotenberg/semconv/common_test.go
Normal 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{}))
|
||||
}
|
||||
7
pkg/gotenberg/semconv/doc.go
Normal file
7
pkg/gotenberg/semconv/doc.go
Normal 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
|
||||
15
pkg/gotenberg/semconv/gen.go
Normal file
15
pkg/gotenberg/semconv/gen.go
Normal 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
|
||||
455
pkg/gotenberg/semconv/httpconvtest_test.go
Normal file
455
pkg/gotenberg/semconv/httpconvtest_test.go
Normal 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"
|
||||
}
|
||||
405
pkg/gotenberg/semconv/server.go
Normal file
405
pkg/gotenberg/semconv/server.go
Normal 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
|
||||
}
|
||||
210
pkg/gotenberg/semconv/server_test.go
Normal file
210
pkg/gotenberg/semconv/server_test.go
Normal 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)
|
||||
})
|
||||
}
|
||||
}
|
||||
127
pkg/gotenberg/semconv/util.go
Normal file
127
pkg/gotenberg/semconv/util.go
Normal 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
|
||||
}
|
||||
75
pkg/gotenberg/semconv/util_test.go
Normal file
75
pkg/gotenberg/semconv/util_test.go
Normal 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))
|
||||
}
|
||||
}
|
||||
@@ -4,11 +4,10 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
// ErrProcessAlreadyRestarting happens if the [ProcessSupervisor] is trying
|
||||
@@ -28,15 +27,15 @@ var ErrMaximumQueueSizeExceeded = errors.New("maximum queue size exceeded")
|
||||
type Process interface {
|
||||
// Start initiates the process and returns an error if the process cannot
|
||||
// be started.
|
||||
Start(logger *zap.Logger) error
|
||||
Start(logger *slog.Logger) error
|
||||
|
||||
// Stop terminates the process and returns an error if the process cannot
|
||||
// be stopped.
|
||||
Stop(logger *zap.Logger) error
|
||||
Stop(logger *slog.Logger) error
|
||||
|
||||
// Healthy checks the health of the process. It returns true if the process
|
||||
// is healthy; otherwise, it returns false.
|
||||
Healthy(logger *zap.Logger) bool
|
||||
Healthy(logger *slog.Logger) bool
|
||||
}
|
||||
|
||||
// ProcessSupervisor provides methods to manage a [Process], including
|
||||
@@ -67,17 +66,20 @@ type ProcessSupervisor interface {
|
||||
//
|
||||
// It returns an error if the task cannot be run or if the process state
|
||||
// cannot be managed properly.
|
||||
Run(ctx context.Context, logger *zap.Logger, task func() error) error
|
||||
Run(ctx context.Context, logger *slog.Logger, task func() error) error
|
||||
|
||||
// ReqQueueSize returns the current size of the request queue.
|
||||
ReqQueueSize() int64
|
||||
|
||||
// RestartsCount returns the current number of restart.
|
||||
RestartsCount() int64
|
||||
|
||||
// ActiveTasksCount returns the current number of active tasks.
|
||||
ActiveTasksCount() int64
|
||||
}
|
||||
|
||||
type processSupervisor struct {
|
||||
logger *zap.Logger
|
||||
logger *slog.Logger
|
||||
process Process
|
||||
maxReqLimit int64
|
||||
maxQueueSize int64
|
||||
@@ -99,7 +101,7 @@ type processSupervisor struct {
|
||||
}
|
||||
|
||||
// NewProcessSupervisor initializes a new [ProcessSupervisor].
|
||||
func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
|
||||
func NewProcessSupervisor(logger *slog.Logger, process Process, maxReqLimit, maxQueueSize, maxConcurrency int64) ProcessSupervisor {
|
||||
if maxConcurrency < 1 {
|
||||
maxConcurrency = 1
|
||||
}
|
||||
@@ -122,38 +124,38 @@ func NewProcessSupervisor(logger *zap.Logger, process Process, maxReqLimit, maxQ
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Launch() error {
|
||||
s.logger.Debug("start process")
|
||||
s.logger.DebugContext(context.Background(), "start process")
|
||||
err := s.process.Start(s.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("start process: %w", err)
|
||||
}
|
||||
|
||||
s.firstStart.Store(true)
|
||||
s.logger.Debug("process successfully started")
|
||||
s.logger.DebugContext(context.Background(), "process successfully started")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Shutdown() error {
|
||||
s.logger.Debug("shutdown process")
|
||||
s.logger.DebugContext(context.Background(), "shutdown process")
|
||||
err := s.process.Stop(s.logger)
|
||||
if err != nil {
|
||||
return fmt.Errorf("shutdown process: %w", err)
|
||||
}
|
||||
|
||||
s.logger.Debug("process successfully shutdown")
|
||||
s.logger.DebugContext(context.Background(), "process successfully shutdown")
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (s *processSupervisor) restart() error {
|
||||
s.logger.Debug("restart process")
|
||||
s.logger.DebugContext(context.Background(), "restart process")
|
||||
|
||||
err := s.Shutdown()
|
||||
if err != nil {
|
||||
// Not necessarily critical — chances are the process is already stopped,
|
||||
// but worth flagging in case it indicates a real issue.
|
||||
s.logger.Warn(fmt.Sprintf("stop process before restart: %s", err))
|
||||
s.logger.WarnContext(context.Background(), fmt.Sprintf("stop process before restart: %s", err))
|
||||
}
|
||||
|
||||
err = s.Launch()
|
||||
@@ -163,7 +165,7 @@ func (s *processSupervisor) restart() error {
|
||||
|
||||
s.reqCounter.Store(0)
|
||||
s.restartsCounter.Add(1)
|
||||
s.logger.Debug("process successfully restarted")
|
||||
s.logger.DebugContext(context.Background(), "process successfully restarted")
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -186,7 +188,7 @@ func (s *processSupervisor) Healthy() bool {
|
||||
return s.process.Healthy(s.logger)
|
||||
}
|
||||
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task func() error) error {
|
||||
func (s *processSupervisor) Run(ctx context.Context, logger *slog.Logger, task func() error) error {
|
||||
// Atomically check and increment the queue size to avoid the TOCTOU race
|
||||
// originally reported in https://github.com/gotenberg/gotenberg/issues/951.
|
||||
for {
|
||||
@@ -219,7 +221,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
defer func() {
|
||||
s.activeTasks.Add(-1)
|
||||
if semaphoreOwned {
|
||||
logger.Debug("process lock released")
|
||||
logger.DebugContext(ctx, "process lock released")
|
||||
<-s.semaphore
|
||||
}
|
||||
}()
|
||||
@@ -243,7 +245,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
}()
|
||||
|
||||
if errors.Is(err, ErrProcessAlreadyRestarting) {
|
||||
logger.Debug("process is already restarting, trying to acquire process lock again...")
|
||||
logger.DebugContext(ctx, "process is already restarting, trying to acquire process lock again...")
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
continue
|
||||
}
|
||||
@@ -255,7 +257,7 @@ func (s *processSupervisor) Run(ctx context.Context, logger *zap.Logger, task fu
|
||||
|
||||
// acquireSlot attempts to acquire a semaphore slot, yielding it back if a
|
||||
// restart drain is in progress.
|
||||
func (s *processSupervisor) acquireSlot(ctx context.Context, logger *zap.Logger) error {
|
||||
func (s *processSupervisor) acquireSlot(ctx context.Context, logger *slog.Logger) error {
|
||||
select {
|
||||
case s.semaphore <- struct{}{}:
|
||||
// If a restart drain is in progress, release the slot
|
||||
@@ -265,11 +267,11 @@ func (s *processSupervisor) acquireSlot(ctx context.Context, logger *zap.Logger)
|
||||
return ErrProcessAlreadyRestarting
|
||||
}
|
||||
|
||||
logger.Debug("process lock acquired")
|
||||
logger.DebugContext(ctx, "process lock acquired")
|
||||
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
logger.Debug("failed to acquire process lock before deadline")
|
||||
logger.DebugContext(ctx, "failed to acquire process lock before deadline")
|
||||
|
||||
return fmt.Errorf("acquire process lock: %w", ctx.Err())
|
||||
}
|
||||
@@ -303,7 +305,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
s.logger.Debug("process is unhealthy, cannot handle task, restarting...")
|
||||
s.logger.DebugContext(context.Background(), "process is unhealthy, cannot handle task, restarting...")
|
||||
|
||||
if err := s.doRestart(ctx); err != nil {
|
||||
return fmt.Errorf("process restart before task: %w", err)
|
||||
@@ -316,7 +318,7 @@ func (s *processSupervisor) ensureHealthy(ctx context.Context) error {
|
||||
// and, if so, triggers an asynchronous restart. If a restart is initiated, it
|
||||
// takes ownership of the caller's semaphore slot (the caller must not release
|
||||
// it). Returns true if ownership was taken.
|
||||
func (s *processSupervisor) maybeRestartAfterTask(logger *zap.Logger) bool {
|
||||
func (s *processSupervisor) maybeRestartAfterTask(logger *slog.Logger) bool {
|
||||
if s.maxReqLimit <= 0 || s.reqCounter.Load() < s.maxReqLimit {
|
||||
return false
|
||||
}
|
||||
@@ -325,15 +327,15 @@ func (s *processSupervisor) maybeRestartAfterTask(logger *zap.Logger) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
s.logger.Debug("max request limit reached, restarting eagerly...")
|
||||
s.logger.DebugContext(context.Background(), "max request limit reached, restarting eagerly...")
|
||||
|
||||
go func() {
|
||||
restartErr := s.doRestartLocked(context.Background())
|
||||
s.restartMutex.Unlock()
|
||||
if restartErr != nil {
|
||||
s.logger.Error(fmt.Sprintf("process restart after task: %v", restartErr))
|
||||
s.logger.ErrorContext(context.Background(), fmt.Sprintf("process restart after task: %v", restartErr))
|
||||
}
|
||||
logger.Debug("process lock released")
|
||||
logger.DebugContext(context.Background(), "process lock released")
|
||||
<-s.semaphore
|
||||
}()
|
||||
|
||||
@@ -405,6 +407,10 @@ func (s *processSupervisor) RestartsCount() int64 {
|
||||
return s.restartsCounter.Load()
|
||||
}
|
||||
|
||||
func (s *processSupervisor) ActiveTasksCount() int64 {
|
||||
return s.activeTasks.Load()
|
||||
}
|
||||
|
||||
// Interface guards.
|
||||
var (
|
||||
_ ProcessSupervisor = (*processSupervisor)(nil)
|
||||
|
||||
@@ -3,12 +3,11 @@ package gotenberg
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"log/slog"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
@@ -38,10 +37,10 @@ func TestProcessSupervisor_Launch(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return tc.startError
|
||||
},
|
||||
}
|
||||
@@ -86,10 +85,10 @@ func TestProcessSupervisor_Shutdown(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
return tc.stopError
|
||||
},
|
||||
}
|
||||
@@ -135,13 +134,13 @@ func TestProcessSupervisor_restart(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return tc.startError
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
return tc.stopError
|
||||
},
|
||||
}
|
||||
@@ -194,10 +193,10 @@ func TestProcessSupervisor_Healthy(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return tc.processHealthy
|
||||
},
|
||||
}
|
||||
@@ -365,7 +364,7 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var startCalls, healthyCalls, stopCalls atomic.Int64
|
||||
startCalls.Store(0)
|
||||
@@ -373,15 +372,15 @@ func TestProcessSupervisor_Run(t *testing.T) {
|
||||
stopCalls.Store(0)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
startCalls.Add(1)
|
||||
return tc.startError
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
stopCalls.Add(1)
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
healthyCalls.Add(1)
|
||||
return tc.processHealthy
|
||||
},
|
||||
@@ -471,7 +470,7 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
ps := NewProcessSupervisor(zap.NewNop(), new(ProcessMock), 0, 0, 1).(*processSupervisor)
|
||||
ps := NewProcessSupervisor(slog.New(slog.DiscardHandler), new(ProcessMock), 0, 0, 1).(*processSupervisor)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 1*time.Second)
|
||||
if tc.ctxDone {
|
||||
@@ -496,12 +495,12 @@ func TestProcessSupervisor_runWithDeadline(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
@@ -553,13 +552,13 @@ func TestProcessSupervisor_ReqQueueSize(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
@@ -608,13 +607,13 @@ func TestProcessSupervisor_QueueSizeCAS(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_QueueSizeIncludesActiveTasks(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
@@ -698,13 +697,13 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
},
|
||||
} {
|
||||
t.Run(tc.scenario, func(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return tc.startError
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
return tc.stopError
|
||||
},
|
||||
}
|
||||
@@ -725,18 +724,18 @@ func TestProcessSupervisor_RestartsCount(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
var startCalls atomic.Int64
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
startCalls.Add(1)
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
@@ -789,16 +788,16 @@ func TestProcessSupervisor_ConcurrentRun(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestProcessSupervisor_RestartDrainsAllSlots(t *testing.T) {
|
||||
logger := zap.NewNop()
|
||||
logger := slog.New(slog.DiscardHandler)
|
||||
|
||||
process := &ProcessMock{
|
||||
StartMock: func(logger *zap.Logger) error {
|
||||
StartMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
StopMock: func(logger *zap.Logger) error {
|
||||
StopMock: func(logger *slog.Logger) error {
|
||||
return nil
|
||||
},
|
||||
HealthyMock: func(logger *zap.Logger) bool {
|
||||
HealthyMock: func(logger *slog.Logger) bool {
|
||||
return true
|
||||
},
|
||||
}
|
||||
|
||||
238
pkg/gotenberg/telemetry.go
Normal file
238
pkg/gotenberg/telemetry.go
Normal 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)
|
||||
)
|
||||
Reference in New Issue
Block a user