One of the most important step to take while taking a website or app into production is analytics and usage statistics. This is important as it allows you to see how users are actually using your app, improve usability and inform future development decisions.
In this tutorial, I will describe how to monitor all requests an application is going to receive, we will use the data gotten from monitoring to track a few metrics such as:
We will start out by setting up our project directory. You will need to create a directory called analytics-dashboard
. The location of this directory will depend on the version of the Go toolchain you have:
<=1.11
, you should create the directory in $GOPATH/src/github.com/pusher-tutorials/analytics-dashboard
1.12
or greater, you can create the directory anywhere.In the newly created directory, create a .env
in the root directory with the following command:
$ touch .env
In the .env
file, you will need to add your credentials. Copy and paste the following contents into the file:
1// analytics-dashboard/.env 2 PUSHER_APP_ID=PUSHER_APP_ID 3 PUSHER_APP_KEY=PUSHER_APP_KEY 4 PUSHER_APP_SECRET=PUSHER_APP_SECRET 5 PUSHER_APP_CLUSTER=PUSHER_APP_CLUSTER 6 PUSHER_APP_SECURE="1"
Please make sure to replace the placeholders with your own credentials.
MongoDB is going to be used as a persistent datastore and we are going to make use of it’s calculation abilities to build out the functionality I described above.
Since we are building the application in Golang, we will need to fetch a client library that will assist us in connecting and querying the MongoDB database. To that, you should run the following command:
$ go get -u -v gopkg.in/mgo.v2/...
Once the above command succeeds, you will need to create a new file called analytics.go
. In this file, paste the following code:
1// analytics-dashboard/analytics.go 2 3 package main 4 5 import ( 6 "gopkg.in/mgo.v2" 7 "gopkg.in/mgo.v2/bson" 8 ) 9 10 const ( 11 collectionName = "request_analytics" 12 ) 13 14 type requestAnalytics struct { 15 URL string `json:"url"` 16 Method string `json:"method"` 17 RequestTime int64 `json:"request_time"` 18 Day string `json:"day"` 19 Hour int `json:"hour"` 20 } 21 22 type mongo struct { 23 sess *mgo.Session 24 } 25 26 func (m mongo) Close() error { 27 m.sess.Close() 28 return nil 29 } 30 31 func (m mongo) Write(r requestAnalytics) error { 32 return m.sess.DB("pusher_tutorial").C(collectionName).Insert(r) 33 } 34 35 func (m mongo) Count() (int, error) { 36 return m.sess.DB("pusher_tutorial").C(collectionName).Count() 37 } 38 39 type statsPerRoute struct { 40 ID struct { 41 Method string `bson:"method" json:"method"` 42 URL string `bson:"url" json:"url"` 43 } `bson:"_id" json:"id"` 44 NumberOfRequests int `bson:"numberOfRequests" json:"number_of_requests"` 45 } 46 47 func (m mongo) AverageResponseTime() (float64, error) { 48 49 type res struct { 50 AverageResponseTime float64 `bson:"averageResponseTime" json:"average_response_time"` 51 } 52 53 var ret = []res{} 54 55 var baseMatch = bson.M{ 56 "$group": bson.M{ 57 "_id": nil, 58 "averageResponseTime": bson.M{"$avg": "$requesttime"}, 59 }, 60 } 61 62 err := m.sess.DB("pusher_tutorial").C(collectionName). 63 Pipe([]bson.M{baseMatch}).All(&ret) 64 65 if len(ret) > 0 { 66 return ret[0].AverageResponseTime, err 67 } 68 69 return 0, nil 70 } 71 72 func (m mongo) StatsPerRoute() ([]statsPerRoute, error) { 73 74 var ret []statsPerRoute 75 76 var baseMatch = bson.M{ 77 "$group": bson.M{ 78 "_id": bson.M{"url": "$url", "method": "$method"}, 79 "responseTime": bson.M{"$avg": "$requesttime"}, 80 "numberOfRequests": bson.M{"$sum": 1}, 81 }, 82 } 83 84 err := m.sess.DB("pusher_tutorial").C(collectionName). 85 Pipe([]bson.M{baseMatch}).All(&ret) 86 return ret, err 87 } 88 89 type requestsPerDay struct { 90 ID string `bson:"_id" json:"id"` 91 NumberOfRequests int `bson:"numberOfRequests" json:"number_of_requests"` 92 } 93 94 func (m mongo) RequestsPerHour() ([]requestsPerDay, error) { 95 96 var ret []requestsPerDay 97 98 var baseMatch = bson.M{ 99 "$group": bson.M{ 100 "_id": "$hour", 101 "numberOfRequests": bson.M{"$sum": 1}, 102 }, 103 } 104 105 var sort = bson.M{ 106 "$sort": bson.M{ 107 "numberOfRequests": 1, 108 }, 109 } 110 111 err := m.sess.DB("pusher_tutorial").C(collectionName). 112 Pipe([]bson.M{baseMatch, sort}).All(&ret) 113 return ret, err 114 } 115 116 func (m mongo) RequestsPerDay() ([]requestsPerDay, error) { 117 118 var ret []requestsPerDay 119 120 var baseMatch = bson.M{ 121 "$group": bson.M{ 122 "_id": "$day", 123 "numberOfRequests": bson.M{"$sum": 1}, 124 }, 125 } 126 127 var sort = bson.M{ 128 "$sort": bson.M{ 129 "numberOfRequests": 1, 130 }, 131 } 132 133 err := m.sess.DB("pusher_tutorial").C(collectionName). 134 Pipe([]bson.M{baseMatch, sort}).All(&ret) 135 return ret, err 136 } 137 138 func newMongo(addr string) (mongo, error) { 139 sess, err := mgo.Dial(addr) 140 if err != nil { 141 return mongo{}, err 142 } 143 144 return mongo{ 145 sess: sess, 146 }, nil 147 } 148 149 type Data struct { 150 AverageResponseTime float64 `json:"average_response_time"` 151 StatsPerRoute []statsPerRoute `json:"stats_per_route"` 152 RequestsPerDay []requestsPerDay `json:"requests_per_day"` 153 RequestsPerHour []requestsPerDay `json:"requests_per_hour"` 154 TotalRequests int `json:"total_requests"` 155 } 156 157 func (m mongo) getAggregatedAnalytics() (Data, error) { 158 159 var data Data 160 161 totalRequests, err := m.Count() 162 if err != nil { 163 return data, err 164 } 165 166 stats, err := m.StatsPerRoute() 167 if err != nil { 168 return data, err 169 } 170 171 reqsPerDay, err := m.RequestsPerDay() 172 if err != nil { 173 return data, err 174 } 175 176 reqsPerHour, err := m.RequestsPerHour() 177 if err != nil { 178 return data, err 179 } 180 181 avgResponseTime, err := m.AverageResponseTime() 182 if err != nil { 183 return data, err 184 } 185 186 return Data{ 187 AverageResponseTime: avgResponseTime, 188 StatsPerRoute: stats, 189 RequestsPerDay: reqsPerDay, 190 RequestsPerHour: reqsPerHour, 191 TotalRequests: totalRequests, 192 }, nil 193 }
In the above, we have implemented a few queries on the MongoDB database:
StatsPerRoute
: Analytics for each route visitedRequestsPerDay
: Analytics per dayRequestsPerHour
: Analytics per hourThe next step is to add some HTTP endpoints a user can visit. Without those, the code above for querying MongoDB for analytics is redundant. You will also need to create a logging middleware that writes analytics to MongoDB. And to make it realtime, Pusher Channels will also be used.
To get started with that, you will need to create a file named main.go
. You can do that via the command below:
$ touch main.go
You will also need to fetch some libraries that will be used while building. You will need to run the command below to fetch them:
1$ go get github.com/go-chi/chi 2 $ go get github.com/joho/godotenv 3 $ go get github.com/pusher/pusher-http-go
In the newly created main.go
file, paste the following code:
1// analytics-dashboard/main.go 2 3 package main 4 5 import ( 6 "encoding/json" 7 "flag" 8 "fmt" 9 "html/template" 10 "log" 11 "net/http" 12 "os" 13 "path/filepath" 14 "strconv" 15 "strings" 16 "sync" 17 "time" 18 19 "github.com/go-chi/chi" 20 "github.com/joho/godotenv" 21 "github.com/pusher/pusher-http-go" 22 ) 23 24 const defaultSleepTime = time.Second * 2 25 26 func main() { 27 httpPort := flag.Int("http.port", 4000, "HTTP Port to run server on") 28 mongoDSN := flag.String("mongo.dsn", "localhost:27017", "DSN for mongoDB server") 29 30 flag.Parse() 31 32 if err := godotenv.Load(); err != nil { 33 log.Fatal("Error loading .env file") 34 } 35 36 appID := os.Getenv("PUSHER_APP_ID") 37 appKey := os.Getenv("PUSHER_APP_KEY") 38 appSecret := os.Getenv("PUSHER_APP_SECRET") 39 appCluster := os.Getenv("PUSHER_APP_CLUSTER") 40 appIsSecure := os.Getenv("PUSHER_APP_SECURE") 41 42 var isSecure bool 43 if appIsSecure == "1" { 44 isSecure = true 45 } 46 47 client := &pusher.Client{ 48 AppId: appID, 49 Key: appKey, 50 Secret: appSecret, 51 Cluster: appCluster, 52 Secure: isSecure, 53 HttpClient: &http.Client{ 54 Timeout: time.Second * 10, 55 }, 56 } 57 58 mux := chi.NewRouter() 59 60 log.Println("Connecting to MongoDB") 61 m, err := newMongo(*mongoDSN) 62 if err != nil { 63 log.Fatal(err) 64 } 65 66 log.Println("Successfully connected to MongoDB") 67 68 mux.Use(analyticsMiddleware(m, client)) 69 70 var once sync.Once 71 var t *template.Template 72 73 workDir, _ := os.Getwd() 74 filesDir := filepath.Join(workDir, "static") 75 fileServer(mux, "/static", http.Dir(filesDir)) 76 77 mux.Get("/", func(w http.ResponseWriter, r *http.Request) { 78 79 once.Do(func() { 80 tem, err := template.ParseFiles("static/index.html") 81 if err != nil { 82 log.Fatal(err) 83 } 84 85 t = tem.Lookup("index.html") 86 }) 87 88 t.Execute(w, nil) 89 }) 90 91 mux.Get("/api/analytics", analyticsAPI(m)) 92 mux.Get("/wait/{seconds}", waitHandler) 93 94 log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *httpPort), mux)) 95 } 96 97 func fileServer(r chi.Router, path string, root http.FileSystem) { 98 if strings.ContainsAny(path, "{}*") { 99 panic("FileServer does not permit URL parameters.") 100 } 101 102 fs := http.StripPrefix(path, http.FileServer(root)) 103 104 if path != "/" && path[len(path)-1] != '/' { 105 r.Get(path, http.RedirectHandler(path+"/", 301).ServeHTTP) 106 path += "/" 107 } 108 109 path += "*" 110 111 r.Get(path, http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 112 fs.ServeHTTP(w, r) 113 })) 114 } 115 116 func analyticsAPI(m mongo) http.HandlerFunc { 117 return func(w http.ResponseWriter, r *http.Request) { 118 119 data, err := m.getAggregatedAnalytics() 120 if err != nil { 121 log.Println(err) 122 123 json.NewEncoder(w).Encode(&struct { 124 Message string `json:"message"` 125 TimeStamp int64 `json:"timestamp"` 126 }{ 127 Message: "An error occurred while fetching analytics data", 128 TimeStamp: time.Now().Unix(), 129 }) 130 131 return 132 } 133 134 w.Header().Set("Content-Type", "application/json") 135 json.NewEncoder(w).Encode(data) 136 } 137 } 138 139 func analyticsMiddleware(m mongo, client *pusher.Client) func(next http.Handler) http.Handler { 140 return func(next http.Handler) http.Handler { 141 return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { 142 143 startTime := time.Now() 144 145 defer func() { 146 147 if strings.HasPrefix(r.URL.String(), "/wait") { 148 149 data := requestAnalytics{ 150 URL: r.URL.String(), 151 Method: r.Method, 152 RequestTime: time.Now().Unix() - startTime.Unix(), 153 Day: startTime.Weekday().String(), 154 Hour: startTime.Hour(), 155 } 156 157 if err := m.Write(data); err != nil { 158 log.Println(err) 159 } 160 161 aggregatedData, err := m.getAggregatedAnalytics() 162 if err == nil { 163 client.Trigger("analytics-dashboard", "data", aggregatedData) 164 } 165 } 166 }() 167 168 next.ServeHTTP(w, r) 169 }) 170 } 171 } 172 173 func waitHandler(w http.ResponseWriter, r *http.Request) { 174 var sleepTime = defaultSleepTime 175 176 secondsToSleep := chi.URLParam(r, "seconds") 177 n, err := strconv.Atoi(secondsToSleep) 178 if err == nil && n >= 2 { 179 sleepTime = time.Duration(n) * time.Second 180 } else { 181 n = 2 182 } 183 184 log.Printf("Sleeping for %d seconds", n) 185 time.Sleep(sleepTime) 186 w.Write([]byte(`Done`)) 187 }
While the above might seem like a lot, basically what has been done is:
.env
created earlier.Another reminder to update the
.env
file to contain your actual credentials
analyticsMiddleware
is used to capture all requests, and for requests that have the path wait/{seconds}
, a log is written to MongoDB. It is also sent to Pusher Channels.Before running the server, you need a frontend to visualize the analytics. The frontend is going to be as simple and usable as can be. You will need to create a new directory called static
in your root directory - analytics-dashboard
. That can be done with the following command:
$ mkdir analytics-dashboard/static
In the static
directory, create two files - index.html
and app.js
. You can run the command below to do just that:
$ touch static/{index.html,app.js}
Open the index.html
file and paste the following code:
1// analytics-dashboard/static/index.html 2 3 <!DOCTYPE html> 4 <html lang="en"> 5 6 <head> 7 <meta charset="UTF-8"> 8 <meta name="viewport" content="width=device-width, initial-scale=1.0"> 9 <meta http-equiv="X-UA-Compatible" content="ie=edge"> 10 <title>Realtime analytics dashboard</title> 11 <link rel="stylesheet" href="https://maxcdn.bootstrapcdn.com/bootstrap/3.3.7/css/bootstrap.min.css" 12 integrity="sha384-BVYiiSIFeK1dGmJRAkycuHAHRg32OmUcww7on3RYdg4Va+PmSTsz/K68vbdEjh4u" crossorigin="anonymous"> 13 </head> 14 <body> 15 <div class="container" id="app"></div> 16 <script src="https://cdnjs.cloudflare.com/ajax/libs/handlebars.js/4.1.2/handlebars.js"></script> 17 <script src="https://cdnjs.cloudflare.com/ajax/libs/axios/0.18.0/axios.min.js"></script> 18 <script src="https://js.pusher.com/4.3/pusher.min.js"></script> 19 <script src="/static/app.js"></script> 20 </body> 21 </html>
While that is an empty page, you will make use of JavaScript to fill it up with useful data. So you will also need to open up the app.js
file. In the app.js
file, paste the following code:
1// analytics-dashboard/static/app.js 2 3 const appDiv = document.getElementById('app'); 4 5 const tmpl = ` 6 <div class="row"> 7 <div class="col-md-5"> 8 <div class="card"> 9 <div class="card-body"> 10 <h5 class="card-title">Total requests</h5> 11 <div class="card-text"> 12 <h3>\{{total_requests}}</h3> 13 </div> 14 </div> 15 </div> 16 </div> 17 <div class="col-md-5"> 18 <div class="card"> 19 <div class="card-body"> 20 <h5 class="card-title">Average response time</h5> 21 <div class="card-text"> 22 <h3>\{{ average_response_time }} seconds</h3> 23 </div> 24 </div> 25 </div> 26 </div> 27 </div> 28 29 <div class="row"> 30 <div class="col-md-5"> 31 <div class="card"> 32 <div class="card-body"> 33 <h5 class="card-title">Busiest days of the week</h5> 34 <div class="card-text" style="width: 18rem"> 35 <ul class="list-group list-group-flush"> 36 {{#each requests_per_day}} 37 <li class="list-group-item"> 38 \{{ this.id }} (\{{ this.number_of_requests }} requests) 39 </li> 40 {{/each }} 41 </ul> 42 </div> 43 </div> 44 </div> 45 </div> 46 <div class="col-md-5"> 47 <div class="card"> 48 <div class="card-body"> 49 <h5 class="card-title">Busiest hours of day</h5> 50 <div class="card-text" style="width: 18rem;"> 51 <ul class="list-group list-group-flush"> 52 {{#each requests_per_hour}} 53 <li class="list-group-item"> 54 \{{ this.id }} (\{{ this.number_of_requests }} requests) 55 </li> 56 {{/each}} 57 </ul> 58 </div> 59 </div> 60 </div> 61 </div> 62 </div> 63 64 <div class="row"> 65 <div class="col-md-5"> 66 <div class="card"> 67 <div class="card-body"> 68 <h5 class="card-title">Most visited routes</h5> 69 <div class="card-text" style="width: 18rem;"> 70 <ul class="list-group list-group-flush"> 71 {{#each stats_per_route}} 72 <li class="list-group-item"> 73 \{{ this.id.method }} \{{ this.id.url }} (\{{ this.number_of_requests }} requests) 74 </li> 75 {{/each}} 76 </ul> 77 </div> 78 </div> 79 </div> 80 </div> 81 </div> 82 `; 83 84 const template = Handlebars.compile(tmpl); 85 86 writeData = data => { 87 appDiv.innerHTML = template(data); 88 }; 89 90 axios 91 .get('http://localhost:4000/api/analytics', {}) 92 .then(res => { 93 console.log(res.data); 94 writeData(res.data); 95 }) 96 .catch(err => { 97 console.error(err); 98 }); 99 100 const APP_KEY = 'PUSHER_APP_KEY'; 101 const APP_CLUSTER = 'PUSHER_CLUSTER'; 102 103 const pusher = new Pusher(APP_KEY, { 104 cluster: APP_CLUSTER, 105 }); 106 107 const channel = pusher.subscribe('analytics-dashboard'); 108 109 channel.bind('data', data => { 110 writeData(data); 111 });
Please replace
PUSHER_APP_KEY
andPUSHER_CLUSTER
with your own credentials.
In the above code, we defined a constant called tmpl
, it holds an HTML template which we will run through the Handlebars template engine to fill it up with actual data.
With this done, you can go ahead to run the Golang server one. You will need to go to the root directory - analytics-dashboard
and run the following command:
1$ go build 2 $ ./analytics-dashboard
Make sure you have a MongoDB instance running. If your MongoDB is running on a port other than the default 27017, make sure to add
-mongo.dsn "YOUR_DSN"
to the above command
Also make sure your credentials are in
.env
At this stage, you will need to open two browser tabs. Visit http://localhost:4000
in one and http://localhost:4000/wait/2
in the other. Refresh the tab where you have http://localhost:4000/wait/2
and go back to the other tab to see a breakdown of usage activity.
Note you can change the value of 2 in the url to any other digit.
In this tutorial, we’ve built a middleware that tracks every request, and a Golang application that calculates analytics of the tracked requests. We also built a dashboard that displays the relevant data. With Pusher Channels, we’ve been able to update the dashboard in realtime. The full source code can be found on GitHub.