├── .gitignore ├── .travis.yml ├── LICENSE ├── Makefile ├── README.md ├── handlers.go ├── handlers_test.go ├── main.go ├── recorders.go └── recorders_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | http-stats-collector 2 | coverage.out 3 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: go 2 | 3 | go: 4 | - 1.4 5 | 6 | install: 7 | - go get github.com/cactus/go-statsd-client/statsd 8 | - go get github.com/mattn/goveralls 9 | 10 | script: 11 | - go test -v -covermode=count -coverprofile=coverage.out 12 | - "$HOME/gopath/bin/goveralls -coverprofile=coverage.out -service=travis-ci -repotoken $COVERALLS_TOKEN" 13 | 14 | env: 15 | global: 16 | secure: ladNHHK2aYrI/vN0hOUvogn0azSLQo9ApqsBlFBuSweM8zlESHhhqlKVft8P29rYOQ2tlU5+7c0wFTexCdoMqNH9Py6i9RjnvHcdzKNML99Otp2vlDwAbJCnRnwj4JtwErBT1dkVvzrxWx6nSmmIAiuhsqO5Pv3tlRN9rjsAEF8= 17 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2015 Michael Lanyon 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: test build help 2 | 3 | BINARY := http-stats-collector 4 | 5 | help: 6 | @grep -E '^[a-zA-Z_-]+:.*?## .*$$' $(MAKEFILE_LIST) | awk 'BEGIN {FS = ":.*?## "}; {printf "\033[36m%-20s\033[0m %s\n", $$1, $$2}' 7 | 8 | all: clean test build ## clean, test, and build all the things! 9 | 10 | clean: ## delete dat binary! 11 | rm -f $(BINARY) 12 | 13 | build: *.go ## mmmm, compilation! 14 | go build -o $(BINARY) *.go 15 | 16 | test: ## avoid the footgun! 17 | go test -v 18 | 19 | run: all ## equivalent of doit.sh 20 | ./$(BINARY) 21 | 22 | cover: ## get you some test coverage! 23 | go test -race -covermode=count -coverprofile=coverage.out && go tool cover -html=coverage.out 24 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # http-stats-collector [![Build Status](https://travis-ci.org/lanyonm/http-stats-collector.svg)](https://travis-ci.org/lanyonm/http-stats-collector) [![Coverage Status](https://coveralls.io/repos/lanyonm/http-stats-collector/badge.svg)](https://coveralls.io/r/lanyonm/http-stats-collector) 2 | This program collects [Navigation Timing API](http://www.html5rocks.com/en/tutorials/webperformance/basics/) data and Javascript errors and forwards the information along to the specified recorders (like [StatsD](https://github.com/etsy/statsd/) or [Logstash](http://logstash.net/)). 3 | 4 | ## Navigation Timing 5 | The JSON structure the `/nav-timing` endpoint expects is: 6 | 7 | ```javascript 8 | { 9 | "page-uri": "/foo/bar", 10 | "nav-timing": { 11 | "dns":1, 12 | "connect":2, 13 | "ttfb":3, 14 | "basePage":4, 15 | "frontEnd":5 16 | } 17 | } 18 | ``` 19 | 20 | Javascript that will send this information can be as simple as the following: 21 | 22 | ```javascript 23 | 50 | ``` 51 | 52 | The `page-uri` will be converted into the appropriate format for the recorder and the stat pushed to that recorder. 53 | 54 | ## Javascript Errors 55 | The JSON structure the `/js-error` endpoint expects is: 56 | 57 | ```javascript 58 | { 59 | "page-uri": "fizz/buzz", 60 | "query-string": "param=value&other=not", 61 | "js-error": { 62 | "error-type": "ReferenceError", 63 | "description": "func is not defined" 64 | } 65 | } 66 | ``` 67 | 68 | Javascript that collects and sends this information can be as simple as the following: 69 | 70 | ```javascript 71 | 94 | ``` 95 | 96 | ## Building 97 | This will run tests as well. 98 | 99 | make all 100 | 101 | ## Running 102 | 103 | make run 104 | 105 | If you want to test the running system, you'll need to send it some stats. Send it this for Navigation Timing: 106 | 107 | ```bash 108 | curl -d '{"page-uri": "/foo/bar", "nav-timing":{"dns":1,"connect":2,"ttfb":3,"basePage":4,"frontEnd":5}}' -H "X-Real-Ip: 192.168.0.1" http://localhost:8080/nav-timing 109 | ``` 110 | And this for JS Error reporting: 111 | ```bash 112 | curl -d '{"page-uri": "/foo/bar", "query-string": "?param=true", "js-error":{"error-type": "ReferenceError", "description": "func is not defined"}}' -H "X-Real-Ip: 192.168.0.1" http://localhost:8080/js-error 113 | ``` 114 | 115 | ## Test Coverage 116 | 117 | make cover 118 | 119 | ## TODO 120 | 121 | - [ ] Create ability to specify a whitelist nav-timing page-uris 122 | - [ ] Write regex for StatsD validStat 123 | -------------------------------------------------------------------------------- /handlers.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "encoding/json" 5 | "log" 6 | "net/http" 7 | "reflect" 8 | "strings" 9 | "time" 10 | ) 11 | 12 | type NavTimingReport struct { 13 | Details NavTimingDetails `json:"nav-timing" statName:"navTiming"` 14 | Page string `json:"page-uri" statName:"pageUri"` 15 | Referer string `statName:"referer"` 16 | UserAgent string `statName:"userAgent` 17 | } 18 | 19 | type NavTimingDetails struct { 20 | DNS int64 `json:"dns" statName:"dns"` 21 | Connect int64 `json:"connect" statName:"connect"` 22 | TTFB int64 `json:"ttfb" statName:"ttfb"` 23 | BasePage int64 `json:"basePage" statName:"basePage"` 24 | FrontEnd int64 `json:"frontEnd" statName:"frontEnd"` 25 | } 26 | 27 | // for Navigation Timing API 28 | func NavTimingHandler(recorders []Recorder) http.HandlerFunc { 29 | return func(w http.ResponseWriter, req *http.Request) { 30 | var timing NavTimingReport 31 | 32 | if req.Method != "POST" { 33 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 34 | w.Header().Set("Allow", "POST") 35 | return 36 | } 37 | 38 | if err := json.NewDecoder(req.Body).Decode(&timing); err != nil { 39 | http.Error(w, "Error parsing JSON", http.StatusBadRequest) 40 | return 41 | } 42 | 43 | // You could consider this a flaw, but we don't send the stat anywhere 44 | // if it can't go to one of the recorders. 45 | for _, recorder := range recorders { 46 | if !recorder.validStat(timing.Page) { 47 | http.Error(w, "Invalid page-uri passed", http.StatusNotAcceptable) 48 | return 49 | } 50 | } 51 | 52 | // for each recorder we're sending all the NavTimingDetails stats 53 | t := reflect.TypeOf(timing.Details) 54 | v := reflect.ValueOf(timing.Details) 55 | for i := 0; i < v.NumField(); i++ { 56 | for _, recorder := range recorders { 57 | stat := recorder.cleanURI(timing.Page) + t.Field(i).Tag.Get("statName") 58 | val := v.Field(i).Int() 59 | recorder.pushStat(stat, val) 60 | } 61 | } 62 | } 63 | } 64 | 65 | type JsErrorReport struct { 66 | PageURI string `json:"page-uri"` 67 | QueryString string `json:"query-string"` 68 | Details JsError `json:"js-error"` 69 | ReportTime time.Time 70 | } 71 | 72 | type JsError struct { 73 | UserAgent string `json:"user-agent"` 74 | ErrorType string `json:"error-type"` 75 | Description string `json:"description"` 76 | } 77 | 78 | func JsErrorReportHandler() http.HandlerFunc { 79 | return func(w http.ResponseWriter, req *http.Request) { 80 | var jsError JsErrorReport 81 | 82 | if req.Method != "POST" { 83 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 84 | w.Header().Set("Allow", "POST") 85 | return 86 | } 87 | 88 | if err := json.NewDecoder(req.Body).Decode(&jsError); err != nil { 89 | http.Error(w, "Error parsing JSON", http.StatusBadRequest) 90 | return 91 | } 92 | 93 | jsError.ReportTime = time.Now().UTC() 94 | jsError.Details.UserAgent = req.UserAgent() 95 | 96 | // do something smart with the error 97 | dets, _ := json.Marshal(jsError) 98 | log.Println(strings.Join(req.Header["X-Real-Ip"], ""), "encountered a javascript error:", string(dets)) 99 | } 100 | } 101 | 102 | type CSPReport struct { 103 | Details CSPDetails `json:"csp-report" statName:"cspReport"` 104 | ReportTime time.Time `statName:"dateTime"` 105 | } 106 | 107 | type CSPDetails struct { 108 | DocumentUri string `json:"document-uri" statName:"documentUri" validate:"min=1,max=200"` 109 | Referrer string `json:"referrer" statName:"referrer" validate:"max=200"` 110 | BlockedUri string `json:"blocked-uri" statName:"blockedUri" validate:"max=200"` 111 | ViolatedDirective string `json:"violated-directive" statName:"violatedDirective" validate:"min=1,max=200,regexp=^[a-z0-9 '/\\*\\.:;-]+$"` 112 | } 113 | 114 | // for Content Security Policy 115 | func CSPReportHandler() http.HandlerFunc { 116 | return func(w http.ResponseWriter, req *http.Request) { 117 | var report CSPReport 118 | 119 | if req.Method != "POST" { 120 | http.Error(w, "Method not allowed", http.StatusMethodNotAllowed) 121 | w.Header().Set("Allow", "POST") 122 | return 123 | } 124 | 125 | if err := json.NewDecoder(req.Body).Decode(&report); err != nil { 126 | http.Error(w, "Error parsing JSON", http.StatusBadRequest) 127 | return 128 | } 129 | 130 | report.ReportTime = time.Now().UTC() 131 | 132 | // if validationError := validator.Validate(report); validationError != nil { 133 | // log.Println("Request failed validation:", validationError) 134 | // log.Println("Failed with report:", report) 135 | // http.Error(w, "Unable to validate JSON", http.StatusBadRequest) 136 | // return 137 | // } 138 | 139 | // do something smart with the report 140 | log.Println("policy violation from", strings.Join(req.Header["X-Real-Ip"], ""), "was:", report.Details.ViolatedDirective) 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /handlers_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "bytes" 5 | "net/http" 6 | "net/http/httptest" 7 | "testing" 8 | ) 9 | 10 | var ( 11 | timing_data = `{"page-uri":"/foo/bar", 12 | "nav-timing": { 13 | "dns":1, 14 | "connect":2, 15 | "ttfb":3, 16 | "basePage":4, 17 | "frontEnd":5 18 | } 19 | }` 20 | js_data = `{"page-uri": "fizz/buzz", 21 | "query-string": "param=value&other=not", 22 | "js-error": { 23 | "error-type": "ReferenceError", 24 | "description": "func is not defined" 25 | } 26 | }` 27 | csp_data = `{"csp-report": { 28 | "document-uri": "https://www.example.com/", 29 | "blocked-uri": "https://evil.example.com/", 30 | "violated-directive": "directive", 31 | "original-policy": "policy" 32 | }}` 33 | recorders = []Recorder{StatsDRecorder{}} 34 | ) 35 | 36 | func TestNavTimingHandlerSuccess(t *testing.T) { 37 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(timing_data)) 38 | req.Header.Add("X-Real-Ip", "192.168.0.1") 39 | req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:36.0) Gecko/20100101 Firefox/36.0") 40 | resp := httptest.NewRecorder() 41 | 42 | NavTimingHandler(recorders)(resp, req) 43 | 44 | const expected_response_code = 200 45 | if code := resp.Code; code != expected_response_code { 46 | t.Errorf("received %v response code, expected %v", code, expected_response_code) 47 | } 48 | } 49 | 50 | func TestNavTimingHandlerNotPOST(t *testing.T) { 51 | req, _ := http.NewRequest("GET", "/r", bytes.NewBufferString(timing_data)) 52 | resp := httptest.NewRecorder() 53 | 54 | NavTimingHandler(recorders)(resp, req) 55 | 56 | const expected_response_code = 405 57 | if code := resp.Code; code != expected_response_code { 58 | t.Errorf("received %v response code, expected %v", code, expected_response_code) 59 | } 60 | } 61 | 62 | func TestNavTimingHandlerInvalidJSON(t *testing.T) { 63 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(`{invalid:"json"}`)) 64 | resp := httptest.NewRecorder() 65 | 66 | NavTimingHandler(recorders)(resp, req) 67 | 68 | const expected_response_code = 400 69 | if code := resp.Code; code != expected_response_code { 70 | t.Errorf("expected %v, but received %v response code", expected_response_code, code) 71 | } 72 | const expected_error_message = "Error parsing JSON\n" 73 | if msg := resp.Body.String(); msg != expected_error_message { 74 | t.Errorf("expected \"%v\", but found \"%v\" error message", expected_error_message, msg) 75 | } 76 | } 77 | 78 | func TestNavTimingHandlerInvalidPageURI(t *testing.T) { 79 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(`{"page-uri":"/foo/bar///"}`)) 80 | resp := httptest.NewRecorder() 81 | 82 | NavTimingHandler(recorders)(resp, req) 83 | 84 | const expected_response_code = 406 85 | if code := resp.Code; code != expected_response_code { 86 | t.Errorf("expected %v, but received %v response code", expected_response_code, code) 87 | } 88 | const expected_error_message = "Invalid page-uri passed\n" 89 | if msg := resp.Body.String(); msg != expected_error_message { 90 | t.Errorf("expected \"%v\", but found \"%v\" error message", expected_error_message, msg) 91 | } 92 | } 93 | 94 | func TestJsErrorHandlerSuccess(t *testing.T) { 95 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(js_data)) 96 | req.Header.Add("X-Real-Ip", "192.168.0.1") 97 | req.Header.Add("User-Agent", "Mozilla/5.0 (X11; Ubuntu; Linux x86_64; rv:36.0) Gecko/20100101 Firefox/36.0") 98 | resp := httptest.NewRecorder() 99 | 100 | JsErrorReportHandler()(resp, req) 101 | 102 | const expected_response_code = 200 103 | if code := resp.Code; code != expected_response_code { 104 | t.Errorf("received %v response code, expected %v", code, expected_response_code) 105 | } 106 | } 107 | 108 | func TestJsErrorHandlerNotPOST(t *testing.T) { 109 | req, _ := http.NewRequest("GET", "/r", bytes.NewBufferString(js_data)) 110 | resp := httptest.NewRecorder() 111 | 112 | JsErrorReportHandler()(resp, req) 113 | 114 | const expected_response_code = 405 115 | if code := resp.Code; code != expected_response_code { 116 | t.Errorf("received %v response code, expected %v", code, expected_response_code) 117 | } 118 | } 119 | 120 | func TestJsErrorHandlerInvalidJSON(t *testing.T) { 121 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(`{invalid:"json"}`)) 122 | resp := httptest.NewRecorder() 123 | 124 | JsErrorReportHandler()(resp, req) 125 | 126 | const expected_response_code = 400 127 | if code := resp.Code; code != expected_response_code { 128 | t.Errorf("expected %v, but received %v response code", expected_response_code, code) 129 | } 130 | const expected_error_message = "Error parsing JSON\n" 131 | if msg := resp.Body.String(); msg != expected_error_message { 132 | t.Errorf("expected \"%v\", but found \"%v\" error message", expected_error_message, msg) 133 | } 134 | } 135 | 136 | func TestCSPReportHandlerSuccess(t *testing.T) { 137 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(csp_data)) 138 | req.Header.Add("X-Real-Ip", "192.168.0.1") 139 | resp := httptest.NewRecorder() 140 | 141 | CSPReportHandler()(resp, req) 142 | 143 | const expected_response_code = 200 144 | if code := resp.Code; code != expected_response_code { 145 | t.Errorf("received %v response code, expected %v", code, expected_response_code) 146 | } 147 | } 148 | 149 | func TestCSPReportHandlerNotPOST(t *testing.T) { 150 | req, _ := http.NewRequest("GET", "/r", bytes.NewBufferString(csp_data)) 151 | resp := httptest.NewRecorder() 152 | 153 | CSPReportHandler()(resp, req) 154 | 155 | const expected_response_code = 405 156 | if code := resp.Code; code != expected_response_code { 157 | t.Errorf("received %v response code, expected %v", code, expected_response_code) 158 | } 159 | } 160 | 161 | func TestCSPReportHandlerInvalidJSON(t *testing.T) { 162 | req, _ := http.NewRequest("POST", "/r", bytes.NewBufferString(`{invalid:"json"}`)) 163 | resp := httptest.NewRecorder() 164 | 165 | CSPReportHandler()(resp, req) 166 | 167 | const expected_response_code = 400 168 | if code := resp.Code; code != expected_response_code { 169 | t.Errorf("expected %v, but received %v response code", expected_response_code, code) 170 | } 171 | const expected_error_message = "Error parsing JSON\n" 172 | if msg := resp.Body.String(); msg != expected_error_message { 173 | t.Errorf("expected \"%v\", but found \"%v\" error message", expected_error_message, msg) 174 | } 175 | } 176 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Application to collect HTTP stats and send them to various storage. 2 | package main 3 | 4 | import ( 5 | "flag" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | 10 | "github.com/cactus/go-statsd-client/statsd" 11 | ) 12 | 13 | // run the server 14 | func main() { 15 | var ( 16 | port = flag.Int("port", 8080, "Server listen port.") 17 | statsHostPort = flag.String("statsHostPort", "127.0.0.1:8125", "host:port of statsd server") 18 | statsPrefix = flag.String("statsPrefix", "http-stats-collector", "the prefix used when stats are sent to statsd") 19 | ) 20 | flag.Parse() 21 | 22 | // Create recorders and pass those to handlers. 23 | // Not sure if this is the best way to continue, but it's a point along 24 | // the process. 25 | var client statsd.Statter 26 | client, err := statsd.NewClient(*statsHostPort, *statsPrefix) 27 | if err != nil { 28 | log.Fatal(err) 29 | } 30 | defer client.Close() 31 | 32 | recorders := []Recorder{StatsDRecorder{client}} 33 | http.HandleFunc("/nav-timing", NavTimingHandler(recorders)) 34 | http.HandleFunc("/js-error", JsErrorReportHandler()) 35 | http.HandleFunc("/csp-report", CSPReportHandler()) 36 | 37 | log.Println("http-stats-collector: listening on port", *port) 38 | 39 | log.Fatal(http.ListenAndServe(fmt.Sprintf(":%d", *port), nil)) 40 | } 41 | -------------------------------------------------------------------------------- /recorders.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "log" 5 | "strings" 6 | 7 | "github.com/cactus/go-statsd-client/statsd" 8 | ) 9 | 10 | // This interface is used to represent the functionality of clients/objects 11 | // that send information to be persisted or further processed. 12 | type Recorder interface { 13 | pushStat(stat string, value int64) bool 14 | cleanURI(input string) string 15 | validStat(stat string) bool 16 | } 17 | 18 | type StatsDRecorder struct { 19 | Statter statsd.Statter 20 | } 21 | 22 | // Push stats to StatsD. 23 | // This assumes that the data being written is always timing data and we are 24 | // always collecting all the samples. 25 | func (statsd StatsDRecorder) pushStat(stat string, value int64) bool { 26 | if statsd.Statter != nil { 27 | err := statsd.Statter.Timing(stat, value, 1.0) 28 | if err != nil { 29 | log.Fatal("there was an error sending the statsd timing", err) 30 | return false 31 | } 32 | } 33 | return true 34 | } 35 | 36 | // The valid page-uri checker for StatsD. We don't want to accept anything 37 | // that the storage would have trouble handing. 38 | func (statsd StatsDRecorder) validStat(stat string) bool { 39 | return !strings.ContainsAny(stat, "&#") && strings.Index(stat, "//") == -1 40 | } 41 | 42 | // Clean up the page-uri. 43 | // This will strip the leading and trailing /'s and replace the rest with '.' 44 | func (statsd StatsDRecorder) cleanURI(input string) string { 45 | //replace rightmost / with "index" 46 | ret := input 47 | if last := len(ret) - 1; last >= 0 && ret[last] == '/' { 48 | ret = ret + "index" 49 | } 50 | ret = strings.TrimLeft(ret, "/") 51 | ret = strings.Split(ret, ".")[0] 52 | ret = strings.Replace(ret, "/", ".", -1) 53 | ret = strings.ToLower(ret) 54 | if len(ret) > 0 { 55 | ret = ret + "." 56 | } 57 | return ret 58 | } 59 | -------------------------------------------------------------------------------- /recorders_test.go: -------------------------------------------------------------------------------- 1 | package main 2 | 3 | import ( 4 | "testing" 5 | ) 6 | 7 | var ( 8 | recorder = StatsDRecorder{} 9 | ) 10 | 11 | var validStatTests = []struct { 12 | Input string 13 | Expected bool 14 | }{ 15 | {"/", true}, 16 | {"foo/bar", true}, 17 | {"/foo/bar", true}, 18 | {"/foo/bar/", true}, 19 | {"/foo/bar///", false}, 20 | {"/about.html", true}, 21 | {"/company/about.png.php", true}, 22 | } 23 | 24 | func TestStatsdValidStat(t *testing.T) { 25 | for _, tt := range validStatTests { 26 | if ret := recorder.validStat(tt.Input); ret != tt.Expected { 27 | t.Errorf("input was %v and expected %v, but got %v", tt.Input, tt.Expected, ret) 28 | } 29 | } 30 | } 31 | 32 | var uriTests = []struct { 33 | Input string 34 | Expected string 35 | }{ 36 | {"/", "index."}, 37 | {"foo/bar", "foo.bar."}, 38 | {"/foo/bar", "foo.bar."}, 39 | {"/foo/bar/", "foo.bar.index."}, 40 | {"/about.html", "about."}, 41 | {"/company/about.png.php", "company.about."}, 42 | } 43 | 44 | func TestCleanURI(t *testing.T) { 45 | for _, tt := range uriTests { 46 | if ret := recorder.cleanURI(tt.Input); ret != tt.Expected { 47 | t.Errorf("input was %v and expected %v, but got %v", tt.Input, tt.Expected, ret) 48 | } 49 | } 50 | } 51 | --------------------------------------------------------------------------------