blob: 0a03512cf350c2e2975032478037f53f5df1bc08 [file] [edit]
// Application that serves up the contents of /tmp/glog via HTTP, giving access
// to logs w/o needing to SSH into the server.
package main
import (
"bufio"
"encoding/gob"
"flag"
"fmt"
"html/template"
"net/http"
"net/url"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
metrics "github.com/rcrowley/go-metrics"
"skia.googlesource.com/buildbot.git/go/common"
"skia.googlesource.com/buildbot.git/go/util"
"github.com/golang/glog"
)
var (
port = flag.String("port", ":10115", "HTTP service address (e.g., ':10115')")
dir = flag.String("dir", "/tmp/glog", "Directory to serve log files from.")
graphiteServer = flag.String("graphite_server", "skiamonitor.com:2003", "Where is Graphite metrics ingestion server running.")
stateFile = flag.String("state_file", "/tmp/logserver.state", "File where logserver stores all encountered log files. This ensures that metrics are not duplicated for already processed log files.")
appLogThreshold = flag.Int64(
"app_log_threshold", 1<<30,
"If any app's logs for a log level use up more than app_log_threshold value then the files with the oldest modified time are deleted till size is less than app_log_threshold - app_log_threshold_buffer.")
appLogThresholdBuffer = flag.Int64(
"app_log_threshold_buffer", 10*1<<20,
"If any app's logs for a log level use up more than app_log_threshold then the files with the oldest modified time are deleted till size is less than app_log_threshold - app_log_threshold_buffer.")
dirWatchDuration = flag.Duration("dir_watch_duration", 10*time.Second, "How long dir watcher sleeps for before checking the dir.")
)
// FileServer returns a handler that serves HTTP requests
// with the contents of the file system rooted at root.
//
// To use the operating system's file system implementation,
// use http.Dir:
//
// http.Handle("/", FileServer(http.Dir("/tmp")))
//
// Differs from net/http FileServer by making directory listings better.
func FileServer(root http.FileSystem) http.Handler {
return &fileHandler{root}
}
type fileHandler struct {
root http.FileSystem
}
func (f *fileHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
upath := r.URL.Path
if !strings.HasPrefix(upath, "/") {
upath = "/" + upath
r.URL.Path = upath
}
serveFile(w, r, f.root, path.Clean(upath))
}
// FileInfoNameSlice is for sorting files by their names.
type FileInfoNameSlice []os.FileInfo
func (p FileInfoNameSlice) Len() int { return len(p) }
func (p FileInfoNameSlice) Less(i, j int) bool { return p[i].Name() < p[j].Name() }
func (p FileInfoNameSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
// FileInfoModifiedSlice is for sorting files by their modified time.
type FileInfoModifiedSlice []os.FileInfo
func (p FileInfoModifiedSlice) Len() int { return len(p) }
func (p FileInfoModifiedSlice) Less(i, j int) bool { return p[i].ModTime().Before(p[j].ModTime()) }
func (p FileInfoModifiedSlice) Swap(i, j int) { p[i], p[j] = p[j], p[i] }
// dirList writes the directory list to the HTTP response.
//
// glog convention is that log files are created in the following format:
// "ingest.skia-testing-b.perf.log.ERROR.20141015-133007.3273"
// where the first word is the name of the app.
// glog also creates symlinks that look like "ingest.ERROR". These
// symlinks point to the latest log type.
// This method displays sorted symlinks first and then displays sorted sections for
// all apps. Files and directories not in the glog format are bucketed into an
// "unknown" app.
func dirList(w http.ResponseWriter, f http.File) {
w.Header().Set("Content-Type", "text/html; charset=utf-8")
fmt.Fprintf(w, "<pre>\n")
// Datastructures to populate and output.
topLevelSymlinks := make([]os.FileInfo, 0)
appToLogs := make(map[string][]os.FileInfo)
for {
fileInfos, err := f.Readdir(10000)
if err != nil || len(fileInfos) == 0 {
break
}
// Prepopulate the datastructures.
for _, fileInfo := range fileInfos {
name := fileInfo.Name()
nameTokens := strings.Split(name, ".")
if len(nameTokens) == 2 {
topLevelSymlinks = append(topLevelSymlinks, fileInfo)
} else if len(nameTokens) > 1 {
appToLogs[nameTokens[0]] = append(appToLogs[nameTokens[0]], fileInfo)
} else {
// File all directories or files created by something other than
// glog under "unknown" app.
appToLogs["unknown"] = append(appToLogs["unknown"], fileInfo)
}
}
}
// First output the top level symlinks.
sort.Sort(FileInfoNameSlice(topLevelSymlinks))
for _, fileInfo := range topLevelSymlinks {
writeFileInfo(w, fileInfo)
}
// Second output app links to their anchors.
var keys []string
for k := range appToLogs {
keys = append(keys, k)
}
sort.Strings(keys)
if len(keys) != 0 {
fmt.Fprint(w, "\nJump to sections:\n")
}
for _, app := range keys {
fmt.Fprintf(w, "<a href=\"#%s\">%s</a>\n", app, template.HTMLEscapeString(app))
}
fmt.Fprint(w, "\n")
// Then output the logs of all the different apps.
for _, app := range keys {
appFileInfos := appToLogs[app]
sort.Sort(FileInfoNameSlice(appFileInfos))
fmt.Fprintf(w, "\n===== <a name=\"%s\">%s</a> =====\n\n", app, template.HTMLEscapeString(app))
for _, fileInfo := range appFileInfos {
writeFileInfo(w, fileInfo)
}
}
fmt.Fprintf(w, "</pre>\n")
}
func writeFileInfo(w http.ResponseWriter, fileInfo os.FileInfo) {
name := fileInfo.Name()
if fileInfo.IsDir() {
name += "/"
}
url := url.URL{Path: name}
downloadLink := ""
if !fileInfo.IsDir() {
fileSize := util.GetFormattedByteSize(float64(fileInfo.Size()))
downloadLink = fmt.Sprintf("(%s <a href=\"%s\" download=\"%s\">download</a>)", fileSize, url.String(), template.HTMLEscapeString(name))
}
fmt.Fprintf(w, "%s <a href=\"%s\">%s</a> %s\n", fileInfo.ModTime(), url.String(), template.HTMLEscapeString(name), downloadLink)
}
func serveFile(w http.ResponseWriter, r *http.Request, fs http.FileSystem, name string) {
f, err := fs.Open(name)
if err != nil {
http.NotFound(w, r)
return
}
defer f.Close()
d, err1 := f.Stat()
if err1 != nil {
http.NotFound(w, r)
return
}
url := r.URL.Path
if d.IsDir() {
if url[len(url)-1] != '/' {
w.Header().Set("Location", path.Base(url)+"/")
w.WriteHeader(http.StatusMovedPermanently)
return
}
}
if d.IsDir() {
glog.Infof("Dir List: %s", name)
dirList(w, f)
return
}
http.ServeContent(w, r, d.Name(), d.ModTime(), f)
}
// getAppAndLogLevel returns the app name and the log level of the specified
// glog file by parsing it.
// It expects a structure that looks like this:
// "ingest.skia-testing-b.perf.log.ERROR.20141015-133007.3273"
func getAppAndLogLevel(fileInfo os.FileInfo) (string, string) {
name := fileInfo.Name()
nameTokens := strings.Split(name, ".")
if len(nameTokens) > 5 {
return nameTokens[0], nameTokens[4]
}
// Ignore symlinks and other logs not created by glog.
return "", ""
}
type fileState struct {
LineCount int64
Size int64
}
type logserverState struct {
FilesToState map[string]fileState
AppLogLevelToSpace map[string]int64
AppLogLevelToCount map[string]int64
LastCompletedRun time.Time
}
func getPreviousState() (map[string]fileState, map[string]int64, map[string]int64, time.Time, error) {
if _, err := os.Stat(*stateFile); os.IsNotExist(err) {
// State file does not exist, return empty values.
return map[string]fileState{}, map[string]int64{}, map[string]int64{}, time.Time{}, nil
}
f, err := os.Open(*stateFile)
if err != nil {
return nil, nil, nil, time.Time{}, fmt.Errorf("Failed to open state file %s for reading: %s", *stateFile, err)
}
defer f.Close()
state := &logserverState{}
dec := gob.NewDecoder(f)
if err := dec.Decode(state); err != nil {
return nil, nil, nil, time.Time{}, fmt.Errorf("Failed to decode state file: %s", err)
}
return state.FilesToState, state.AppLogLevelToSpace, state.AppLogLevelToCount, state.LastCompletedRun, nil
}
func writeCurrentState(filestoState map[string]fileState, appLogLevelToSpace, appLogLevelToCount map[string]int64, lastCompletedRun time.Time) error {
f, err := os.Create(*stateFile)
if err != nil {
return fmt.Errorf("Unable to create state file %s: %s", *stateFile, err)
}
defer f.Close()
state := &logserverState{
FilesToState: filestoState,
AppLogLevelToSpace: appLogLevelToSpace,
AppLogLevelToCount: appLogLevelToCount,
LastCompletedRun: lastCompletedRun,
}
enc := gob.NewEncoder(f)
if err := enc.Encode(state); err != nil {
return fmt.Errorf("Failed to encode state: %s", err)
}
return nil
}
func getLineCount(path string) int64 {
file, _ := os.Open(path)
fileScanner := bufio.NewScanner(file)
var lineCount int64
for fileScanner.Scan() {
lineCount++
}
return lineCount
}
// dirWatcher watches for changes in the specified dir. The frequency of polling
// is determined by the duration parameter. dirWatcher ensures:
// * Each app's logs do not exceed the log limit threshold. If they do then the
// oldest files are deleted.
// * New encountered logs are reported to InfluxDB.
func dirWatcher(duration time.Duration, dir string) {
filesToState, appLogLevelToSpace, appLogLevelToCount, lastCompletedRun, err := getPreviousState()
if err != nil {
glog.Fatalf("Could get access previous state: %s", err)
}
appLogLevelToMetric := make(map[string]metrics.Gauge)
updatedFiles := false
markFn := func(path string, fileInfo os.FileInfo, err error) error {
if err != nil {
return err
}
if fileInfo.IsDir() || fileInfo.Mode()&os.ModeSymlink != 0 {
// We are only interested in watching non-symlink log files in the
// top-level dir.
return nil
}
if _, exists := filesToState[path]; !exists || fileInfo.ModTime().After(lastCompletedRun) {
glog.Infof("Processing %s", path)
app, logLevel := getAppAndLogLevel(fileInfo)
if app != "" && logLevel != "" {
appLogLevel := fmt.Sprintf("%s.%s", app, logLevel)
if _, ok := appLogLevelToMetric[appLogLevel]; !ok {
// First time encountered this app and log level combination.
// Create a counter metric.
appLogLevelToMetric[appLogLevel] = metrics.NewRegisteredGauge("logserver."+appLogLevel, metrics.DefaultRegistry)
}
// Calculate how many new lines and new disk space usage there is.
totalLines := getLineCount(path)
totalSize := fileInfo.Size()
newLines := totalLines
newSpace := totalSize
if exists {
fileState := filesToState[path]
newLines = totalLines - fileState.LineCount
newSpace = totalSize - fileState.Size
}
glog.Infof("Processed %d new lines", newLines)
glog.Infof("Processed %d new bytes", newSpace)
// Update the logs count metric.
appLogLevelToCount[appLogLevel] += newLines
appLogLevelToMetric[appLogLevel].Update(appLogLevelToCount[appLogLevel])
// Add the file size to the current space count for this app and
// log level combination.
appLogLevelToSpace[appLogLevel] += newSpace
updatedFiles = true
}
filesToState[path] = fileState{LineCount: getLineCount(path), Size: fileInfo.Size()}
}
return nil
}
for _ = range time.Tick(duration) {
filepath.Walk(dir, markFn)
deletedFiles := cleanupAppLogs(dir, appLogLevelToSpace, filesToState)
if updatedFiles || deletedFiles {
if err := writeCurrentState(filesToState, appLogLevelToSpace, appLogLevelToCount, time.Now()); err != nil {
glog.Fatalf("Could not write state: %s", err)
}
glog.Info(getPrettyMap(appLogLevelToCount, "AppLogLevels to their line counts"))
glog.Info(getPrettyMap(appLogLevelToSpace, "AppLogLevels to their disk space"))
}
updatedFiles = false
lastCompletedRun = time.Now()
}
}
func getPrettyMap(m map[string]int64, name string) string {
log := name + ": {"
for k := range m {
log += fmt.Sprintf("%s: %d, ", k, m[k])
}
log = strings.TrimRight(log, ", ")
log += "}"
return log
}
func cleanupAppLogs(dir string, appLogLevelToSpace map[string]int64, filesToState map[string]fileState) bool {
deletedFiles := false
for appLogLevel := range appLogLevelToSpace {
if appLogLevelToSpace[appLogLevel] > *appLogThreshold {
glog.Infof("App %s is above the threshold. Usage: %d. Threshold: %d", appLogLevel, appLogLevelToSpace[appLogLevel], *appLogThreshold)
tokens := strings.Split(appLogLevel, ".")
app := tokens[0]
logLevel := tokens[1]
logGlob := filepath.Join(dir, app+".*"+logLevel+".*")
matches, err := filepath.Glob(logGlob)
if err != nil {
glog.Fatalf("Could not glob for %s: %s", logGlob, err)
}
fileInfos := make([]os.FileInfo, len(matches))
for i, match := range matches {
fileInfo, err := os.Stat(match)
if err != nil {
glog.Fatalf("Could not stat %s: %s", match, err)
}
fileInfos[i] = fileInfo
}
// Sort by Modified time and keep deleting till we are at
// (threshold - buffer) space left.
sort.Sort(FileInfoModifiedSlice(fileInfos))
index := 0
for appLogLevelToSpace[appLogLevel] > *appLogThreshold-*appLogThresholdBuffer {
fileName := fileInfos[index].Name()
appLogLevelToSpace[appLogLevel] -= fileInfos[index].Size()
if err = os.Remove(filepath.Join(dir, fileName)); err != nil {
glog.Fatalf("Could not delete %s: %s", fileName, err)
}
// Remove the entry from the filesToState map.
delete(filesToState, filepath.Join(dir, fileName))
deletedFiles = true
glog.Infof("Deleted %s", fileName)
index++
}
// Just incase we delete a massive log file.
if appLogLevelToSpace[appLogLevel] < 0 {
appLogLevelToSpace[appLogLevel] = 0
}
}
}
return deletedFiles
}
func main() {
hostname, err := os.Hostname()
if err != nil {
glog.Fatalf("Failed to get Hostname: %s", err)
}
appName := "logserver." + hostname
common.InitWithMetrics(appName, *graphiteServer)
if err := os.MkdirAll(*dir, 0777); err != nil {
glog.Fatalf("Failed to create dir for log files: %s", err)
}
go dirWatcher(*dirWatchDuration, *dir)
http.Handle("/", http.StripPrefix("/", FileServer(http.Dir(*dir))))
glog.Fatal(http.ListenAndServe(*port, nil))
}