Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reporter package #116

Merged
merged 18 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from 8 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
211 changes: 211 additions & 0 deletions x/reporter/reporter.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,211 @@
// Copyright 2023 Jigsaw Operations LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package reporter
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
package reporter
package report


import (
"bytes"
"encoding/json"
"fmt"
"io"
"log"
"math/rand"
"net/http"
"net/url"
"sort"
"time"
)

var debugLog log.Logger = *log.New(io.Discard, "", 0)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should remove all logging from this library.

Suggested change
var debugLog log.Logger = *log.New(io.Discard, "", 0)

var httpClient = &http.Client{}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pass the client to the RemoteReporter

Suggested change
var httpClient = &http.Client{}


type ConnectivityReport struct {
// Connection setup
Connection interface{} `json:"connection"`
// Observations
Time time.Time `json:"time"`
DurationMs int64 `json:"durationMs"`
Error interface{} `json:"error"`
}

type Report interface {
IsSuccess() bool
CanMarshal() bool
}
amircybersec marked this conversation as resolved.
Show resolved Hide resolved

// ConnectivityReport implements the Report interface
func (r ConnectivityReport) IsSuccess() bool {
if r.Error == nil {
return true
} else {
return false
}
}

// Makes sure the Report type can be marshalled into JSON
func (r ConnectivityReport) CanMarshal() bool {
_, err := json.Marshal(r)
if err != nil {
log.Printf("Error encoding JSON: %s\n", err)
return false
} else {
return true
}
}
amircybersec marked this conversation as resolved.
Show resolved Hide resolved

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO: WriteCollector that takes a io.Writer

type Collector interface {
Collect(Report) error
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
}

type RemoteCollector struct {
collectorEndpoint *url.URL
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
collectorEndpoint *url.URL
httpClient *http.Client
collectorEndpoint *url.URL

}

type SamplingCollector struct {
collector Collector
successFraction float64
failureFraction float64
}

type CollectorTarget struct {
collector Collector
priority int
maxRetry int
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
}

// TODO: implement a rotating collector
type RotatingCollector struct {
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
collectors []CollectorTarget
stopOnSuccess bool
}

// SortByPriority sorts the collectors based on their priority in ascending order
func (rc *RotatingCollector) SortByPriority() {
sort.Slice(rc.collectors, func(i, j int) bool {
return rc.collectors[i].priority < rc.collectors[j].priority
})
}

func (c *RotatingCollector) Collect(report Report) error {
// sort collectors in RotatingCollector by priority
// into a new slice
c.SortByPriority()
for _, target := range c.collectors {
for i := 0; i < target.maxRetry; i++ {
err := target.collector.Collect(report)
if err != nil {
if err, ok := err.(StatusErr); ok {
switch {
case err.StatusCode == 500:
// skip retrying
break
case err.StatusCode == 408:
// wait for 1 second before retry
time.Sleep(time.Duration(1000 * time.Millisecond))
default:
break
}
} else {
return err
}
} else {
fmt.Println("Report sent")
if c.stopOnSuccess {
return nil
}
break
}
}
}
return nil
}

type StatusErr struct {
StatusCode int
Message string
}

func (e StatusErr) Error() string {
return e.Message
}

func (c *RemoteCollector) Collect(report Report) error {
jsonData, err := json.Marshal(report)
var statusCode int
if err != nil {
log.Printf("Error encoding JSON: %s\n", err)
return err
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Printf("Error encoding JSON: %s\n", err)
return err
return fmt.Errorf("failed to marshal JSON: %w", err)

}
statusCode, err = sendReport(jsonData, c.collectorEndpoint)
if err != nil {
log.Printf("Send report failed: %v", err)
return err
} else if statusCode > 400 {
return StatusErr{
StatusCode: statusCode,
Message: fmt.Sprintf("HTTP request failed with status code %d", statusCode),
}
} else {
fmt.Println("Report sent")
return nil
}
}

func (c *SamplingCollector) Collect(report Report) error {
var samplingRate float64
if report.IsSuccess() {
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
samplingRate = c.successFraction
} else {
samplingRate = c.failureFraction
}
// Generate a random float64 number between 0 and 1
random := rand.Float64()
if random < samplingRate {
err := c.collector.Collect(report)
if err != nil {
log.Printf("Error collecting report: %v", err)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
log.Printf("Error collecting report: %v", err)

return err
}
return nil
} else {
fmt.Println("Report was not sent this time")
return nil
}
}

