# Gin (Go)

> HTTP metrics from prometheus/client_golang, structured logs to stdout, both observed by the agent.

Canonical: https://docs.getlumin.dev/stacks/gin/

The agent observes a Gin service via two channels: stdout for logs, and a Prometheus `/metrics` endpoint for HTTP metrics. No application-side Lumin SDK — the agent reads what your service already exposes.

## Labels on your container

```yaml
services:
  api:
    image: my-gin-api:latest
    labels:
      lumin.logs: "true"
      lumin.service: "api"
      lumin.metrics: "true"
      lumin.metrics.port: "8080"
      lumin.metrics.framework: "gin"
```

## Emit Prometheus metrics

Use [`prometheus/client_golang`](https://github.com/prometheus/client_golang) plus a Gin middleware. The Lumin team's `lumin-api` service uses exactly this approach — the middleware records request count, latency histogram, and error count, and the canonical Lumin schema (`http.requests`, `http.latency_ms`, `http.errors`) is reconstructed by the agent from the underlying Prometheus names when `lumin.metrics.framework=gin` is set.

Minimal middleware shape:

```go
package main

import (
	"strconv"
	"time"

	"github.com/gin-gonic/gin"
	"github.com/prometheus/client_golang/prometheus"
	"github.com/prometheus/client_golang/prometheus/promhttp"
)

var (
	requests = prometheus.NewCounterVec(
		prometheus.CounterOpts{Name: "http_requests_total"},
		[]string{"method", "route", "status_class"},
	)
	latency = prometheus.NewHistogramVec(
		prometheus.HistogramOpts{
			Name:    "http_request_duration_ms",
			Buckets: []float64{5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000},
		},
		[]string{"method", "route"},
	)
	errors = prometheus.NewCounterVec(
		prometheus.CounterOpts{Name: "http_errors_total"},
		[]string{"method", "route", "status"},
	)
)

func init() {
	prometheus.MustRegister(requests, latency, errors)
}

func HTTPMetrics() gin.HandlerFunc {
	return func(c *gin.Context) {
		start := time.Now()
		c.Next()

		route := c.FullPath() // templated route, e.g. /projects/:id
		if route == "" {
			return // 404, don't pollute cardinality
		}
		status := c.Writer.Status()
		class := strconv.Itoa(status/100) + "xx"

		requests.WithLabelValues(c.Request.Method, route, class).Inc()
		latency.WithLabelValues(c.Request.Method, route).Observe(float64(time.Since(start).Milliseconds()))
		if status >= 500 {
			errors.WithLabelValues(c.Request.Method, route, strconv.Itoa(status)).Inc()
		}
	}
}

func main() {
	r := gin.Default()
	r.Use(HTTPMetrics())
	r.GET("/metrics", gin.WrapH(promhttp.Handler()))
	// ...your routes...
	r.Run(":8080")
}
```

Three things to watch:

- **`c.FullPath()` returns the templated route**, e.g. `/projects/:id`. Always use this — `c.Request.URL.Path` is the expanded path and will explode your cardinality.
- **Skip non-routes.** When a request doesn't match any registered route, `FullPath()` is empty. Don't record metrics for those — they're 404s and they'd all share a `""` route label.
- **Skip the metrics endpoint itself.** Either return early in the middleware for `/metrics`, or don't `Use()` the middleware on that route, otherwise your dogfood charts count the agent's own scrapes as application traffic.

## Emit structured logs

Use any JSON logger writing to stdout — [`slog`](https://pkg.go.dev/log/slog) with the JSON handler is the boring choice and works perfectly. The agent's log tailer recognises JSON lines with a `level` field and stores everything in the `attributes` JSON column.

```go
package main

import (
	"log/slog"
	"os"
)

func main() {
	logger := slog.New(slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
		Level: slog.LevelInfo,
	}))
	slog.SetDefault(logger)

	slog.Info("server starting", "port", 8080)
}
```

Lumin reads `level` out of the object to set the row's level, and stores the whole object as `attributes`. The row's timestamp comes from Docker's log timestamp rather than a `time` or `ts` field, and `message` keeps the raw JSON line. No field is promoted to a dedicated column — `trace_id`, `span_id`, and `session_id` are all reachable as `attr.<key>`.

## Propagating browser session IDs

If you use [`@lumin-monitor/browser`](https://docs.getlumin.dev/stacks/browser/), have it send the session ID as `X-Lumin-Session` on every API request, then read it server-side and include it on every log line for that request:

```go
func WithSession() gin.HandlerFunc {
	return func(c *gin.Context) {
		sid := c.GetHeader("X-Lumin-Session")
		if sid != "" {
			c.Set("session_id", sid)
		}
		c.Next()
	}
}

// In your handler:
slog.InfoContext(c.Request.Context(), "fetched project", "session_id", c.GetString("session_id"))
```

Once that field is on the log row, clicking a 500 in the Logs UI will jump to the matching browser session.
