diff --git a/cmd/gradle-cache/main.go b/cmd/gradle-cache/main.go index 85cd03d..b26a2ff 100644 --- a/cmd/gradle-cache/main.go +++ b/cmd/gradle-cache/main.go @@ -34,6 +34,7 @@ type CLI struct { DatadogAPIKey string `help:"DataDog API key for direct metric submission (no agent required)." env:"DATADOG_API_KEY"` MetricsTags []string `help:"Additional metric tags in key:value format. May be repeated." name:"metrics-tag"` CPUProfile string `help:"Write CPU profile to file." name:"cpuprofile" hidden:"" type:"path"` + DDLogFile string `help:"Path to a log file tailed by the Datadog Agent for telemetry events." name:"dd-log-file" env:"DD_LOG_PATH"` } type backendFlags struct { @@ -266,6 +267,7 @@ func main() { StatsdAddr: cli.StatsdAddr, DatadogAPIKey: cli.DatadogAPIKey, MetricsTags: cli.MetricsTags, + DDLogPath: cli.DDLogFile, } metrics := mf.NewMetricsClient() defer metrics.Close() diff --git a/gradlecache/ddlog.go b/gradlecache/ddlog.go new file mode 100644 index 0000000..aec52bd --- /dev/null +++ b/gradlecache/ddlog.go @@ -0,0 +1,34 @@ +package gradlecache + +import ( + "log/slog" + "os" +) + +// loggedMetrics wraps a MetricsClient and also writes each metric as a JSON +// log line to a file tailed by the Datadog Agent. This allows a single +// Distribution call to emit both a DogStatsD metric and a structured log event. +type loggedMetrics struct { + inner MetricsClient + logger *slog.Logger + file *os.File +} + +func newLoggedMetrics(inner MetricsClient, path string) (*loggedMetrics, error) { + f, err := os.OpenFile(path, os.O_WRONLY|os.O_APPEND|os.O_CREATE, 0600) + if err != nil { + return nil, err + } + logger := slog.New(slog.NewJSONHandler(f, nil)) + return &loggedMetrics{inner: inner, logger: logger, file: f}, nil +} + +func (m *loggedMetrics) Distribution(name string, value float64, tags ...string) { + m.inner.Distribution(name, value, tags...) + m.logger.Info(name, "value", value, "tags", tags) +} + +func (m *loggedMetrics) Close() { + m.inner.Close() + _ = m.file.Close() +} diff --git a/gradlecache/ddlog_test.go b/gradlecache/ddlog_test.go new file mode 100644 index 0000000..eb12ac8 --- /dev/null +++ b/gradlecache/ddlog_test.go @@ -0,0 +1,110 @@ +package gradlecache + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoggedMetrics_WritesJSON(t *testing.T) { + path := filepath.Join(t.TempDir(), "dd.log") + + inner := &recordingMetrics{} + lm, err := newLoggedMetrics(inner, path) + if err != nil { + t.Fatal(err) + } + + lm.Distribution("gradle_cache.restore.duration_ms", 1234, "cache_key:foo") + lm.Close() + + // Verify inner client received the metric. + if len(inner.calls) != 1 { + t.Fatalf("expected 1 inner call, got %d", len(inner.calls)) + } + if inner.calls[0].name != "gradle_cache.restore.duration_ms" { + t.Errorf("expected metric name, got %s", inner.calls[0].name) + } + + // Verify JSON was written to the file. + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + var m map[string]any + if err := json.Unmarshal(data, &m); err != nil { + t.Fatalf("expected valid JSON, got: %s", string(data)) + } + if m["msg"] != "gradle_cache.restore.duration_ms" { + t.Errorf("expected metric name as msg, got %v", m["msg"]) + } + if m["value"] != 1234.0 { + t.Errorf("expected value=1234, got %v", m["value"]) + } +} + +func TestLoggedMetrics_Appends(t *testing.T) { + path := filepath.Join(t.TempDir(), "dd.log") + + inner := NoopMetrics{} + lm, err := newLoggedMetrics(inner, path) + if err != nil { + t.Fatal(err) + } + + lm.Distribution("metric.one", 1) + lm.Distribution("metric.two", 2) + lm.Close() + + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + + lines := strings.Split(strings.TrimSpace(string(data)), "\n") + if len(lines) != 2 { + t.Fatalf("expected 2 lines, got %d", len(lines)) + } +} + +func TestLoggedMetrics_InvalidPath(t *testing.T) { + _, err := newLoggedMetrics(NoopMetrics{}, "/nonexistent/dir/dd.log") + if err == nil { + t.Error("expected error for invalid path") + } +} + +func TestLoggedMetrics_ClosesInner(t *testing.T) { + path := filepath.Join(t.TempDir(), "dd.log") + inner := &recordingMetrics{} + lm, err := newLoggedMetrics(inner, path) + if err != nil { + t.Fatal(err) + } + lm.Close() + if !inner.closed { + t.Error("expected inner client to be closed") + } +} + +type metricCall struct { + name string + value float64 + tags []string +} + +type recordingMetrics struct { + calls []metricCall + closed bool +} + +func (r *recordingMetrics) Distribution(name string, value float64, tags ...string) { + r.calls = append(r.calls, metricCall{name, value, tags}) +} + +func (r *recordingMetrics) Close() { + r.closed = true +} diff --git a/gradlecache/metrics.go b/gradlecache/metrics.go index 56e65b6..e8fe89b 100644 --- a/gradlecache/metrics.go +++ b/gradlecache/metrics.go @@ -30,6 +30,7 @@ type MetricsFlags struct { StatsdAddr string DatadogAPIKey string MetricsTags []string + DDLogPath string // optional: path to a file tailed by the DD agent for log-based metrics } // DetectStatsdAddr returns the DogStatsD address from the environment, or empty @@ -47,27 +48,42 @@ func DetectStatsdAddr() string { } // NewMetricsClient returns a MetricsClient based on the configured flags. +// When DDLogPath is set, each metric is also written as a JSON log line to +// that file for collection by the Datadog Agent. func (f *MetricsFlags) NewMetricsClient() MetricsClient { + var client MetricsClient if f.StatsdAddr != "" { if c := NewStatsdClient(f.StatsdAddr, f.MetricsTags); c != nil { slog.Debug("metrics: using DogStatsD", "addr", f.StatsdAddr) - return c + client = c + } else { + slog.Warn("failed to connect to DogStatsD, metrics disabled", "addr", f.StatsdAddr) + client = NoopMetrics{} } - slog.Warn("failed to connect to DogStatsD, metrics disabled", "addr", f.StatsdAddr) - return NoopMetrics{} - } - if f.DatadogAPIKey != "" { + } else if f.DatadogAPIKey != "" { slog.Debug("metrics: using Datadog HTTP API") - return NewDatadogAPIClient(f.DatadogAPIKey, f.MetricsTags) - } - if addr := DetectStatsdAddr(); addr != "" { + client = NewDatadogAPIClient(f.DatadogAPIKey, f.MetricsTags) + } else if addr := DetectStatsdAddr(); addr != "" { if c := NewStatsdClient(addr, f.MetricsTags); c != nil { slog.Debug("metrics: auto-detected DogStatsD agent", "addr", addr) - return c + client = c + } + } + if client == nil { + slog.Debug("metrics: no backend configured, metrics disabled") + client = NoopMetrics{} + } + + if f.DDLogPath != "" { + lm, err := newLoggedMetrics(client, f.DDLogPath) + if err != nil { + slog.Warn("failed to open DD log file, log-based metrics disabled", "path", f.DDLogPath, "error", err) + return client } + slog.Debug("metrics: also logging to DD agent file", "path", f.DDLogPath) + return lm } - slog.Debug("metrics: no backend configured, metrics disabled") - return NoopMetrics{} + return client } // ── DogStatsD (UDP) ─────────────────────────────────────────────────────────