DEV Community

Cover image for A real Go service in 85 lines: link shortener with probes, metrics and traces
Pratham Jaiswal
Pratham Jaiswal

Posted on

A real Go service in 85 lines: link shortener with probes, metrics and traces

Every small Go service starts with the same chore list. You want to write the feature, but first you write the boring parts: structured logging, a metrics endpoint, health and readiness probes, configuration. None of it is hard. It is just the same work every time, and it usually gets finished after the first incident instead of before.

This post builds a small service people actually deploy: a link shortener. Create a short link, redirect clicks to the destination, count the clicks. I built it with GoFr, an opinionated Go microservice framework, to see how much of the production wiring I could skip.

Everything below is real output from a live run (GoFr v1.62.0, Go 1.26). The service is one file, 85 lines including imports and types.

What we are building

What runs when you start the service: app server on :8000, probes, metrics on :2121, and structured logs

The API surface:

Method Route What it does
POST /links Create a short link
GET /{code} 302 redirect to the target, increments the click count
GET /links List links with click counts

And the parts we do not write ourselves: probes at /.well-known/health and /.well-known/alive, a Prometheus metrics server on port 2121, and JSON logs on stdout with trace ids.

Setup

mkdir shortlink && cd shortlink
go mod init shortlink
go get gofr.dev/pkg/gofr
Enter fullscreen mode Exit fullscreen mode

GoFr v1.62 targets Go 1.26 or newer.

The whole service: three routes, one counter

Imports first. The three gofr.dev packages are the framework itself, its HTTP error types, and its response helpers.

import (
    "errors"
    "net/url"
    "strconv"
    "sync"

    "gofr.dev/pkg/gofr"
    gofrHTTP "gofr.dev/pkg/gofr/http"
    "gofr.dev/pkg/gofr/http/response"
)
Enter fullscreen mode Exit fullscreen mode

The state is deliberately boring: a map of codes to links, an ordered slice for listing, and a counter that produces short codes in base36, so the first code is rs.

type Link struct {
    Code   string `json:"code"`
    URL    string `json:"url"`
    Clicks int    `json:"clicks"`
}

var (
    mu      sync.Mutex
    byCode  = map[string]*Link{}
    ordered []*Link
    next    = 1000
)
Enter fullscreen mode Exit fullscreen mode

Creating a link looks like this:

func createLink(c *gofr.Context) (any, error) {
    in := struct {
        URL string `json:"url"`
    }{}
    if err := c.Bind(&in); err != nil {
        return nil, err
    }
    if _, err := url.ParseRequestURI(in.URL); err != nil {
        return nil, errors.New("url must be an absolute URL, e.g. https://example.com")
    }

    mu.Lock()
    l := &Link{Code: strconv.FormatInt(int64(next), 36), URL: in.URL}
    next++
    byCode[l.Code] = l
    ordered = append(ordered, l)
    mu.Unlock()

    c.Logf("created link %s -> %s", l.Code, l.URL)

    return l, nil
}
Enter fullscreen mode Exit fullscreen mode

A few details that matter later:

  • The handler signature is func(c *gofr.Context) (any, error). What you return becomes the JSON body, and a non-nil error becomes a JSON error response.
  • c.Bind decodes the request body into a struct.
  • c.Logf writes a structured log line that carries the current request's trace id.

The redirect is where the framework does something I would otherwise write by hand. Returning response.Redirect makes the framework reply 302 (or 303 for a POST) with the Location header set:

func openLink(c *gofr.Context) (any, error) {
    mu.Lock()
    l, ok := byCode[c.PathParam("code")]
    if ok {
        l.Clicks++
    }
    mu.Unlock()

    if !ok {
        return nil, gofrHTTP.ErrorEntityNotFound{Name: "link", Value: c.PathParam("code")}
    }

    return response.Redirect{URL: l.URL}, nil
}
Enter fullscreen mode Exit fullscreen mode

Note the typed error. By default a returned error becomes a 500. ErrorEntityNotFound is how you return a proper 404 instead. Small thing, but it is the difference between "the service is broken" and "this link does not exist" for every dashboard that watches your error rate.

Listing and main are short:

func listLinks(c *gofr.Context) (any, error) {
    mu.Lock()
    defer mu.Unlock()

    out := make([]Link, 0, len(ordered))
    for _, l := range ordered {
        out = append(out, *l)
    }

    return out, nil
}

func main() {
    app := gofr.New()

    app.POST("/links", createLink)
    app.GET("/links", listLinks)
    app.GET("/{code}", openLink)

    app.Run()
}
Enter fullscreen mode Exit fullscreen mode

