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:
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.
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.
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>
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.
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"))
}
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"))
}
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.
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"))
}
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))
}
Go
75.6%
HTML
13.0%
CSS
8.7%
JavaScript
2.7%
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:
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.
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.
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>
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.
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"))
}
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"))
}
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.
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"))
}
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))
}
Go
75.6%
HTML
13.0%
CSS
8.7%
JavaScript
2.7%