package main import ( "bufio" "context" "encoding/json" "fmt" "io" "net/http" "os" "path/filepath" "strings" "sync" "time" ) type namedURLs []string func (n *namedURLs) String() string { return strings.Join(*n, ",") } func (n *namedURLs) Set(value string) error { if !strings.Contains(value, "=") { return fmt.Errorf("URL must use name=https://host/path") } *n = append(*n, value) return nil } type metricSnapshot struct { Timestamp time.Time `json:"timestamp"` Phase string `json:"phase"` Name string `json:"name"` URL string `json:"url"` Status int `json:"status"` Duration int64 `json:"duration_ms"` Body string `json:"body,omitempty"` Error string `json:"error,omitempty"` } func runCollector(ctx context.Context, sources namedURLs, output string, interval, duration time.Duration, coordinatorURL, coordinatorOutput string) error { if len(sources) == 0 { return fmt.Errorf("at least one --url name=url is required") } if interval <= 0 || duration <= 0 { return fmt.Errorf("interval and duration must be positive") } if dir := filepath.Dir(output); dir != "." { if err := os.MkdirAll(dir, 0o750); err != nil { return err } } file, err := os.OpenFile(output, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) if err != nil { return err } defer file.Close() writer := bufio.NewWriterSize(file, 256<<10) defer writer.Flush() client := &http.Client{Timeout: 10 * time.Second} deadline := time.Now().Add(duration) phase := "before" for { phase = detectCollectorPhase(ctx, client, phase, coordinatorURL, coordinatorOutput) snapshots := collectOnce(ctx, client, sources, phase) for _, snapshot := range snapshots { raw, _ := json.Marshal(snapshot) if _, err = writer.Write(append(raw, '\n')); err != nil { return err } } if err = writer.Flush(); err != nil { return err } if time.Now().Add(interval).After(deadline) { return nil } if err = waitContext(ctx, interval); err != nil { return err } } } func detectCollectorPhase(ctx context.Context, client *http.Client, current, coordinatorURL, coordinatorOutput string) string { if coordinatorOutput != "" { if _, err := os.Stat(coordinatorOutput); err == nil { return "after" } } if coordinatorURL == "" { return current } req, err := http.NewRequestWithContext(ctx, http.MethodGet, strings.TrimRight(coordinatorURL, "/")+"/v1/status", nil) if err != nil { return current } response, err := client.Do(req) if err != nil { return current } defer response.Body.Close() var status struct { Start StartInfo `json:"start"` } if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&status) != nil || status.Start.StartAt.IsZero() || time.Now().Before(status.Start.StartAt) { return current } return "during" } func collectOnce(ctx context.Context, client *http.Client, sources namedURLs, phase string) []metricSnapshot { out := make([]metricSnapshot, len(sources)) var wg sync.WaitGroup for i, source := range sources { i, source := i, source wg.Add(1) go func() { defer wg.Done() parts := strings.SplitN(source, "=", 2) snapshot := metricSnapshot{Timestamp: time.Now().UTC(), Phase: phase, Name: parts[0], URL: parts[1]} started := time.Now() req, err := http.NewRequestWithContext(ctx, http.MethodGet, parts[1], nil) if err == nil { var response *http.Response response, err = client.Do(req) if err == nil { snapshot.Status = response.StatusCode raw, readErr := io.ReadAll(io.LimitReader(response.Body, 16<<20)) _ = response.Body.Close() if readErr != nil { err = readErr } else { snapshot.Body = string(raw) } } } snapshot.Duration = time.Since(started).Milliseconds() if err != nil { snapshot.Error = err.Error() } out[i] = snapshot }() } wg.Wait() return out }