Skip to content
Merged
5 changes: 2 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ Out of the box, Checkup currently supports:

- Checking HTTP endpoints
- Checking TCP endpoints (TLS supported)
- Storing results on S3
- Storing results on S3 or on the local filesystem
- Viewing results on a status page that is mobile-responsive and 100% static


Expand Down Expand Up @@ -143,7 +143,7 @@ As you perform checks, the status page will update every so often with the lates

### Performing checks

You can run checks many different ways: cron, AWS Lambda, or a time.Ticker in your own Go program, to name a few. Checks should be run on a regular basis. How often you run checks depends on your requirements and how much time you render on the status page.
You can run checks many different ways: cron, AWS Lambda, or a time.Ticker in your own Go program, to name a few. Checks should be run on a regular basis. How often you run checks depends on your requirements and how much time you render on the status page.

For example, if you run checks every 10 minutes, showing the last 24 hours on the status page will require 144 check files to be downloaded on each page load. You can distribute your checks to help avoid localized network problems, but this multiplies the number of files by the number of nodes you run checks on, so keep that in mind.

Expand Down Expand Up @@ -287,4 +287,3 @@ Uh oh, having some fires? 🔥 You can create a type that implements `checkup.No
#### Other kinds of checks or storage providers

Need to check more than HTTP? S3 too Amazony for you? You can implement your own Checker and Storage types. If it's general enough, feel free to submit a pull request so others can use it too!

9 changes: 9 additions & 0 deletions checkup.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,8 @@ func (c Checkup) MarshalJSON() ([]byte, error) {
switch c.Storage.(type) {
case S3:
providerName = "s3"
case FS:
providerName = "fs"
default:
return result, fmt.Errorf("unknown Storage type")
}
Expand Down Expand Up @@ -297,6 +299,13 @@ func (c *Checkup) UnmarshalJSON(b []byte) error {
return err
}
c.Storage = storage
case "fs":
var storage FS
err = json.Unmarshal(raw.Storage, &storage)
if err != nil {
return err
}
c.Storage = storage
default:
return fmt.Errorf("%s: unknown Storage type", types.Storage.Provider)
}
Expand Down
114 changes: 114 additions & 0 deletions fs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
package checkup

import (
"encoding/json"
"io/ioutil"
"os"
"path/filepath"
"time"
)

const indexName = "index.json"

// FS is a way to store checkup results on the local filesystem.
type FS struct {
// The path to the directory where check files will be stored.
Dir string `json:"dir"`
// The URL corresponding to fs.Dir.
URL string `json:"url"`

// Check files older than CheckExpiry will be
// deleted on calls to Maintain(). If this is
// the zero value, no old check files will be
// deleted.
CheckExpiry time.Duration `json:"check_expiry,omitempty"`
}

func (fs FS) readIndex() (map[string]int64, error) {
index := map[string]int64{}

f, err := os.Open(filepath.Join(fs.Dir, indexName))
if os.IsNotExist(err) {
return index, nil
} else if err != nil {
return nil, err
}
defer f.Close()

err = json.NewDecoder(f).Decode(&index)
return index, err
}

@mholt mholt Aug 18, 2016

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

As a matter of style, I would prefer not to have named returns here, and instead start the function with index := make(map[string]int64). Even though the function is small, I think the explicit returns are a little easier to follow, especially since you have to make the map anyway.


func (fs FS) writeIndex(index map[string]int64) error {
f, err := os.Create(filepath.Join(fs.Dir, indexName))
if err != nil {
return err
}
defer f.Close()

return json.NewEncoder(f).Encode(index)
}

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

So is the purpose of the index just so that other files can be intermingled with the check files?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

No, it's to allow the JS client to fetch a list of check files. There is no standard way to list all files in a directory in HTTP.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oops, nevermind - I got to the bottom of this PR and I see that the javascript loads the index file to know which check files to load. Makes sense.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

GitHub really needs to make these pages update live. 👎


// Store stores results on filesystem according to the configuration in fs.
func (fs FS) Store(results []Result) error {
// Write results to a new file
name := *GenerateFilename()
f, err := os.Create(filepath.Join(fs.Dir, name))
if err != nil {
return err
}
err = json.NewEncoder(f).Encode(results)
f.Close()
if err != nil {
return err
}

// Read current index file
index, err := fs.readIndex()
if err != nil {
return err
}

// Add new file to index
index[name] = time.Now().UnixNano()

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Shouldn't this be the same filename that you used above in os.Create? name is retrieved from GenerateFilename() but you use a potentially different time value here.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The index contains both filenames and their timestamp, which is helpful because we don't need to parse filenames anymore. Since filenames are just informative, I though having a slightly different timestamp wouldn't matter too much...

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Oh, right, thanks for clarifying.


// Write new index
return fs.writeIndex(index)
}

// Maintain deletes check files that are older than fs.CheckExpiry.
func (fs FS) Maintain() error {
if fs.CheckExpiry == 0 {
return nil
}

files, err := ioutil.ReadDir(fs.Dir)
if err != nil {
return err
}

index, err := fs.readIndex()
if err != nil {
return err
}

for _, f := range files {
if f.Name() == indexName {
continue
}

nsec, ok := index[f.Name()]
if !ok {
continue
}

if time.Since(time.Unix(0, nsec)) > fs.CheckExpiry {
if err := os.Remove(filepath.Join(fs.Dir, f.Name())); err != nil {
return err
}
delete(index, f.Name())
}
}

return fs.writeIndex(index)
}
78 changes: 78 additions & 0 deletions fs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package checkup

import (
"bytes"
"io/ioutil"
"os"
"path/filepath"
"testing"
"time"
)

func TestFS(t *testing.T) {
results := []Result{{Title: "Testing"}}
resultsBytes := []byte(`[{"title":"Testing"}]`+"\n")

dir, err := ioutil.TempDir("", "checkup")
if err != nil {
t.Fatalf("Cannot create temporary directory: %v", err)
}
defer os.RemoveAll(dir)

specimen := FS{
Dir: dir,
}

if err := specimen.Store(results); err != nil {
t.Fatalf("Expected no error from Store(), got: %v", err)
}

// Make sure index has been created
index, err := specimen.readIndex()
if err != nil {
t.Fatalf("Cannot read index: %v", err)
}

if len(index) != 1 {
t.Fatalf("Expected length of index to be 1, but got %v", len(index))
}

var (
name string
nsec int64
)
for name, nsec = range index {}

// Make sure index has timestamp of check
ts := time.Unix(0, nsec)
if time.Since(ts) > 1*time.Second {
t.Errorf("Timestamp of check is %s but expected something very recent", ts)
}

// Make sure check file bytes are correct
checkfile := filepath.Join(specimen.Dir, name)
b, err := ioutil.ReadFile(checkfile)
if err != nil {
t.Fatalf("Expected no error reading body, got: %v", err)
}
if bytes.Compare(b, resultsBytes) != 0 {
t.Errorf("Contents of file are wrong\nExpected %s\nGot %s", resultsBytes, b)
}

// Make sure check file is not deleted after maintain with CheckExpiry == 0
if err := specimen.Maintain(); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if _, err := os.Stat(checkfile); err != nil {
t.Fatalf("Expected not error calling Stat() on checkfile, got: %v", err)
}

// Make sure checkfile is deleted after maintain with CheckExpiry > 0
specimen.CheckExpiry = 1 * time.Nanosecond
if err := specimen.Maintain(); err != nil {
t.Fatalf("Expected no error, got %v", err)
}
if _, err := os.Stat(checkfile); !os.IsNotExist(err) {
t.Fatalf("Expected checkfile to be deleted, but Stat() returned error: %v", err)
}
}
68 changes: 68 additions & 0 deletions statuspage/js/fs.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
/**

FS Storage Adapter for Checkup.js

**/

var checkup = checkup || {};

checkup.storage = (function() {
var url;

// getCheckFileList gets the list of check files within
// the given timeframe (as a unit of nanoseconds) to
// download.
function getCheckFileList(timeframe, callback) {
var after = time.Now() - timeframe;
checkup.getJSON(url+'/index.json', function(index) {
var names = [];
for (var name in index) {
if (index[name] >= after) {
names.push(name);
}
}
callback(names);
});
};

// setup prepares this storage unit to operate.
this.setup = function(cfg) {
url = cfg.url;
};

// getChecksWithin gets all the checks within timeframe as a unit
// of nanoseconds, and executes callback for each check file.
this.getChecksWithin = function(timeframe, fileCallback, doneCallback) {
var checksLoaded = 0, resultsLoaded = 0;
getCheckFileList(timeframe, function(list) {
if (list.length == 0 && (typeof doneCallback === 'function')) {
doneCallback(checksLoaded);
} else {
for (var i = 0; i < list.length; i++) {
checkup.getJSON(url+'/'+list[i], function(filename) {
return function(json, url) {
checksLoaded++;
resultsLoaded += json.length;
if (typeof fileCallback === 'function')
fileCallback(json, filename);
if (checksLoaded >= list.length && (typeof doneCallback === 'function'))
doneCallback(checksLoaded, resultsLoaded);
};
}(list[i]));
}
}
});
};

// getNewChecks gets any checks since the timestamp on the file name
// of the youngest check file that has been downloaded. If no check
// files have been downloaded, no new check files will be loaded.
this.getNewChecks = function(fileCallback, doneCallback) {
if (!checkup.lastCheckTs == null)
return;
var timeframe = time.Now() - checkup.lastCheckTs;
return this.getChecksWithin(timeframe, fileCallback, doneCallback);
};

return this;
})();