diff --git a/.gcloudignore b/.gcloudignore new file mode 100644 index 00000000..199e6d9f --- /dev/null +++ b/.gcloudignore @@ -0,0 +1,25 @@ +# This file specifies files that are *not* uploaded to Google Cloud Platform +# using gcloud. It follows the same syntax as .gitignore, with the addition of +# "#!include" directives (which insert the entries of the given .gitignore-style +# file at that point). +# +# For more information, run: +# $ gcloud topic gcloudignore +# +.gcloudignore +# If you would like to upload your .git directory, .gitignore file or files +# from your .gitignore file, remove the corresponding line +# below: +.git +.gitignore + +# Binaries for programs and plugins +*.exe +*.exe~ +*.dll +*.so +*.dylib +# Test binary, build with `go test -c` +*.test +# Output of the go coverage tool, specifically when used with LiteIDE +*.out \ No newline at end of file diff --git a/.gitignore b/.gitignore index 252f74e8..9bebe3fe 100644 --- a/.gitignore +++ b/.gitignore @@ -7,3 +7,4 @@ _cgo_* _test* *.out _obj +ga-beacon \ No newline at end of file diff --git a/ga-beacon.go b/ga-beacon.go index 51508bdf..928e7cb4 100644 --- a/ga-beacon.go +++ b/ga-beacon.go @@ -13,11 +13,9 @@ import ( "os" "strings" "time" - - "google.golang.org/appengine/delay" ) -const beaconURL = "http://www.google-analytics.com/collect" +var beaconURL = "http://www.google-analytics.com/collect" var ( pixel = mustReadFile("static/pixel.gif") @@ -64,26 +62,26 @@ func generateUUID(cid *string) error { return nil } -var delayHit = delay.Func("collect", logHit) +// var delayHit = delay.Func("collect", logHit) -func sendToGA(c context.Context, ua string, ip string, cid string, values url.Values) error { +func sendToGA(c context.Context, ua string, ip string, cid string, values url.Values) { client := &http.Client{} req, _ := http.NewRequest("POST", beaconURL, strings.NewReader(values.Encode())) - req.Header.Add("User-Agent", ua) + if ua != "" { + req.Header.Add("User-Agent", ua) + } req.Header.Add("Content-Type", "application/x-www-form-urlencoded") if resp, err := client.Do(req); err != nil { log.Printf("GA collector POST error: %s", err.Error()) - return err } else { log.Printf("GA collector status: %v, cid: %v, ip: %s", resp.Status, cid, ip) log.Printf("Reported payload: %v", values) } - return nil } -func logHit(c context.Context, params []string, query url.Values, ua string, ip string, cid string) error { +func logHit(c context.Context, params []string, query url.Values, ua string, ip string, cid string) { // 1) Initialize default values from path structure // 2) Allow query param override to report arbitrary values to GA // @@ -95,14 +93,17 @@ func logHit(c context.Context, params []string, query url.Values, ua string, ip "tid": {params[0]}, // tracking / property ID "cid": {cid}, // unique client ID (server generated UUID) "dp": {params[1]}, // page path - "uip": {ip}, // IP address of the user + } + + if ip != "" { + payload["uip"] = []string{ip} // IP address of the user } for key, val := range query { payload[key] = val } - return sendToGA(c, ua, ip, cid, payload) + sendToGA(c, ua, ip, cid, payload) } func handler(w http.ResponseWriter, r *http.Request) { @@ -119,14 +120,16 @@ func handler(w http.ResponseWriter, r *http.Request) { // activate referrer path if ?useReferer is used and if referer exists if _, ok := query["useReferer"]; ok { - if len(refOrg) != 0 { - referer := strings.Replace(strings.Replace(refOrg, "http://", "", 1), "https://", "", 1) - if len(referer) != 0 { - // if the useReferer is present and the referer information exists - // the path is ignored and the beacon referer information is used instead. - params = strings.SplitN(strings.Trim(r.URL.Path, "/")+"/"+referer, "/", 2) - } + referer := strings.Replace(strings.Replace(refOrg, "http://", "", 1), "https://", "", 1) + + if referer == "" { + http.Error(w, "could not extract referer from headers", http.StatusBadRequest) + return } + + // if the useReferer is present and the referer information exists + // the path is ignored and the beacon referer information is used instead. + params = strings.SplitN(strings.Trim(r.URL.Path, "/")+"/"+referer, "/", 2) } // /account -> account template if len(params) == 1 { @@ -138,7 +141,7 @@ func handler(w http.ResponseWriter, r *http.Request) { Referer: refOrg, } if err := pageTemplate.ExecuteTemplate(w, "page.html", templateParams); err != nil { - http.Error(w, "could not show account page", 500) + http.Error(w, "could not show account page", http.StatusInternalServerError) log.Printf("Cannot execute template: %v", err) } return @@ -168,21 +171,27 @@ func handler(w http.ResponseWriter, r *http.Request) { // delayHit.Call(c, params, r.Header.Get("User-Agent"), cid) } + var err error + // Write out GIF pixel or badge, based on presence of "pixel" param. if _, ok := query["pixel"]; ok { w.Header().Set("Content-Type", "image/gif") - w.Write(pixel) + _, err = w.Write(pixel) } else if _, ok := query["gif"]; ok { w.Header().Set("Content-Type", "image/gif") - w.Write(badgeGif) + _, err = w.Write(badgeGif) } else if _, ok := query["flat"]; ok { w.Header().Set("Content-Type", "image/svg+xml") - w.Write(badgeFlat) + _, err = w.Write(badgeFlat) } else if _, ok := query["flat-gif"]; ok { w.Header().Set("Content-Type", "image/gif") - w.Write(badgeFlatGif) + _, err = w.Write(badgeFlatGif) } else { w.Header().Set("Content-Type", "image/svg+xml") - w.Write(badge) + _, err = w.Write(badge) + } + + if err != nil { + log.Print(err) } } diff --git a/ga-beacon_test.go b/ga-beacon_test.go new file mode 100644 index 00000000..8bebc420 --- /dev/null +++ b/ga-beacon_test.go @@ -0,0 +1,171 @@ +package main + +import ( + "io" + "io/ioutil" + "net/http" + "net/http/httptest" + "testing" + "time" + + "github.com/stretchr/testify/assert" +) + +const ( + defaultTID = "UA-XXXXX-X" + defaultDP = "homepage" + defaultURL = "/" + defaultTID + "/" + defaultDP +) + +// Record the response from the handler function +func record(req *http.Request) *httptest.ResponseRecorder { + rec := httptest.NewRecorder() + handler(rec, req) + return rec +} + +// Create a request for a URL +func newRequest(t *testing.T, url string) *http.Request { + req, err := http.NewRequest("GET", url, nil) + if err != nil { + t.Fatal(err) + } + return req +} + +// Record the response for a URL +func recordURL(t *testing.T, url string) *httptest.ResponseRecorder { + return record(newRequest(t, url)) +} + +// Read a file +func readFile(t *testing.T, path string) []byte { + b, err := ioutil.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} + +// Read an input stream +func readAll(t *testing.T, r io.Reader) []byte { + b, err := ioutil.ReadAll(r) + if err != nil { + t.Fatal(err) + } + return b +} + +// Test the correct data is sent to Google Analytics +func testBeaconRequest(t *testing.T, r *http.Request, tid string, dp string) { + assert.Equal(t, "POST", r.Method) + assert.Equal(t, "application/x-www-form-urlencoded", r.Header.Get("Content-Type")) + err := r.ParseForm() + if err != nil { + t.Fatal(err) + } + assert.Equal(t, "1", r.PostForm.Get("v")) + assert.Equal(t, "pageview", r.PostForm.Get("t")) + assert.Equal(t, tid, r.PostForm.Get("tid")) + assert.Equal(t, dp, r.PostForm.Get("dp")) + // The requests are not actually sent, so should not have an IP address + assert.Empty(t, r.PostForm.Get("uip")) +} + +// Test the tracking request +func testTrackRequest(t *testing.T, tid string, dp string, cid string, req *http.Request) *http.Response { + if req == nil { + req = newRequest(t, "/"+tid+"/"+dp) + } + var cidf string + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + testBeaconRequest(t, r, tid, dp) + cidf = r.PostForm.Get("cid") + if cid != "" { + assert.Equal(t, cid, cidf) + } + })) + defer server.Close() + beaconURL = server.URL + res := record(req).Result() + assert.Equal(t, http.StatusOK, res.StatusCode) + assert.Equal(t, "no-cache, no-store, must-revalidate, private", res.Header.Get("Cache-Control")) + expires, err := time.Parse(http.TimeFormat, res.Header.Get("Expires")) + if err != nil { + t.Fatal(err) + } + assert.True(t, expires.Before(time.Now())) + if cid == "" { + // ensure cid cookie has been set + var cidc string + for _, c := range res.Cookies() { + if c.Name == "cid" { + cidc = c.Value + break + } + } + assert.Equal(t, cidf, cidc) + } + return res +} + +func TestBeacon(t *testing.T) { + t.Run("redirect on no params", func(t *testing.T) { + rec := recordURL(t, "/") + assert.Equal(t, http.StatusFound, rec.Code) + assert.Equal(t, "https://github.com/igrigorik/ga-beacon", rec.Header().Get("Location")) + }) + t.Run("account page", func(t *testing.T) { + req := newRequest(t, "/UA-XXXXX-X") + req.Header.Set("Referer", "https://example.com") + rec := record(req) + assert.Equal(t, http.StatusOK, rec.Code) + assert.Equal(t, readFile(t, "page_test.html"), rec.Body.Bytes()) + }) + t.Run("badge", func(t *testing.T) { + res := testTrackRequest(t, defaultTID, defaultDP, "", nil) + assert.Equal(t, "image/svg+xml", res.Header.Get("Content-Type")) + assert.Equal(t, readFile(t, "static/badge.svg"), readAll(t, res.Body)) + }) + t.Run("pixel", func(t *testing.T) { + res := testTrackRequest(t, defaultTID, defaultDP, "", newRequest(t, defaultURL+"?pixel")) + assert.Equal(t, "image/gif", res.Header.Get("Content-Type")) + assert.Equal(t, readFile(t, "static/pixel.gif"), readAll(t, res.Body)) + }) + t.Run("badge gif", func(t *testing.T) { + res := testTrackRequest(t, defaultTID, defaultDP, "", newRequest(t, defaultURL+"?gif")) + assert.Equal(t, "image/gif", res.Header.Get("Content-Type")) + assert.Equal(t, readFile(t, "static/badge.gif"), readAll(t, res.Body)) + }) + t.Run("badge flat", func(t *testing.T) { + res := testTrackRequest(t, defaultTID, defaultDP, "", newRequest(t, defaultURL+"?flat")) + assert.Equal(t, "image/svg+xml", res.Header.Get("Content-Type")) + assert.Equal(t, readFile(t, "static/badge-flat.svg"), readAll(t, res.Body)) + }) + t.Run("badge flat gif", func(t *testing.T) { + res := testTrackRequest(t, defaultTID, defaultDP, "", newRequest(t, defaultURL+"?flat-gif")) + assert.Equal(t, "image/gif", res.Header.Get("Content-Type")) + assert.Equal(t, readFile(t, "static/badge-flat.gif"), readAll(t, res.Body)) + }) + t.Run("referer as path", func(t *testing.T) { + req := newRequest(t, "/"+defaultTID+"?useReferer") + t.Run("warn on missing referer", func(t *testing.T) { + rec := record(req) + assert.Equal(t, http.StatusBadRequest, rec.Code) + assert.Contains(t, rec.Body.String(), "could not extract referer from headers") + }) + dp := "example.com" + req.Header.Set("Referer", "https://"+dp) + testTrackRequest(t, defaultTID, dp, "", req) + }) + t.Run("existing cid", func(t *testing.T) { + req := newRequest(t, defaultURL) + cid := "5d7b632fef264b76a7938362e5aba2c8" + req.AddCookie(&http.Cookie{ + Name: "cid", + Value: cid, + Path: "/" + defaultTID, + }) + testTrackRequest(t, defaultTID, defaultDP, cid, req) + }) +} diff --git a/go.mod b/go.mod new file mode 100644 index 00000000..aae8925b --- /dev/null +++ b/go.mod @@ -0,0 +1,8 @@ +module github.com/igrigorik/ga-beacon + +go 1.15 + +require ( + github.com/stretchr/testify v1.7.0 + google.golang.org/appengine v1.6.7 +) diff --git a/go.sum b/go.sum new file mode 100644 index 00000000..cb1b3698 --- /dev/null +++ b/go.sum @@ -0,0 +1,22 @@ +github.com/davecgh/go-spew v1.1.0 h1:ZDRjVQ15GmhC3fiQ8ni8+OwkZQO4DARzQgrnXU1Liz8= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/golang/protobuf v1.3.1 h1:YF8+flBXS5eO826T4nzqPrxfhQThhXl0YzfuUPu4SBg= +github.com/golang/protobuf v1.3.1/go.mod h1:6lQm79b+lXiMfvg/cZm0SGofjICqVBUtrP5yJMmIC1U= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/objx v0.1.0 h1:4G4v2dO3VZwixGIRoQ5Lfboy6nUhCyYzaqnIAPPhYs4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +golang.org/x/crypto v0.0.0-20190308221718-c2843e01d9a2/go.mod h1:djNgcEr1/C05ACkg1iLfiJU5Ep61QUkGW8qpdssI0+w= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65 h1:+rhAzEzT3f4JtomfC371qB+0Ola2caSKcY69NUBZrRQ= +golang.org/x/net v0.0.0-20190603091049-60506f45cf65/go.mod h1:HSz+uSET+XFnRR8LxR5pz3Of3rY3CfYBVs4xY44aLks= +golang.org/x/sys v0.0.0-20190215142949-d0b11bdaac8a/go.mod h1:STP8DvDyc/dI5b8T5hshtkjS+E42TnysNCUPdjciGhY= +golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ= +golang.org/x/text v0.3.2/go.mod h1:bEr9sfX3Q8Zfm5fL9x+3itogRgK3+ptLWKqgva+5dAk= +golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= +google.golang.org/appengine v1.6.7 h1:FZR1q0exgwxzPzp/aF+VccGrSfxfPpkBqjIIEq3ru6c= +google.golang.org/appengine v1.6.7/go.mod h1:8WjMMxjGQR8xUklV/ARdw2HLXBOI7O7uCIDZVag1xfc= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/page_test.html b/page_test.html new file mode 100644 index 00000000..9f024041 --- /dev/null +++ b/page_test.html @@ -0,0 +1,23 @@ + + +
+ +GA account: UA-XXXXX-X
+Beacon Referrer: https://example.com
+Setup instructions: https://github.com/igrigorik/ga-beacon
+ +