This phase started off simple create metrics endpoint and create a webhook that ships generic logs. We ended up refactoring the code into multiple files given the size of main.go. Something about Go really clicked for me when we dicussed structs and interfaces at length. I will let Claude explain the rest
Phase 7 focused on making demo-app production-ready from an observability standpoint:
/metrics)main.go into focused filesENV_FILTERdocs/CONFIGURATION.mdThe /metrics endpoint exposes both application-specific and Go runtime metrics in Prometheus format.
| Metric | Type | Purpose |
|---|---|---|
demoapp_http_requests_total |
Counter | Request counts by method, path, status |
demoapp_http_request_duration_seconds |
Histogram | Response time distribution |
demoapp_items_total |
Gauge | Current item count in database |
demoapp_display_updates_total |
Counter | Display panel POST count |
demoapp_info |
Gauge | Build info (version label) |
One subtle but important detail: we normalize paths to prevent cardinality explosion.
func normalizePath(path string) string {
if strings.HasPrefix(path, "/api/items/") {
return "/api/items/:id"
}
return path
}
Without this, every unique item ID (/api/items/1, /api/items/2, etc.) would create a separate metric series. In Prometheus, high cardinality means high memory and storage costs. Normalizing to /api/items/:id keeps the metric series bounded.
We chose Prometheus format for simplicity and compatibility. Most observability platforms (Splunk, Datadog, Grafana, New Relic) can ingest Prometheus metrics natively or via collectors. OpenTelemetry is powerful but adds complexity we don’t need for a demo app.
Before adding more features, we split main.go (~730 lines) into focused files:
| File | Lines | Responsibility |
|---|---|---|
handlers.go |
455 | HTTP endpoint logic |
main.go |
149 | Startup, configuration, routing |
middleware.go |
90 | Request logging, metrics recording |
metrics.go |
74 | Prometheus definitions |
store.go |
67 | Data model, database setup |
A key learning: all .go files in the same directory with package main share variables and functions automatically. No imports needed between files in the same package.
// store.go
var db *badger.DB // package-level
// handlers.go
func listItems(w http.ResponseWriter, r *http.Request) {
db.View(...) // uses db from store.go directly
}
This is exactly how Terraform works — all .tf files in a directory share the same namespace. Same design pattern, since Terraform is written in Go.
:= Shadowing TrapOne gotcha that caught us: := creates a NEW variable even if a package-level one exists.
var db *badger.DB // package-level
func main() {
db, err := initStore() // WRONG: creates local db, package-level stays nil!
var err error
db, err = initStore() // RIGHT: assigns to existing package-level db
}
For demos where you’re running the binary directly (not in a container with log agents), we added optional webhook shipping.
LOG_WEBHOOK_URL="https://splunk.example.com:8088/services/collector" \
LOG_WEBHOOK_TOKEN="Splunk your-hec-token" \
./demo-app
Go’s log/slog package uses handlers to control where logs go. We created a custom handler that wraps the standard JSONHandler:
type webhookHandler struct {
jsonHandler slog.Handler
webhookURL string
authToken string
client *http.Client
}
func (w *webhookHandler) Handle(ctx context.Context, record slog.Record) error {
// Always write to stdout via wrapped handler
if err := w.jsonHandler.Handle(ctx, record); err != nil {
return err
}
// Async POST to webhook (fire and forget)
go w.postToWebhook(record)
return nil
}
Key design decisions:
The System Info panel displays environment variables, but which ones? Originally we had a hardcoded allowlist. Now users can control it with ENV_FILTER.
# Show only vars starting with DEMO_
ENV_FILTER="^DEMO_" ./demo-app
# Show Kubernetes-related vars
ENV_FILTER="^(POD_|NODE_|KUBERNETES_)" ./demo-app
func getFilteredEnvVars() map[string]string {
if pattern := os.Getenv("ENV_FILTER"); pattern != "" {
// Case-insensitive regex matching
re, err := regexp.Compile("(?i)" + pattern)
if err != nil {
slog.Error("invalid ENV_FILTER regex", "pattern", pattern, "error", err)
return make(map[string]string) // safe fallback
}
result := make(map[string]string)
for _, envVar := range os.Environ() {
parts := strings.SplitN(envVar, "=", 2)
if re.MatchString(parts[0]) {
result[parts[0]] = parts[1]
}
}
return result
}
// Default: safe allowlist
// ...
}
A question that came up: how does os.Getenv() work?
Every process inherits environment variables from its parent (your shell). When you run ./demo-app, it gets a snapshot of the shell’s environment. You can see the same data with env or printenv in bash.
This is different from os.Hostname(), which makes a syscall to the kernel. The HOSTNAME environment variable may or may not be set (Docker sets it, desktop Linux often doesn’t), but os.Hostname() always works.
We created docs/CONFIGURATION.md with comprehensive documentation for all environment variables. The README has a quick reference table linking to the full docs.
This structure keeps the README scannable while providing depth for users who need it.
While testing ENV_FILTER, we noticed long variable names (like DEMO_LONG_VARIABLE_NAME) overlapped with values in the dashboard. The fix was simple:
/* Before */
#system-content .info-label {
width: 100px; /* fixed width, text overflows */
}
/* After */
#system-content .info-label {
min-width: 100px; /* minimum width, can grow */
margin-right: 1rem;
}
All observability features are in place:
/metrics endpointNext up: Phase 8 — CI/CD with tests, GitHub Actions, and automated releases.