func sendReport(jsonData []byte, remote *url.URL) (int, error) {
// TODO: return status code of HTTP response
req, err := http.NewRequest("POST", remote.String(), bytes.NewReader(jsonData))
if err != nil {
debugLog.Printf("Error creating the HTTP request: %s\n", err)
return 0, err
}

req.Header.Set("Content-Type", "application/json; charset=utf-8")
resp, err := httpClient.Do(req)
amircybersec marked this conversation as resolved.
Show resolved Hide resolved
if err != nil {
log.Printf("Error sending the HTTP request: %s\n", err)
return 0, err
}
defer resp.Body.Close()
// Access the HTTP response status code
fmt.Printf("HTTP Response Status Code: %d\n", resp.StatusCode)
respBody, err := io.ReadAll(resp.Body)
if err != nil {
debugLog.Printf("Error reading the HTTP response body: %s\n", err)
return 0, err
}
debugLog.Printf("Response: %s\n", respBody)
return resp.StatusCode, nil
}
187 changes: 187 additions & 0 deletions x/reporter/reporter_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,187 @@
// Copyright 2023 Jigsaw Operations LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// https://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package reporter

import (
"fmt"
"net/url"
"testing"
"time"
)

type ConnectivitySetup struct {
Proxy string `json:"proxy"`
Resolver string `json:"resolver"`
Proto string `json:"proto"`
Prefix string `json:"prefix"`
}

type ConnectivityError struct {
Op string `json:"operation"`
PosixError string `json:"posixError"`
Msg string `json:"msg"`
}

func TestIsSuccess(t *testing.T) {
var testReport = ConnectivityReport{
Connection: nil,
Time: time.Now().UTC().Truncate(time.Second),
DurationMs: 1,
}

var r Report = testReport
if !r.IsSuccess() {
t.Errorf("Expected false, but got: %v", r.IsSuccess())
} else {
fmt.Println("IsSuccess Test Passed")
}
}

func TestSendReportSuccessfully(t *testing.T) {
var testSetup = ConnectivitySetup{
Proxy: "testProxy",
Resolver: "8.8.8.8",
Proto: "udp",
Prefix: "HTTP1/1",
}
var testErr = ConnectivityError{
Op: "read",
PosixError: "ETIMEDOUT",
Msg: "i/o timeout",
}
var testReport = ConnectivityReport{
Connection: testSetup,
Time: time.Now().UTC().Truncate(time.Second),
DurationMs: 1,
Error: testErr,
}

var r Report = testReport
fmt.Printf("The test report shows success: %v\n", r.IsSuccess())
u, err := url.Parse("https://script.google.com/macros/s/AKfycbzoMBmftQaR9Aw4jzTB-w4TwkDjLHtSfBCFhh4_2NhTEZAUdj85Qt8uYCKCNOEAwCg4/exec")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
c := RemoteCollector{
collectorEndpoint: u,
}
err = c.Collect(r)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}

func TestSendReportUnsuccessfully(t *testing.T) {
var testReport = ConnectivityReport{
Connection: nil,
Time: time.Now().UTC().Truncate(time.Second),
DurationMs: 1,
}
var r Report = testReport
fmt.Printf("The test report shows success: %v\n", r.IsSuccess())
u, err := url.Parse("https://google.com")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
c := RemoteCollector{
collectorEndpoint: u,
}
err = c.Collect(r)
if err == nil {
t.Errorf("Expected 405 error no error occurred!")
} else {
if err, ok := err.(StatusErr); ok {
if err.StatusCode != 405 {
t.Errorf("Expected 405 error no error occurred!")
}
}
}
}

func TestSamplingCollector(t *testing.T) {
var testReport = ConnectivityReport{
Connection: nil,
Time: time.Now().UTC().Truncate(time.Second),
DurationMs: 1,
}
var r Report = testReport
fmt.Printf("The test report shows success: %v\n", r.IsSuccess())
u, err := url.Parse("https://example.com")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
c := SamplingCollector{
collector: &RemoteCollector{
collectorEndpoint: u,
},
successFraction: 0.5,
failureFraction: 0.1,
}
err = c.Collect(r)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}

func TestRotatingCollector(t *testing.T) {
var testReport = ConnectivityReport{
Connection: nil,
Time: time.Now().UTC().Truncate(time.Second),
DurationMs: 1,
}
var r Report = testReport
fmt.Printf("The test report shows success: %v\n", r.IsSuccess())
u1, err := url.Parse("https://example.com")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
u2, err := url.Parse("https://google.com")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
u3, err := url.Parse("https://script.google.com/macros/s/AKfycbzoMBmftQaR9Aw4jzTB-w4TwkDjLHtSfBCFhh4_2NhTEZAUdj85Qt8uYCKCNOEAwCg4/exec")
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
c := RotatingCollector{
collectors: []CollectorTarget{
{collector: &RemoteCollector{
collectorEndpoint: u1,
},
priority: 1,
maxRetry: 2,
},
{
collector: &RemoteCollector{
collectorEndpoint: u2,
},
priority: 2,
maxRetry: 3,
},
{
collector: &RemoteCollector{
collectorEndpoint: u3,
},
priority: 3,
maxRetry: 2,
},
},
stopOnSuccess: false,
}
err = c.Collect(r)
if err != nil {
t.Errorf("Expected no error, but got: %v", err)
}
}