Skip to content

Observability

The SDK exposes two lifecycle hooks on client construction, option.WithOnRetry(func(polipage.RetryEvent)) and option.WithOnError(func(error)). They run synchronously inside the SDK call, which makes them the place to count retries, record a request ID or emit structured log lines. The Gin integration adds no event bus of its own: Go already has slog, OpenTelemetry and metrics libraries that compose with plain functions.

  • option.WithOnRetry fires before each retry sleep. RetryEvent has Attempt (the attempt about to run, so 2 for the first retry), DelayMs and Reason. Reason is an error that is a *polipage.Error at runtime. The first HTTP call (Attempt 1) fires no event.
  • option.WithOnError fires once at terminal failure: retries exhausted, a non-retryable error, or cancellation. The argument is the *polipage.Error the call returns.
  • Panics inside a callback are recovered by the SDK, so a buggy hook never breaks the request.
  • Both options work on polipage.NewClient and per call, as an extra argument to Render.PDF and the other methods. A per-call hook leaves the shared client untouched.

option.WithLogger takes a *slog.Logger for the SDK’s own lines. The callbacks are where you add a custom shape.

main.go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
client := polipage.NewClient(
option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")),
option.WithLogger(logger),
option.WithOnRetry(func(e polipage.RetryEvent) {
attrs := []slog.Attr{
slog.Int("attempt", e.Attempt),
slog.Float64("delay_ms", e.DelayMs),
}
var pe *polipage.Error
if errors.As(e.Reason, &pe) {
attrs = append(attrs, slog.String("reason_code", pe.Code))
}
logger.LogAttrs(context.Background(), slog.LevelWarn, "polipage.retry", attrs...)
}),
option.WithOnError(func(err error) {
var pe *polipage.Error
if errors.As(err, &pe) {
logger.Error("polipage.error",
"code", pe.Code, "status", pe.StatusCode,
"request_id", pe.RequestID, "message", pe.Message)
return
}
logger.Error("polipage.error", "err", err)
}),
)

Collect the events of one render in a value the handler owns, and hand it to your logging middleware after c.Next(). Do not close over *gin.Context in a callback: Gin pools contexts and recycles them after the handler returns, so a goroutine that touches c later reads the next request’s state. When a callback must do async work, capture c.Copy() instead of c.

observability.go
type renderTrail struct {
mu sync.Mutex
events []renderEvent
}
type renderEvent struct {
Attempt int
DelayMs float64
Reason error // a *polipage.Error at runtime; nil for terminal-error rows
Err error
}
func (t *renderTrail) push(e renderEvent) {
t.mu.Lock()
t.events = append(t.events, e)
t.mu.Unlock()
}
func renderHandler(c *gin.Context) {
client := polipagegin.ClientFrom(c)
trail := &renderTrail{}
pdf, err := client.Render.PDF(c.Request.Context(), input,
option.WithOnRetry(func(e polipage.RetryEvent) {
trail.push(renderEvent{Attempt: e.Attempt, DelayMs: e.DelayMs, Reason: e.Reason})
}),
option.WithOnError(func(err error) {
trail.push(renderEvent{Err: err})
}),
)
c.Set("polipage.trail", trail.events) // read after c.Next() by a logging middleware
if err != nil {
polipagegin.WriteError(c, err)
return
}
polipagegin.PDF(c, pdf, polipagegin.PDFOptions{Filename: "invoice.pdf"})
}

Label with the *polipage.Error code, a bounded set (VALIDATION_ERROR, QUOTA_EXCEEDED, network_error, and so on). Never label by err.Error(), e.DelayMs or pe.RequestID: unbounded labels overload the time-series database.

metrics.go
var (
renderRetries = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "polipage_render_retries_total",
Help: "Total retries emitted by the Poli Page SDK.",
}, []string{"reason"})
renderErrors = promauto.NewCounterVec(prometheus.CounterOpts{
Name: "polipage_render_errors_total",
Help: "Total terminal errors emitted by the Poli Page SDK.",
}, []string{"code"})
)
client := polipage.NewClient(
option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")),
option.WithOnRetry(func(e polipage.RetryEvent) {
reason := "unknown"
var pe *polipage.Error
if errors.As(e.Reason, &pe) {
reason = pe.Code
}
renderRetries.WithLabelValues(reason).Inc()
}),
option.WithOnError(func(err error) {
code := "unknown"
var pe *polipage.Error
if errors.As(err, &pe) {
code = pe.Code
}
renderErrors.WithLabelValues(code).Inc()
}),
)
  • Callbacks run synchronously. Heavy work in a callback, such as a database write or an HTTP call, blocks the render that triggered it. Emit and return, and push slow work to a worker pool or a buffered channel.
  • OnError fires for every terminal failure, including the ones you will answer as 4xx. Filter on pe.IsRetryable() for server-side issues only, or on pe.IsAuthError() for misconfigurations.
  • e.Reason is an error, not a string. Extract .Code with errors.As. A type assertion to string panics.
  • The SDK does not retry on context.Canceled. Cancel c.Request.Context() and you get polipage.ErrAborted with no OnRetry event.
  • A panic is not a stop signal. Use option.WithMaxRetries(0) to turn retries off.
main.go
logger := slog.New(slog.NewJSONHandler(os.Stdout, nil))
client := polipage.NewClient(
option.WithAPIKey(os.Getenv("POLI_PAGE_API_KEY")),
option.WithLogger(logger),
option.WithOnError(func(err error) {
logger.Error("polipage.error", "err", err)
}),
)
r := gin.Default()
r.Use(polipagegin.Middleware(client))