diff --git a/README.md b/README.md
index 59c8549..1d6df56 100644
--- a/README.md
+++ b/README.md
@@ -47,19 +47,30 @@ system.
The number of page views for a given path and their associated referrer.
```text
-# HELP pages_page_view_count page views
+# HELP pages_page_view_count the number of times a given page has been viewed and by what referrer
# TYPE pages_page_view_count counter
pages_page_view_count{country="",path="/charts/",referrer="http://localhost:8080/blog/"} 1
```
-### pages_page_session_duration
+### pages_page_session_seconds
-How long a user is actively engaged with the page.
-
-https://github.com/mjpitz/pages/issues/2
+Histogram of how long users spend on a page.
```text
-# HELP pages_page_session_duration time spent on a given page in seconds
-# TYPE pages_page_session_duration histogram
-pages_page_session_duration{country="",path="/charts/"}
+# HELP pages_page_session_seconds how long someone spent on a given page
+# TYPE pages_page_session_seconds histogram
+pages_page_session_seconds_bucket{country="",path="/",le="0.005"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="0.01"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="0.025"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="0.05"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="0.1"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="0.25"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="0.5"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="1"} 0
+pages_page_session_seconds_bucket{country="",path="/",le="2.5"} 1
+pages_page_session_seconds_bucket{country="",path="/",le="5"} 1
+pages_page_session_seconds_bucket{country="",path="/",le="10"} 1
+pages_page_session_seconds_bucket{country="",path="/",le="+Inf"} 1
+pages_page_session_seconds_sum{country="",path="/"} 1.855976549
+pages_page_session_seconds_count{country="",path="/"} 1
```
diff --git a/go.mod b/go.mod
index e744e3b..c4f3f67 100644
--- a/go.mod
+++ b/go.mod
@@ -11,6 +11,7 @@ require (
github.com/go-git/go-billy/v5 v5.3.1
github.com/go-git/go-git/v5 v5.4.2
github.com/gorilla/mux v1.8.0
+ github.com/gorilla/websocket v1.5.0
github.com/pkg/errors v0.9.1
github.com/prometheus/client_golang v1.12.2
go.uber.org/zap v1.19.1
diff --git a/go.sum b/go.sum
index 2648683..b1c4744 100644
--- a/go.sum
+++ b/go.sum
@@ -171,6 +171,8 @@ github.com/googleapis/gax-go/v2 v2.0.4/go.mod h1:0Wqv26UfaUD9n4G6kQubkQ+KchISgw+
github.com/googleapis/gax-go/v2 v2.0.5/go.mod h1:DWXyrwAJ9X0FpwwEdw+IPEYBICEFu5mhpdKc/us6bOk=
github.com/gorilla/mux v1.8.0 h1:i40aqfkR1h2SlN9hojwV5ZA91wcXFOvkdNIeFDP5koI=
github.com/gorilla/mux v1.8.0/go.mod h1:DVbg23sWSpFRCP0SfiEN6jmj59UnW/n46BH5rLB71So=
+github.com/gorilla/websocket v1.5.0 h1:PPwGk2jz7EePpoHN/+ClbZu8SPxiqlu12wZP/3sWmnc=
+github.com/gorilla/websocket v1.5.0/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/hashicorp/errwrap v1.0.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/errwrap v1.1.0/go.mod h1:YH+1FKiLXxHSkmPseP+kNlulaMuP3n2brvKWEqk/Jc4=
github.com/hashicorp/go-cleanhttp v0.5.0/go.mod h1:JpRdi6/HCYpAwUzNwuwqhbovhLtngrth3wmdIIUrZ80=
diff --git a/internal/excludes/excludes.go b/internal/excludes/excludes.go
new file mode 100644
index 0000000..598ea7f
--- /dev/null
+++ b/internal/excludes/excludes.go
@@ -0,0 +1,62 @@
+// Copyright (C) 2022 The pages authors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+package excludes
+
+import (
+ "path"
+ "regexp"
+ "strings"
+)
+
+// Exclusion defines an abstraction for matching paths.
+type Exclusion func(s string) bool
+
+// AnyExclusion returns a matcher who returns true if any of the provided matchers match the string.
+func AnyExclusion(exclusions ...Exclusion) Exclusion {
+ return func(s string) bool {
+ for _, exclusion := range exclusions {
+ if exclusion(s) {
+ return true
+ }
+ }
+
+ return false
+ }
+}
+
+// AssetExclusion returns a matcher who returns true when an asset file is requested.
+func AssetExclusion() Exclusion {
+ return func(s string) bool {
+ return path.Ext(s) != ""
+ }
+}
+
+// PrefixExclusion returns a matcher who returns true if the string matches the provided prefix.
+func PrefixExclusion(prefix string) Exclusion {
+ return func(s string) bool {
+ return strings.HasPrefix(s, prefix)
+ }
+}
+
+// RegexExclusion returns a matcher who returns true if the string matches the provided regular expression pattern.
+func RegexExclusion(pattern string) Exclusion {
+ exp := regexp.MustCompile(pattern)
+
+ return func(s string) bool {
+ return exp.MatchString(s)
+ }
+}
diff --git a/internal/geoip/middleware.go b/internal/geoip/middleware.go
new file mode 100644
index 0000000..b8e9f1f
--- /dev/null
+++ b/internal/geoip/middleware.go
@@ -0,0 +1,70 @@
+// Copyright (C) 2022 The pages authors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+package geoip
+
+import (
+ "context"
+ "net"
+ "net/http"
+
+ "github.com/gorilla/mux"
+
+ "github.com/mjpitz/myago"
+)
+
+var key = myago.ContextKey("geoip.info")
+
+type Info struct {
+ CountryCode string
+}
+
+func Extract(ctx context.Context) Info {
+ val := ctx.Value(key)
+ v, ok := val.(Info)
+
+ if val == nil || !ok {
+ return Info{}
+ }
+
+ return v
+}
+
+type Interface interface {
+ Lookup(ip string) Info
+}
+
+type Empty struct{}
+
+func (e Empty) Lookup(ip string) Info {
+ return Info{}
+}
+
+func Middleware(geoip Interface) mux.MiddlewareFunc {
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
+ if err != nil {
+ http.Error(w, "", http.StatusBadRequest)
+ return
+ }
+
+ ctx := context.WithValue(r.Context(), key, geoip.Lookup(clientIP))
+
+ next.ServeHTTP(w, r.WithContext(ctx))
+ })
+ }
+}
diff --git a/internal/metrics/metrics.go b/internal/metrics/metrics.go
index a7d0078..b0d898c 100644
--- a/internal/metrics/metrics.go
+++ b/internal/metrics/metrics.go
@@ -31,8 +31,18 @@ var (
Namespace: namespace,
Subsystem: page,
Name: "view_count",
- Help: "page views",
+ Help: "the number of times a given page has been viewed and by what referrer",
},
[]string{"path", "referrer", "country"},
)
+
+ PageSessionDuration = promauto.NewHistogramVec(
+ prometheus.HistogramOpts{
+ Namespace: namespace,
+ Subsystem: page,
+ Name: "session_seconds",
+ Help: "how long someone spent on a given page",
+ },
+ []string{"path", "country"},
+ )
)
diff --git a/internal/middleware.go b/internal/pageviews/middleware.go
similarity index 50%
rename from internal/middleware.go
rename to internal/pageviews/middleware.go
index fc78a10..41bf13d 100644
--- a/internal/middleware.go
+++ b/internal/pageviews/middleware.go
@@ -14,73 +14,40 @@
// along with this program. If not, see .
//
-package internal
+package pageviews
import (
- "net"
"net/http"
- "path"
- "regexp"
- "strings"
"github.com/gorilla/mux"
+ "github.com/mjpitz/pages/internal/excludes"
+ "github.com/mjpitz/pages/internal/geoip"
"github.com/mjpitz/pages/internal/metrics"
)
-// GeoIP provides an abstraction for looking up a country code from an IP address.
-type GeoIP interface {
- Lookup(ip string) (countryCode string)
+type opt struct {
+ excludes []excludes.Exclusion
}
-type emptyGeoIP struct{}
+// Option provides a way to configure elements of the Middleware.
+type Option func(*opt)
-func (e emptyGeoIP) Lookup(ip string) (countryCode string) {
- return ""
-}
-
-// Matcher defines an abstraction for matching paths.
-type Matcher func(s string) bool
-
-// AnyMatcher returns a matcher who returns true if any of the provided matchers match the string.
-func AnyMatcher(matchers ...Matcher) Matcher {
- return func(s string) bool {
- for _, matcher := range matchers {
- if matcher(s) {
- return true
- }
- }
-
- return false
- }
-}
-
-// AssetMatcher returns a matcher who returns true when an asset file is requested.
-func AssetMatcher() Matcher {
- return func(s string) bool {
- return path.Ext(s) != ""
- }
-}
-
-// PrefixMatcher returns a matcher who returns true if the string matches the provided prefix.
-func PrefixMatcher(prefix string) Matcher {
- return func(s string) bool {
- return strings.HasPrefix(s, prefix)
- }
-}
-
-// RegexMatcher returns a matcher who returns true if the string matches the provided regular expression pattern.
-func RegexMatcher(pattern string) Matcher {
- exp := regexp.MustCompile(pattern)
-
- return func(s string) bool {
- return exp.MatchString(s)
+// Exclusions appends the provided rules to the excludes list. Any path that matches an exclusion will not be measured.
+func Exclusions(exclusions ...excludes.Exclusion) Option {
+ return func(o *opt) {
+ o.excludes = append(o.excludes, exclusions...)
}
}
// Middleware produces an HTTP middleware function that reports page views.
-func Middleware(geoIP GeoIP, excludes ...Matcher) mux.MiddlewareFunc {
- exclude := AnyMatcher(excludes...)
+func Middleware(opts ...Option) mux.MiddlewareFunc {
+ o := opt{}
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ exclude := excludes.AnyExclusion(o.excludes...)
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -89,18 +56,12 @@ func Middleware(geoIP GeoIP, excludes ...Matcher) mux.MiddlewareFunc {
return
}
- clientIP, _, err := net.SplitHostPort(r.RemoteAddr)
- if err != nil {
- http.Error(w, "", http.StatusBadRequest)
- return
- }
-
- countryCode := geoIP.Lookup(clientIP)
+ info := geoip.Extract(r.Context())
d := &writer{w, http.StatusOK}
defer func() {
if d.statusCode != http.StatusNotFound {
- metrics.PageViewCount.WithLabelValues(r.URL.Path, r.Referer(), countryCode).Inc()
+ metrics.PageViewCount.WithLabelValues(r.URL.Path, r.Referer(), info.CountryCode).Inc()
}
}()
diff --git a/internal/server.go b/internal/server.go
index f067f03..19487d1 100644
--- a/internal/server.go
+++ b/internal/server.go
@@ -20,6 +20,7 @@ import (
"context"
"net"
"net/http"
+ "path"
"github.com/gorilla/mux"
"github.com/prometheus/client_golang/prometheus"
@@ -31,11 +32,16 @@ import (
httpauth "github.com/mjpitz/myago/auth/http"
"github.com/mjpitz/myago/headers"
"github.com/mjpitz/myago/livetls"
+ "github.com/mjpitz/pages/internal/excludes"
+ "github.com/mjpitz/pages/internal/geoip"
+ "github.com/mjpitz/pages/internal/pageviews"
+ "github.com/mjpitz/pages/internal/session"
+ "github.com/mjpitz/pages/internal/web"
)
-// AdminConfig encapsulates configuration for the administrative process.
+// AdminConfig encapsulates configuration for the administrative endpoints.
type AdminConfig struct {
- Prefix string `json:"prefix" usage:"configure the prefix to use for admin endpoints" default:"/_admin"`
+ Prefix string `json:"prefix" usage:"configure the prefix to use for admin endpoints" default:"/_admin" hidden:"true"`
Username string `json:"username" usage:"specify the username used to authenticate requests with the admin endpoints" default:"admin"`
Password string `json:"password" usage:"specify the password used to authenticate requests with the admin endpoints"`
}
@@ -48,6 +54,7 @@ type BindConfig struct {
// ServerConfig defines configuration for a public and private interface.
type ServerConfig struct {
Admin AdminConfig `json:"admin"`
+ Session session.Config `json:"session"`
TLS livetls.Config `json:"tls"`
Public BindConfig `json:"public"`
Private BindConfig `json:"private"`
@@ -63,17 +70,26 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) {
private := mux.NewRouter()
private.Handle("/metrics", promhttp.Handler())
+ exclusions := []excludes.Exclusion{
+ excludes.AssetExclusion(),
+ excludes.PrefixExclusion(config.Admin.Prefix),
+ excludes.PrefixExclusion(config.Session.Prefix),
+ }
+
public := mux.NewRouter()
public.Use(
func(next http.Handler) http.Handler { return headers.HTTP(next) },
- Middleware(
- emptyGeoIP{},
- AssetMatcher(),
- PrefixMatcher(config.Admin.Prefix),
+ geoip.Middleware(geoip.Empty{}),
+ pageviews.Middleware(
+ pageviews.Exclusions(exclusions...),
+ ),
+ session.Middleware(
+ session.Exclusions(exclusions...),
+ session.JavaScriptPath(path.Join(config.Session.Prefix, "pages.js")),
),
)
- admin := public.Path(config.Admin.Prefix).Subrouter()
+ admin := public.PathPrefix(config.Admin.Prefix).Subrouter()
if config.Admin.Password != "" {
authenticate := basicauth.Static(config.Admin.Username, config.Admin.Password)
@@ -84,6 +100,15 @@ func NewServer(ctx context.Context, config ServerConfig) (*Server, error) {
})
}
+ {
+ var handler http.Handler = session.Handler()
+ handler = http.StripPrefix(config.Session.Prefix, handler)
+
+ session := public.PathPrefix(config.Session.Prefix).Subrouter()
+ session.HandleFunc("/pages.js", web.Handler()).Methods(http.MethodGet)
+ session.Handle("/", handler)
+ }
+
return &Server{
AdminMux: admin,
diff --git a/internal/session/config.go b/internal/session/config.go
new file mode 100644
index 0000000..a222b5c
--- /dev/null
+++ b/internal/session/config.go
@@ -0,0 +1,22 @@
+// Copyright (C) 2022 The pages authors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+package session
+
+// Config encapsulates configuration for the session endpoints.
+type Config struct {
+ Prefix string `json:"prefix" usage:"configure the prefix to use for recording sessions" default:"/_session" hidden:"true"`
+}
diff --git a/internal/session/handler.go b/internal/session/handler.go
new file mode 100644
index 0000000..a1aaa67
--- /dev/null
+++ b/internal/session/handler.go
@@ -0,0 +1,73 @@
+// Copyright (C) 2022 The pages authors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+package session
+
+import (
+ "net/http"
+ "time"
+
+ "github.com/gorilla/websocket"
+
+ "github.com/mjpitz/pages/internal/geoip"
+ "github.com/mjpitz/pages/internal/metrics"
+)
+
+type Request struct {
+ ID string
+ FullName string
+}
+
+func Handler() *Handle {
+ return &Handle{
+ upgrader: websocket.Upgrader{
+ ReadBufferSize: 1024,
+ WriteBufferSize: 1024,
+ },
+ }
+}
+
+type Handle struct {
+ upgrader websocket.Upgrader
+}
+
+func (h *Handle) ServeHTTP(w http.ResponseWriter, r *http.Request) {
+ conn, err := h.upgrader.Upgrade(w, r, nil)
+ defer func() { _ = conn.Close() }()
+
+ if err != nil {
+ // log
+ return
+ }
+
+ path := r.URL.Path
+ geoInfo := geoip.Extract(r.Context())
+
+ start := time.Now()
+ defer func() {
+ metrics.PageSessionDuration.WithLabelValues(path, geoInfo.CountryCode).Observe(time.Since(start).Seconds())
+ }()
+
+ for {
+ req := Request{}
+
+ err = conn.ReadJSON(&req)
+ if err != nil {
+ // log
+ return
+ }
+ }
+}
diff --git a/internal/session/middleware.go b/internal/session/middleware.go
new file mode 100644
index 0000000..b78830b
--- /dev/null
+++ b/internal/session/middleware.go
@@ -0,0 +1,124 @@
+// Copyright (C) 2022 The pages authors
+//
+// This program is free software: you can redistribute it and/or modify
+// it under the terms of the GNU Affero General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// This program is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+// GNU Affero General Public License for more details.
+//
+// You should have received a copy of the GNU Affero General Public License
+// along with this program. If not, see .
+//
+
+package session
+
+import (
+ "bytes"
+ "fmt"
+ "net/http"
+ "strconv"
+ "strings"
+
+ "github.com/gorilla/mux"
+
+ "github.com/mjpitz/pages/internal/excludes"
+)
+
+func script(path string) string {
+ return fmt.Sprintf(``, path)
+}
+
+type opt struct {
+ jsPath string
+ excludes []excludes.Exclusion
+}
+
+// Option provides a way to configure elements of the Middleware.
+type Option func(*opt)
+
+// JavaScriptPath configures the URL for the JS snippet.
+func JavaScriptPath(path string) Option {
+ return func(o *opt) {
+ o.jsPath = path
+ }
+}
+
+// Exclusions appends the provided rules to the excludes list. Any path that matches an exclusion will not be measured.
+func Exclusions(exclusions ...excludes.Exclusion) Option {
+ return func(o *opt) {
+ o.excludes = append(o.excludes, exclusions...)
+ }
+}
+
+// Middleware injects a JavaScript snippet that establishes a session with the server.
+func Middleware(opts ...Option) mux.MiddlewareFunc {
+ o := opt{}
+ for _, opt := range opts {
+ opt(&o)
+ }
+
+ exclude := excludes.AnyExclusion(o.excludes...)
+ injection := script(o.jsPath)
+
+ return func(next http.Handler) http.Handler {
+ return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
+ if exclude(r.URL.Path) {
+ next.ServeHTTP(w, r)
+ return
+ }
+
+ buffer := &bufferedResponseWriter{
+ header: http.Header{},
+ status: http.StatusOK,
+ buffer: bytes.NewBuffer(nil),
+ }
+
+ next.ServeHTTP(buffer, r)
+
+ for key := range buffer.header {
+ w.Header().Set(key, buffer.header.Get(key))
+ }
+
+ contents := buffer.buffer.Bytes()
+
+ if strings.Contains(buffer.header.Get("Content-Type"), "text/html") {
+ contents = bytes.TrimSpace(contents)
+ contents = bytes.TrimSuffix(contents, []byte("