feat(otel): add OpenTelemetry support

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,8 @@
// Package otel gathers initialization utilities for OpenTelemetry
// instrumentation.
//
// This package has been significantly inspired by
// https://github.com/lucavallin/gotel.
//
// See: https://opentelemetry.io/.
package otel

View File

@@ -0,0 +1,166 @@
package otel
import (
"context"
"fmt"
"log/slog"
"os"
"sync/atomic"
"go.opentelemetry.io/contrib/bridges/otelslog"
"go.opentelemetry.io/contrib/exporters/autoexport"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/log/global"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/sdk/log"
"go.opentelemetry.io/otel/sdk/metric"
"go.opentelemetry.io/otel/sdk/resource"
"go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.40.0"
)
// InitTracerProvider initializes the OpenTelemetry tracer provider.
func InitTracerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, fmt.Errorf("merge resource: %w", err)
}
traceOpts := []trace.TracerProviderOption{
trace.WithResource(res),
}
traceExporter, err := autoexport.NewSpanExporter(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneSpanExporter(traceExporter) {
traceOpts = append(traceOpts, trace.WithBatcher(traceExporter))
}
traceProvider := trace.NewTracerProvider(traceOpts...)
otel.SetTracerProvider(traceProvider)
otel.SetTextMapPropagator(propagation.NewCompositeTextMapPropagator(
propagation.TraceContext{},
propagation.Baggage{},
))
return traceProvider.Shutdown, nil
}
// InitMeterProvider initializes the OpenTelemetry meter provider.
func InitMeterProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, fmt.Errorf("merge resource: %w", err)
}
metricOpts := []metric.Option{
metric.WithResource(res),
}
metricReader, err := autoexport.NewMetricReader(ctx)
if err != nil {
return nil, err
}
if !autoexport.IsNoneMetricReader(metricReader) {
metricOpts = append(metricOpts, metric.WithReader(metricReader))
}
meterProvider := metric.NewMeterProvider(metricOpts...)
otel.SetMeterProvider(meterProvider)
return meterProvider.Shutdown, nil
}
// InitLoggerProvider initializes the OpenTelemetry logger provider.
func InitLoggerProvider(logger *slog.Logger, serviceName, serviceVersion string) (shutdown func(context.Context) error, handler slog.Handler, err error) {
initOtelLogger(logger)
ctx := context.Background()
hostname, err := os.Hostname()
if err != nil {
return nil, nil, fmt.Errorf("get hostname: %w", err)
}
res, err := resource.Merge(
resource.Default(),
resource.NewWithAttributes(
semconv.SchemaURL,
semconv.ServiceName(serviceName),
semconv.ServiceVersion(serviceVersion),
semconv.HostName(hostname),
),
)
if err != nil {
return nil, nil, fmt.Errorf("merge resource: %w", err)
}
logOpts := []log.LoggerProviderOption{
log.WithResource(res),
}
logExporter, err := autoexport.NewLogExporter(ctx)
if err != nil {
return nil, nil, err
}
if !autoexport.IsNoneLogExporter(logExporter) {
logOpts = append(logOpts, log.WithProcessor(log.NewBatchProcessor(logExporter)))
}
loggerProvider := log.NewLoggerProvider(logOpts...)
otelHandler := otelslog.NewHandler(serviceName, otelslog.WithLoggerProvider(loggerProvider))
global.SetLoggerProvider(loggerProvider)
return loggerProvider.Shutdown, otelHandler, nil
}
func initOtelLogger(logger *slog.Logger) {
if otlpLoggerInitialized.Load() {
return
}
otel.SetErrorHandler(otel.ErrorHandlerFunc(func(err error) {
logger.Error(err.Error())
}))
otlpLoggerInitialized.Store(true)
}
var otlpLoggerInitialized atomic.Bool

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -3,12 +3,11 @@ package gotenberg
import (
"context"
"errors"
"log/slog"
"sync"
"sync/atomic"
"testing"
"time"
"go.uber.org/zap"
)
func TestProcessSupervisor_Launch(t *testing.T) {
@@ -38,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
View File

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