diff --git a/counter/counter.go b/counter/counter.go new file mode 100644 index 0000000..a8b25e3 --- /dev/null +++ b/counter/counter.go @@ -0,0 +1,95 @@ +// Package counter, provides a means of incrementing a named counter. +package counter + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "os" +) + +// IncrementNamedCounter takes a key, an increment value, and a JSON file path, and updates the value in the file +func IncrementNamedCounter(key string, increment int, filename string) (int, error) { + jsonData, err := loadJSONFile(filename) + if err != nil { + return 0, err + } + + existingValue, ok := jsonData[key] + if !ok { + jsonData[key] = increment + } else { + switch v := existingValue.(type) { + case float64: + jsonData[key] = v + float64(increment) + case int64: + jsonData[key] = v + int64(increment) + case int32: + jsonData[key] = v + int32(increment) + case int: + jsonData[key] = v + increment + default: + return 0, fmt.Errorf("value for key '%s' is not a valid numeric type", key) + } + } + + err = writeJSONFile(filename, jsonData) + if err != nil { + return 0, err + } + + value, ok := jsonData[key] + if !ok { + return 1, nil + } + + switch v := value.(type) { + case float64: + return int(v), nil + case int64: + return int(v), nil + case int: + return int(v), nil + default: + return 0, fmt.Errorf("value for key '%s' is not a valid int32", key) + } +} + +// writeJSONFile writes the map into a JSON file +func writeJSONFile(filename string, jsonData map[string]interface{}) error { + updatedData, err := json.MarshalIndent(jsonData, "", " ") + if err != nil { + return err + } + + err = ioutil.WriteFile(filename, updatedData, 0644) + if err != nil { + return err + } + + return nil +} + +// loadJSONFile reads the contents of a JSON file and returns a map[string]interface{} +func loadJSONFile(filename string) (map[string]interface{}, error) { + + if _, err := os.Stat(filename); os.IsNotExist(err) { + err := ioutil.WriteFile(filename, []byte("{}"), 0644) + if err != nil { + return nil, fmt.Errorf("failed to create file: %w", err) + } + } + + data, err := ioutil.ReadFile(filename) + if err != nil { + return nil, err + } + + var jsonData map[string]interface{} + err = json.Unmarshal(data, &jsonData) + if err != nil { + return nil, err + } + + return jsonData, nil +} diff --git a/counter/counter_test.go b/counter/counter_test.go new file mode 100644 index 0000000..550d3ca --- /dev/null +++ b/counter/counter_test.go @@ -0,0 +1,27 @@ +package counter + +import ( + "path/filepath" + "testing" +) + +func TestThatANoneExistentCounterWillBeCreatedAndIncrements(t *testing.T) { + tempDir := t.TempDir() + testFile := filepath.Join(tempDir, "test.json") + + counter, err := IncrementNamedCounter("testCounter", 1, testFile) + if err != nil { + t.Errorf("Not expecting and err to be returned: %v", err) + } + if counter != 1 { + t.Errorf("Counter expected to be 1 but got %d", counter) + } + + counter, err = IncrementNamedCounter("testCounter", 1, testFile) + if err != nil { + t.Errorf("Not expecting and err to be returned: %v", err) + } + if counter != 2 { + t.Errorf("Counter expected to be 2 but got %d", counter) + } +} diff --git a/main.go b/main.go index 7d16d9e..341c2ba 100644 --- a/main.go +++ b/main.go @@ -6,20 +6,25 @@ import ( "net/http" "os" "os/exec" + "path/filepath" "runtime/debug" + "strconv" "strings" "syscall" "time" + + "github.com/meysam81/prometheus-command-timer/counter" ) type Config struct { - PushgatewayURL string - JobName string - InstanceName string - Labels string - Debug bool - Version bool - Info bool + PushgatewayURL string + JobName string + InstanceName string + Labels string + ExecCountTransientStore string + Debug bool + Version bool + Info bool } func main() { @@ -32,6 +37,7 @@ func main() { flag.StringVar(&config.JobName, "job-name", "", "Job name for metrics (required)") flag.StringVar(&config.InstanceName, "instance-name", config.InstanceName, "Instance name for metrics") flag.StringVar(&config.Labels, "labels", "", "Additional labels in key=value format, comma-separated (e.g., env=prod,team=infra)") + flag.StringVar(&config.ExecCountTransientStore, "execution-count-store", filepath.Join(os.TempDir(), "prometheus-command-time.json"), "Override the default transient store filename (/prometheus-command-time.json)") flag.BoolVar(&config.Version, "version", false, "Output version") showHelp := flag.Bool("help", false, "Show help message") flag.BoolVar(showHelp, "h", false, "Show help message (shorthand)") @@ -133,11 +139,27 @@ func executeCommand(config *Config, cmdArgs []string) int { sendMetric(config, "job_duration_seconds", fmt.Sprintf("%.6f", duration), "gauge", "Total time taken for job execution in seconds") sendMetric(config, "job_exit_status", fmt.Sprintf("%d", exitStatus), "gauge", "Exit status code of the last job execution (0=success)") sendMetric(config, "job_last_execution_timestamp", fmt.Sprintf("%d", endTime), "gauge", "Timestamp of the last job execution") - sendMetric(config, "job_executions_total", "1", "counter", "Total number of job executions") + sendMetric(config, "job_executions_total", strconv.Itoa(incrementExecutionCounter(config)), "counter", "Total number of job executions") return exitStatus } +// incrementExecutionCounter will return the next value of a counter which +// is named using the push gateway URL. +func incrementExecutionCounter(config *Config) int { + counterVal := 1 + counterName, err := buildPushgatewayURL(config) + if err != nil { + logStdout(config, "error building counter name: %v", err) + } else { + counterVal, err = counter.IncrementNamedCounter(counterName, 1, config.ExecCountTransientStore) + if err != nil { + logStdout(config, "error loading counter: %v", err) + } + } + return counterVal +} + func buildPushgatewayURL(config *Config) (string, error) { url := fmt.Sprintf("%s/metrics/job/%s/instance/%s", strings.TrimSuffix(config.PushgatewayURL, "/"),