Running it: the real outputs

$ curl -s -i -X POST localhost:8000/links -H 'Content-Type: application/json' -d '{"url":"https://gofr.dev"}'
HTTP/1.1 201 Created
{"data":{"code":"rs","url":"https://gofr.dev","clicks":0}}

$ curl -s -i localhost:8000/rs
HTTP/1.1 302 Found
Location: https://gofr.dev

$ curl -s localhost:8000/links
{"data":[{"code":"rs","url":"https://gofr.dev","clicks":2},{"code":"rt","url":"https://github.com/gofr-dev/gofr","clicks":0}]}

$ curl -s -o /dev/null -w '%{http_code}\n' localhost:8000/nope
404
$ curl -s localhost:8000/nope
{"error":{"message":"No entity found with link: nope"}}
Enter fullscreen mode Exit fullscreen mode

Responses are wrapped in a data envelope, and errors use an error envelope.

If your API contract is already fixed you can opt out with response.Raw, but the defaults are consistent, which is the point.

What came for free

These are the parts I normally write during week two, or not at all.

Traced structured logs

Two real lines from a single request cycle, timestamps trimmed for width:

{"level":"INFO","time":"2026-10-19T08:36:44.176Z","message":"created link rs -\u003e https://gofr.dev","trace_id":"1cf499e56bec5202ccf8da6c5aff62d0","gofrVersion":"v1.62.0"}
{"level":"INFO","time":"2026-10-19T08:36:44.196Z","message":{"trace_id":"1c28113c51c43d36fb894071ccd267b1","span_id":"9668b5265c0af36e","response_time":671,"method":"GET","user_agent":"curl/8.14.1","uri":"/rs","response":302},"gofrVersion":"v1.62.0"}
Enter fullscreen mode Exit fullscreen mode

The first line is the c.Logf call inside the handler. The second is the framework's own request log. They carry the same shape of trace context with no middleware to add, and every request gets a response_time.

Metrics on a separate port

http://localhost:2121/metrics serves Prometheus metrics. Runtime metrics are there (goroutines, garbage collection), and every route gets a response-time histogram labeled by route pattern rather than concrete URL, which keeps cardinality under control:

# HELP app_http_response Response time of HTTP requests in seconds.
# TYPE app_http_response histogram
app_http_response_bucket{method="GET",path="/{code}",status="302",le="0.001"} 2
app_http_response_bucket{method="GET",path="/{code}",status="302",le="0.003"} 2
app_http_response_bucket{method="GET",path="/links",status="200",le="0.001"} 1
Enter fullscreen mode Exit fullscreen mode

Because the path label is the route template, every route you add shows up in your dashboards without per-endpoint scrape config.

Probes that are already wired

$ curl -s localhost:8000/.well-known/health
{"data":{"name":"gofr-app","status":"UP"}}

$ curl -s localhost:8000/.well-known/alive
{"data":{"status":"UP"}}
Enter fullscreen mode Exit fullscreen mode

/.well-known/health is the readiness endpoint. Add a database or cache and its health plugs into this check, and the endpoint answers 503 when something is down. /.well-known/alive is a cheap liveness endpoint. On Kubernetes the probe config is two short blocks:

readinessProbe:
  httpGet: { path: /.well-known/health, port: 8000 }
livenessProbe:
  httpGet: { path: /.well-known/alive, port: 8000 }
Enter fullscreen mode Exit fullscreen mode

The honest part

A framework that promises convenience always charges somewhere. What I would tell a teammate before adopting GoFr:

  • You inherit its conventions. The data and error envelopes, the router, the config loading. You can override pieces (raw responses, custom logger, custom config provider), but if you fight the defaults you are paying the framework tax without getting the benefit.
  • It is opinionated about recency. v1.62 targets Go 1.26. On an older toolchain you pin an older release.
  • This demo keeps links in memory. For real storage you plug in a datasource. The framework exposes SQL and Redis handles on the context, but persistence is the part of this post you would rewrite.
  • It is a framework, not a library. For a two-endpoint service that will never grow, plain net/http is completely fine.

For the "small service that has to behave in production" case, though, this is the least wiring I have written for something deployable.

Wrapping up

The whole thing: 85 lines in one file, plus probes, metrics and traces that arrived without a line of glue code.

The parts that usually wait for the first incident were already there when I ran go run .

If you want to dig further:

One question, because I am curious what people actually prioritize: in a new Go service, do you wire logs, metrics or probes first?

Top comments (0)