poundifdef/plainoldanalytics

0

stars

0

commits

Go

primary language

Sep 13, 2026

updated

README

Plain Old Analytics

Self-hosted, bolt-on web analytics for Go applications. It is similar in spirit to Umami, Plausible, or PostHog, but embedded in your app.

It does the following:

  • Logs HTTP traffic
  • Custom tracking events
  • Frontend browser session recording
  • Built-in dashboard

Overview: KPIs, a requests chart, and breakdown cards for pages, referrers, browsers, and OS Traffic: filterable table of logged HTTP requests Session replay: recorded browser session with an events timeline User: sessions and activity feed for one identified user

The core plainoldanalytics package is storage-agnostic — it knows nothing about DuckDB or any other backend, so importing it never pulls one in. Pick a storage package and use its PlainOldAnalytics constructor to get both a working Storage and the Analytics wiring in one call, or implement the Storage interface yourself and pass it to plainoldanalytics.New.

Quick start

package main

import (
    "log"
    "net/http"

    "jaygoel.com/plainoldanalytics/storage/memory_store"
)

func handler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello"))
}

func main() {
    analytics := memory_store.PlainOldAnalytics()
    defer analytics.Close()

    router := http.NewServeMux()
    router.HandleFunc("/", handler)

    // Built-in dashboard
    analytics.Mount(router, "/analytics")

    loggedRouter := analytics.Middleware(router)
    http.ListenAndServe(":8080", loggedRouter)
}

Open /analytics to see the analytics dashboard.

Frontend events and session replay

Include the snippet in your pages:

<script src="/analytics/e.js"></script>
<script>
// Run this (optional) to capture browser screen recording
  plainoldanalytics.init_session_recording();           

  // Capture any events (button clicks, etc)
  plainoldanalytics.track('signup', {plan: 'pro'});   
</script>

Features

Persistent storage

Plainoldanalytics uses DuckDB for persistent storage.

import (
    "github.com/duckdb/duckdb-go/v2"
    "jaygoel.com/plainoldanalytics/storage/duckdb_store"
)

db, _ := duckdb.NewConnector("plainoldanalytics.duckdb", nil)
analytics, _ := duckdb_store.PlainOldAnalytics(db)

Traffic flushes to disk periodically (about once a second); call analytics.Close() on shutdown to flush anything still buffered.

Set properties for each request

You can set key/value properties on each request by calling plainoldanalytics.Set(). In the UI, you can filter on these properties. Plainoldanalytics special-cases the user property to show all traffic related to that user in the UI.

func handler(w http.ResponseWriter, r *http.Request) {
    plainoldanalytics.Set(r.Context(), "user", "user@example.com")
    w.Write([]byte("hello"))
}

Exclude Routes

To exclude a route from being logged, call plainoldanalytics.Exclude()

func handler(w http.ResponseWriter, r *http.Request) {
    plainoldanalytics.Exclude(r.Context())
    w.Write([]byte("hello"))
}

Path parameters

Some routes have path parameters (ie /users/{id}) and those are recorded. If you want to record the literal route instead, wrap that route's handler with plainoldanalytics.UseRequestPath.

router.Handle("/{wildcard...}", plainoldanalytics.UseRequestPath(http.FileServer(http.Dir("public"))))

Works the same way under gin (gin.WrapH(plainoldanalytics.UseRequestPath(handler))) and chi.

Frameworks

Gin

package main

import (
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
    ginadapter "jaygoel.com/plainoldanalytics/adapters/gin"
    "jaygoel.com/plainoldanalytics/storage/memory_store"
)

func main() {
    analytics := memory_store.PlainOldAnalytics()
    defer analytics.Close()

    router := gin.New()

    // Scope the middleware to a group so the dashboard, mounted below
    // outside it, isn't recorded as traffic.
    app := router.Group("/", ginadapter.New(analytics.Capturer()).Handle)
    app.GET("/hello/:name", func(c *gin.Context) {
        c.String(http.StatusOK, "hello "+c.Param("name"))
    })

    mux := http.NewServeMux()
    analytics.Mount(mux, "/analytics")
    dashboard := gin.WrapH(mux)
    router.Any("/analytics", dashboard)
    router.Any("/analytics/*any", dashboard)

    log.Fatal(router.Run(":8080"))
}

Chi

package main

import (
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    chiadapter "jaygoel.com/plainoldanalytics/adapters/chi"
    "jaygoel.com/plainoldanalytics/storage/memory_store"
)

func main() {
    analytics := memory_store.PlainOldAnalytics()
    defer analytics.Close()

    router := chi.NewRouter()

    // Group scopes the middleware so the dashboard, mounted below outside
    // it, isn't recorded as traffic.
    router.Group(func(r chi.Router) {
        r.Use(chiadapter.New(analytics.Capturer()).Wrap)
        r.Get("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
            w.Write([]byte("hello " + chi.URLParam(r, "name")))
        })
    })

    mux := http.NewServeMux()
    analytics.Mount(mux, "/analytics")
    router.Mount("/analytics", mux)

    log.Fatal(http.ListenAndServe(":8080", router))
}

poundifdef/plainoldanalytics

0

stars

0

commits

Go

primary language

Sep 13, 2026

updated

README

Plain Old Analytics

