├── .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 [](https://travis-ci.org/lanyonm/http-stats-collector) [](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 |
--------------------------------------------------------------------------------