Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions .gcloudignore
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ _cgo_*
_test*
*.out
_obj
ga-beacon
57 changes: 33 additions & 24 deletions ga-beacon.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down Expand Up @@ -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
//
Expand All @@ -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) {
Expand All @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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)
}
}
171 changes: 171 additions & 0 deletions ga-beacon_test.go
Original file line number Diff line number Diff line change
@@ -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)
})
}
8 changes: 8 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
@@ -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
)
22 changes: 22 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
@@ -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=
23 changes: 23 additions & 0 deletions page_test.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
<!DOCTYPE HTML>
<html>
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8" />
<title>GA account: UA-XXXXX-X</title>

<script>
(function(i,s,o,g,r,a,m){i['GoogleAnalyticsObject']=r;i[r]=i[r]||function(){
(i[r].q=i[r].q||[]).push(arguments)},i[r].l=1*new Date();a=s.createElement(o),
m=s.getElementsByTagName(o)[0];a.async=1;a.src=g;m.parentNode.insertBefore(a,m)
})(window,document,'script','//www.google-analytics.com/analytics.js','ga');

ga('create', 'UA-XXXXX-X', 'ga-beacon.appspot.com');
ga('send', 'pageview');
</script>
</head>

<body>
<p>GA account: UA-XXXXX-X</p>
<p>Beacon Referrer: https://example.com</p>
<p>Setup instructions: <a href="https://github.com/igrigorik/ga-beacon">https://github.com/igrigorik/ga-beacon</a></p>
</body>
</html>