Self-hosted, bolt-on web analytics for Go applications. It is similar in spirit to Umami, Plausible, or PostHog, but embedded in your app.

It does the following:

  • Logs HTTP traffic
  • Custom tracking events
  • Frontend browser session recording
  • Built-in dashboard

Overview: KPIs, a requests chart, and breakdown cards for pages, referrers, browsers, and OS Traffic: filterable table of logged HTTP requests Session replay: recorded browser session with an events timeline User: sessions and activity feed for one identified user

The core plainoldanalytics package is storage-agnostic — it knows nothing about DuckDB or any other backend, so importing it never pulls one in. Pick a storage package and use its PlainOldAnalytics constructor to get both a working Storage and the Analytics wiring in one call, or implement the Storage interface yourself and pass it to plainoldanalytics.New.

Quick start

package main

import (
    "log"
    "net/http"

    "jaygoel.com/plainoldanalytics/storage/memory_store"
)

func handler(w http.ResponseWriter, r *http.Request) {
    w.Write([]byte("hello"))
}

func main() {
    analytics := memory_store.PlainOldAnalytics()
    defer analytics.Close()

    router := http.NewServeMux()
    router.HandleFunc("/", handler)

    // Built-in dashboard
    analytics.Mount(router, "/analytics")

    loggedRouter := analytics.Middleware(router)
    http.ListenAndServe(":8080", loggedRouter)
}

Open /analytics to see the analytics dashboard.

Frontend events and session replay

Include the snippet in your pages:

<script src="/analytics/e.js"></script>
<script>
// Run this (optional) to capture browser screen recording
  plainoldanalytics.init_session_recording();           

  // Capture any events (button clicks, etc)
  plainoldanalytics.track('signup', {plan: 'pro'});   
</script>

Features

Persistent storage

Plainoldanalytics uses DuckDB for persistent storage.

import (
    "github.com/duckdb/duckdb-go/v2"
    "jaygoel.com/plainoldanalytics/storage/duckdb_store"
)

db, _ := duckdb.NewConnector("plainoldanalytics.duckdb", nil)
analytics, _ := duckdb_store.PlainOldAnalytics(db)

Traffic flushes to disk periodically (about once a second); call analytics.Close() on shutdown to flush anything still buffered.

Set properties for each request

You can set key/value properties on each request by calling plainoldanalytics.Set(). In the UI, you can filter on these properties. Plainoldanalytics special-cases the user property to show all traffic related to that user in the UI.

func handler(w http.ResponseWriter, r *http.Request) {
    plainoldanalytics.Set(r.Context(), "user", "user@example.com")
    w.Write([]byte("hello"))
}

Exclude Routes

To exclude a route from being logged, call plainoldanalytics.Exclude()

func handler(w http.ResponseWriter, r *http.Request) {
    plainoldanalytics.Exclude(r.Context())
    w.Write([]byte("hello"))
}

Path parameters

Some routes have path parameters (ie /users/{id}) and those are recorded. If you want to record the literal route instead, wrap that route's handler with plainoldanalytics.UseRequestPath.

router.Handle("/{wildcard...}", plainoldanalytics.UseRequestPath(http.FileServer(http.Dir("public"))))

Works the same way under gin (gin.WrapH(plainoldanalytics.UseRequestPath(handler))) and chi.

Frameworks

Gin

package main

import (
    "log"
    "net/http"

    "github.com/gin-gonic/gin"
    ginadapter "jaygoel.com/plainoldanalytics/adapters/gin"
    "jaygoel.com/plainoldanalytics/storage/memory_store"
)

func main() {
    analytics := memory_store.PlainOldAnalytics()
    defer analytics.Close()

    router := gin.New()

    // Scope the middleware to a group so the dashboard, mounted below
    // outside it, isn't recorded as traffic.
    app := router.Group("/", ginadapter.New(analytics.Capturer()).Handle)
    app.GET("/hello/:name", func(c *gin.Context) {
        c.String(http.StatusOK, "hello "+c.Param("name"))
    })

    mux := http.NewServeMux()
    analytics.Mount(mux, "/analytics")
    dashboard := gin.WrapH(mux)
    router.Any("/analytics", dashboard)
    router.Any("/analytics/*any", dashboard)

    log.Fatal(router.Run(":8080"))
}

Chi

package main

import (
    "log"
    "net/http"

    "github.com/go-chi/chi/v5"
    chiadapter "jaygoel.com/plainoldanalytics/adapters/chi"
    "jaygoel.com/plainoldanalytics/storage/memory_store"
)

func main() {
    analytics := memory_store.PlainOldAnalytics()
    defer analytics.Close()

    router := chi.NewRouter()

    // Group scopes the middleware so the dashboard, mounted below outside
    // it, isn't recorded as traffic.
    router.Group(func(r chi.Router) {
        r.Use(chiadapter.New(analytics.Capturer()).Wrap)
        r.Get("/hello/{name}", func(w http.ResponseWriter, r *http.Request) {
            w.Write([]byte("hello " + chi.URLParam(r, "name")))
        })
    })

    mux := http.NewServeMux()
    analytics.Mount(mux, "/analytics")
    router.Mount("/analytics", mux)

    log.Fatal(http.ListenAndServe(":8080", router))
}

Languages

Go

75.6%

HTML

13.0%

CSS

8.7%

JavaScript

2.7%