From 17a14888e6cd00f5f414e935007a7fd9127b0ae6 Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Mon, 12 Jan 2026 02:01:20 +0100 Subject: [PATCH 1/7] Optimize performance with background polling and add project filtering --- README.md | 12 +++- internal/exporter/exporter.go | 108 ++++++++++++++++++++++++++++++---- main.go | 41 ++++++++++--- 3 files changed, 138 insertions(+), 23 deletions(-) diff --git a/README.md b/README.md index 04945d0..57b1656 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,16 @@ Flags: Address to listen on for web interface and telemetry. --web.metrics-path="/metrics" Path under which to expose metrics - --dtrack.address=DTRACK.ADDRESS - Dependency-Track server address (default: http://localhost:8080 or $DEPENDENCY_TRACK_ADDR) + --dtrack.address="http://localhost:8080" + Dependency-Track server address (can also be set with $DEPENDENCY_TRACK_ADDR) --dtrack.api-key=DTRACK.API-KEY - Dependency-Track API key (default: $DEPENDENCY_TRACK_API_KEY) + Dependency-Track API key (can also be set with $DEPENDENCY_TRACK_API_KEY) + --dtrack.project-tags=DTRACK.PROJECT-TAGS + Comma-separated list of project tags to filter on + --dtrack.project-version-regex=DTRACK.PROJECT-VERSION-REGEX + Regex to filter project versions + --dtrack.poll-interval=6h + Interval to poll Dependency-Track for metrics --log.level=info Only log messages with the given severity or above. One of: [debug, info, warn, error] --log.format=logfmt Output format of log messages. One of: [logfmt, json] --version Show application version. diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index a84775a..f8e770f 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -2,15 +2,18 @@ package exporter import ( "context" - "fmt" "net/http" + "regexp" "strconv" + "sync" + "time" dtrack "github.com/DependencyTrack/client-go" "github.com/go-kit/log" "github.com/go-kit/log/level" "github.com/prometheus/client_golang/prometheus" "github.com/prometheus/client_golang/prometheus/promhttp" + "github.com/prometheus/common/version" ) const ( @@ -20,24 +23,24 @@ const ( // Exporter exports metrics from a Dependency-Track server type Exporter struct { - Client *dtrack.Client - Logger log.Logger + Client *dtrack.Client + Logger log.Logger + ProjectTags []string + ProjectVersion *regexp.Regexp + + mutex sync.RWMutex + registry *prometheus.Registry } // HandlerFunc handles requests to /metrics func (e *Exporter) HandlerFunc() http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { - registry := prometheus.NewRegistry() + e.mutex.RLock() + registry := e.registry + e.mutex.RUnlock() - if err := e.collectPortfolioMetrics(r.Context(), registry); err != nil { - level.Error(e.Logger).Log("err", err) - http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError) - return - } - - if err := e.collectProjectMetrics(r.Context(), registry); err != nil { - level.Error(e.Logger).Log("err", err) - http.Error(w, fmt.Sprintf("error: %s", err), http.StatusInternalServerError) + if registry == nil { + http.Error(w, "Exporter not yet initialized", http.StatusServiceUnavailable) return } @@ -47,6 +50,46 @@ func (e *Exporter) HandlerFunc() http.HandlerFunc { } } +// Run starts the background polling of Dependency-Track metrics +func (e *Exporter) Run(ctx context.Context, interval time.Duration) { + ticker := time.NewTicker(interval) + defer ticker.Stop() + + level.Info(e.Logger).Log("msg", "Starting background poller", "interval", interval) + + // Initial poll + e.poll(ctx) + + for { + select { + case <-ctx.Done(): + level.Info(e.Logger).Log("msg", "Stopping background poller") + return + case <-ticker.C: + e.poll(ctx) + } + } +} + +func (e *Exporter) poll(ctx context.Context) { + level.Debug(e.Logger).Log("msg", "Polling Dependency-Track metrics") + registry := prometheus.NewRegistry() + registry.MustRegister(version.NewCollector(Namespace + "_exporter")) + + if err := e.collectPortfolioMetrics(ctx, registry); err != nil { + level.Error(e.Logger).Log("msg", "Error collecting portfolio metrics", "err", err) + } + + if err := e.collectProjectMetrics(ctx, registry); err != nil { + level.Error(e.Logger).Log("msg", "Error collecting project metrics", "err", err) + } + + e.mutex.Lock() + e.registry = registry + e.mutex.Unlock() + level.Debug(e.Logger).Log("msg", "Successfully updated metrics cache") +} + func (e *Exporter) collectPortfolioMetrics(ctx context.Context, registry *prometheus.Registry) error { var ( inheritedRiskScore = prometheus.NewGauge( @@ -192,7 +235,13 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe return err } + matchedProjects := make(map[string]struct{}) for _, project := range projects { + if !e.projectMatches(project) { + continue + } + matchedProjects[project.UUID.String()] = struct{}{} + projTags := "," for _, t := range project.Tags { projTags = projTags + t.Name + "," @@ -269,6 +318,9 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe } for _, violation := range violations { + if _, ok := matchedProjects[violation.Project.UUID.String()]; !ok { + continue + } var ( analysisState string suppressed string = "false" @@ -302,3 +354,33 @@ func (e *Exporter) fetchPolicyViolations(ctx context.Context) ([]dtrack.PolicyVi return e.Client.PolicyViolation.GetAll(ctx, true, po) }) } + +func (e *Exporter) projectMatches(project dtrack.Project) bool { + // Filter by tags + if len(e.ProjectTags) > 0 { + found := false + for _, t := range project.Tags { + for _, filterTag := range e.ProjectTags { + if t.Name == filterTag { + found = true + break + } + } + if found { + break + } + } + if !found { + return false + } + } + + // Filter by version + if e.ProjectVersion != nil { + if !e.ProjectVersion.MatchString(project.Version) { + return false + } + } + + return true +} diff --git a/main.go b/main.go index e36cf54..6876745 100644 --- a/main.go +++ b/main.go @@ -1,10 +1,13 @@ package main import ( + "context" "fmt" "net/http" "os" "os/signal" + "regexp" + "strings" "syscall" dtrack "github.com/DependencyTrack/client-go" @@ -30,11 +33,14 @@ func init() { func main() { var ( - webConfig = webflag.AddFlags(kingpin.CommandLine, ":9916") - metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() - dtAddress = kingpin.Flag("dtrack.address", fmt.Sprintf("Dependency-Track server address (can also be set with $%s)", envAddress)).Default("http://localhost:8080").Envar(envAddress).String() - dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() - promlogConfig = promlog.Config{} + webConfig = webflag.AddFlags(kingpin.CommandLine, ":9916") + metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() + dtAddress = kingpin.Flag("dtrack.address", fmt.Sprintf("Dependency-Track server address (can also be set with $%s)", envAddress)).Default("http://localhost:8080").Envar(envAddress).String() + dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() + dtProjectTags = kingpin.Flag("dtrack.project-tags", "Comma-separated list of project tags to filter on").String() + dtProjectVersion = kingpin.Flag("dtrack.project-version-regex", "Regex to filter project versions").String() + pollInterval = kingpin.Flag("dtrack.poll-interval", "Interval to poll Dependency-Track for metrics").Default("6h").Duration() + promlogConfig = promlog.Config{} ) flag.AddFlags(kingpin.CommandLine, &promlogConfig) @@ -53,11 +59,32 @@ func main() { os.Exit(1) } + var projectVersion *regexp.Regexp + if *dtProjectVersion != "" { + projectVersion, err = regexp.Compile(*dtProjectVersion) + if err != nil { + level.Error(logger).Log("msg", "Error compiling project version regex", "err", err) + os.Exit(1) + } + } + + var projectTags []string + if *dtProjectTags != "" { + projectTags = strings.Split(*dtProjectTags, ",") + } + e := exporter.Exporter{ - Client: c, - Logger: logger, + Client: c, + Logger: logger, + ProjectTags: projectTags, + ProjectVersion: projectVersion, } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + go e.Run(ctx, *pollInterval) + http.HandleFunc(*metricsPath, e.HandlerFunc()) http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte(` From e1e240536fdb6c39874728ef51faac69a1b6e1a0 Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Mon, 12 Jan 2026 02:14:17 +0100 Subject: [PATCH 2/7] Remove project version filtering feature --- README.md | 2 -- internal/exporter/exporter.go | 15 +++------------ main.go | 32 ++++++++++---------------------- 3 files changed, 13 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index 57b1656..c1b5dcc 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,6 @@ Flags: Dependency-Track API key (can also be set with $DEPENDENCY_TRACK_API_KEY) --dtrack.project-tags=DTRACK.PROJECT-TAGS Comma-separated list of project tags to filter on - --dtrack.project-version-regex=DTRACK.PROJECT-VERSION-REGEX - Regex to filter project versions --dtrack.poll-interval=6h Interval to poll Dependency-Track for metrics --log.level=info Only log messages with the given severity or above. One of: [debug, info, warn, error] diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index f8e770f..3888d6e 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -3,7 +3,6 @@ package exporter import ( "context" "net/http" - "regexp" "strconv" "sync" "time" @@ -23,10 +22,9 @@ const ( // Exporter exports metrics from a Dependency-Track server type Exporter struct { - Client *dtrack.Client - Logger log.Logger - ProjectTags []string - ProjectVersion *regexp.Regexp + Client *dtrack.Client + Logger log.Logger + ProjectTags []string mutex sync.RWMutex registry *prometheus.Registry @@ -375,12 +373,5 @@ func (e *Exporter) projectMatches(project dtrack.Project) bool { } } - // Filter by version - if e.ProjectVersion != nil { - if !e.ProjectVersion.MatchString(project.Version) { - return false - } - } - return true } diff --git a/main.go b/main.go index 6876745..e25ddba 100644 --- a/main.go +++ b/main.go @@ -6,7 +6,6 @@ import ( "net/http" "os" "os/signal" - "regexp" "strings" "syscall" @@ -33,14 +32,13 @@ func init() { func main() { var ( - webConfig = webflag.AddFlags(kingpin.CommandLine, ":9916") - metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() - dtAddress = kingpin.Flag("dtrack.address", fmt.Sprintf("Dependency-Track server address (can also be set with $%s)", envAddress)).Default("http://localhost:8080").Envar(envAddress).String() - dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() - dtProjectTags = kingpin.Flag("dtrack.project-tags", "Comma-separated list of project tags to filter on").String() - dtProjectVersion = kingpin.Flag("dtrack.project-version-regex", "Regex to filter project versions").String() - pollInterval = kingpin.Flag("dtrack.poll-interval", "Interval to poll Dependency-Track for metrics").Default("6h").Duration() - promlogConfig = promlog.Config{} + webConfig = webflag.AddFlags(kingpin.CommandLine, ":9916") + metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() + dtAddress = kingpin.Flag("dtrack.address", fmt.Sprintf("Dependency-Track server address (can also be set with $%s)", envAddress)).Default("http://localhost:8080").Envar(envAddress).String() + dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() + dtProjectTags = kingpin.Flag("dtrack.project-tags", "Comma-separated list of project tags to filter on").String() + pollInterval = kingpin.Flag("dtrack.poll-interval", "Interval to poll Dependency-Track for metrics").Default("6h").Duration() + promlogConfig = promlog.Config{} ) flag.AddFlags(kingpin.CommandLine, &promlogConfig) @@ -59,25 +57,15 @@ func main() { os.Exit(1) } - var projectVersion *regexp.Regexp - if *dtProjectVersion != "" { - projectVersion, err = regexp.Compile(*dtProjectVersion) - if err != nil { - level.Error(logger).Log("msg", "Error compiling project version regex", "err", err) - os.Exit(1) - } - } - var projectTags []string if *dtProjectTags != "" { projectTags = strings.Split(*dtProjectTags, ",") } e := exporter.Exporter{ - Client: c, - Logger: logger, - ProjectTags: projectTags, - ProjectVersion: projectVersion, + Client: c, + Logger: logger, + ProjectTags: projectTags, } ctx, cancel := context.WithCancel(context.Background()) From 64819bc523069df307777416ac67c2fb49fa0c17 Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Mon, 12 Jan 2026 02:19:15 +0100 Subject: [PATCH 3/7] Updated readme Signed-off-by: Azunna Ikonne --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c1b5dcc..a54228c 100644 --- a/README.md +++ b/README.md @@ -14,10 +14,10 @@ Flags: Address to listen on for web interface and telemetry. --web.metrics-path="/metrics" Path under which to expose metrics - --dtrack.address="http://localhost:8080" - Dependency-Track server address (can also be set with $DEPENDENCY_TRACK_ADDR) + --dtrack.address=DTRACK.ADDRESS + Dependency-Track server address (default: http://localhost:8080 or $DEPENDENCY_TRACK_ADDR) --dtrack.api-key=DTRACK.API-KEY - Dependency-Track API key (can also be set with $DEPENDENCY_TRACK_API_KEY) + Dependency-Track API key (default: $DEPENDENCY_TRACK_API_KEY) --dtrack.project-tags=DTRACK.PROJECT-TAGS Comma-separated list of project tags to filter on --dtrack.poll-interval=6h From cf13cb86ec686f862337182456b5c8f5c5774074 Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Mon, 12 Jan 2026 02:24:55 +0100 Subject: [PATCH 4/7] Add tests for project tag filtering and background poller --- internal/exporter/exporter_test.go | 116 +++++++++++++++++++++++++++++ 1 file changed, 116 insertions(+) diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go index 6d1e672..5490247 100644 --- a/internal/exporter/exporter_test.go +++ b/internal/exporter/exporter_test.go @@ -7,8 +7,10 @@ import ( "net/http/httptest" "strconv" "testing" + "time" dtrack "github.com/DependencyTrack/client-go" + "github.com/go-kit/log" "github.com/google/go-cmp/cmp" "github.com/google/uuid" ) @@ -116,3 +118,117 @@ func TestFetchPolicyViolations_Pagination(t *testing.T) { t.Errorf("unexpected policy violations:\n%s", diff) } } + +func TestProjectMatches(t *testing.T) { + tests := []struct { + name string + projectTags []string + project dtrack.Project + want bool + }{ + { + name: "no tags configured", + projectTags: []string{}, + project: dtrack.Project{Name: "test"}, + want: true, + }, + { + name: "project has matching tag", + projectTags: []string{"prod"}, + project: dtrack.Project{ + Name: "test", + Tags: []dtrack.Tag{{Name: "prod"}}, + }, + want: true, + }, + { + name: "project has multiple tags including matching one", + projectTags: []string{"prod"}, + project: dtrack.Project{ + Name: "test", + Tags: []dtrack.Tag{{Name: "web"}, {Name: "prod"}}, + }, + want: true, + }, + { + name: "project does not have matching tag", + projectTags: []string{"prod"}, + project: dtrack.Project{ + Name: "test", + Tags: []dtrack.Tag{{Name: "dev"}}, + }, + want: false, + }, + { + name: "project has no tags but filtering enabled", + projectTags: []string{"prod"}, + project: dtrack.Project{ + Name: "test", + }, + want: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + e := &Exporter{ + ProjectTags: tt.projectTags, + } + if got := e.projectMatches(tt.project); got != tt.want { + t.Errorf("projectMatches() = %v, want %v", got, tt.want) + } + }) + } +} + +func TestExporter_Run(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + // Mock Portfolio metrics + mux.HandleFunc("/api/v1/metrics/portfolio/latest", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(dtrack.PortfolioMetrics{}) + }) + + // Mock Projects + mux.HandleFunc("/api/v1/project", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Total-Count", "0") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]dtrack.Project{}) + }) + + // Mock Violations + mux.HandleFunc("/api/v1/violation", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Total-Count", "0") + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode([]dtrack.PolicyViolation{}) + }) + + client, _ := dtrack.NewClient(server.URL) + e := &Exporter{ + Client: client, + Logger: log.NewNopLogger(), + } + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + // Start exporter in background with short interval + go e.Run(ctx, 100*time.Millisecond) + + // Wait for at least one poll to complete + deadline := time.Now().Add(2 * time.Second) + for time.Now().Before(deadline) { + e.mutex.RLock() + reg := e.registry + e.mutex.RUnlock() + if reg != nil { + return + } + time.Sleep(100 * time.Millisecond) + } + + t.Fatal("Exporter failed to populate registry in time") +} From c69b752d8337eb1776f6a60ade5d520aa641932c Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Tue, 13 Jan 2026 12:09:33 +0100 Subject: [PATCH 5/7] feat: improve memory utilization Signed-off-by: Azunna Ikonne --- README.md | 36 +++-- internal/exporter/exporter.go | 223 ++++++++++++++++------------- internal/exporter/exporter_test.go | 93 ++++-------- main.go | 22 +-- 4 files changed, 191 insertions(+), 183 deletions(-) diff --git a/README.md b/README.md index a54228c..ea97fe4 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,8 @@ Flags: Comma-separated list of project tags to filter on --dtrack.poll-interval=6h Interval to poll Dependency-Track for metrics + --dtrack.initialize-violation-metrics + Initialize all possible violation metric combinations to 0 (default: true) --log.level=info Only log messages with the given severity or above. One of: [debug, info, warn, error] --log.format=logfmt Output format of log messages. One of: [logfmt, json] --version Show application version. @@ -35,14 +37,32 @@ The API key the exporter uses needs to have the following permissions: | Metric | Meaning | Labels | | ----------------------------------------------- | --------------------------------------------------------------------- | ------------------------------------------------ | -| dependency_track_portfolio_inherited_risk_score | The inherited risk score of the whole portfolio. | | -| dependency_track_portfolio_vulnerabilities | Number of vulnerabilities across the whole portfolio, by severity. | severity | -| dependency_track_portfolio_findings | Number of findings across the whole portfolio, audited and unaudited. | audited | -| dependency_track_project_info | Project information. | uuid, name, version, active, tags | -| dependency_track_project_vulnerabilities | Number of vulnerabilities for a project by severity. | uuid, name, version, severity | -| dependency_track_project_policy_violations | Policy violations for a project. | uuid, name, version, state, analysis, suppressed | -| dependency_track_project_last_bom_import | Last BOM import date, represented as a Unix timestamp. | uuid, name, version | -| dependency_track_project_inherited_risk_score | Inherited risk score for a project. | uuid, name, version | +| dependency_track_portfolio_inherited_risk_score | The inherited risk score of the whole portfolio. | | +| dependency_track_portfolio_vulnerabilities | Number of vulnerabilities across the whole portfolio, by severity. | severity | +| dependency_track_portfolio_findings | Number of findings across the whole portfolio, audited and unaudited. | audited | +| dependency_track_project_info | Project information. | uuid, name, version, classifier, active, tags | +| dependency_track_project_vulnerabilities | Number of vulnerabilities for a project by severity. | uuid, name, version, severity | +| dependency_track_project_policy_violations | Policy violations for a project. | uuid, name, version, type, state, analysis, suppressed | +| dependency_track_project_last_bom_import | Last BOM import date, represented as a Unix timestamp. | uuid, name, version | +| dependency_track_project_inherited_risk_score | Inherited risk score for a project. | uuid, name, version | + +## Performance & Memory Optimization + +If you have a very large Dependency-Track portfolio, the exporter can consume significant memory during polling due to the high cardinality of policy violation metrics. + +### High-Cardinality Metrics +By default, the exporter initializes 72 unique metric series for every project (combinations of violation types, states, etc.) to ensure they record `0` instead of being absent. + +To significantly reduce memory usage, you can disable this behavior: + +```bash +--dtrack.initialize-violation-metrics=false +``` + +When disabled, metric series will only be created when an actual violation is detected. + +### Streaming +The exporter uses streaming pagination to fetch data from Dependency-Track, ensuring that memory usage remains stable even as your portfolio grows. ## Example queries diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 3888d6e..e7b13cd 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -4,6 +4,7 @@ import ( "context" "net/http" "strconv" + "strings" "sync" "time" @@ -22,9 +23,10 @@ const ( // Exporter exports metrics from a Dependency-Track server type Exporter struct { - Client *dtrack.Client - Logger log.Logger - ProjectTags []string + Client *dtrack.Client + Logger log.Logger + ProjectTags []string + InitializeViolationMetrics bool mutex sync.RWMutex registry *prometheus.Registry @@ -228,30 +230,27 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe inheritedRiskScore, ) - projects, err := e.fetchProjects(ctx) - if err != nil { - return err - } - matchedProjects := make(map[string]struct{}) - for _, project := range projects { - if !e.projectMatches(project) { - continue - } - matchedProjects[project.UUID.String()] = struct{}{} - projTags := "," + err := e.forEachProject(ctx, func(project dtrack.Project) error { + projectUUID := project.UUID.String() + matchedProjects[projectUUID] = struct{}{} + + var projTags strings.Builder + projTags.WriteByte(',') for _, t := range project.Tags { - projTags = projTags + t.Name + "," + projTags.WriteString(t.Name) + projTags.WriteByte(',') } - info.With(prometheus.Labels{ - "uuid": project.UUID.String(), - "name": project.Name, - "version": project.Version, - "classifier": project.Classifier, - "active": strconv.FormatBool(project.Active), - "tags": projTags, - }).Set(1) + + info.WithLabelValues( + projectUUID, + project.Name, + project.Version, + project.Classifier, + strconv.FormatBool(project.Active), + projTags.String(), + ).Set(1) severities := map[string]int{ "CRITICAL": project.Metrics.Critical, @@ -261,63 +260,62 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe "UNASSIGNED": project.Metrics.Unassigned, } for severity, v := range severities { - vulnerabilities.With(prometheus.Labels{ - "uuid": project.UUID.String(), - "name": project.Name, - "version": project.Version, - "severity": severity, - }).Set(float64(v)) + vulnerabilities.WithLabelValues( + projectUUID, + project.Name, + project.Version, + severity, + ).Set(float64(v)) } - lastBOMImport.With(prometheus.Labels{ - "uuid": project.UUID.String(), - "name": project.Name, - "version": project.Version, - }).Set(float64(project.LastBOMImport)) - - inheritedRiskScore.With(prometheus.Labels{ - "uuid": project.UUID.String(), - "name": project.Name, - "version": project.Version, - }).Set(project.Metrics.InheritedRiskScore) + lastBOMImport.WithLabelValues( + projectUUID, + project.Name, + project.Version, + ).Set(float64(project.LastBOMImport)) + + inheritedRiskScore.WithLabelValues( + projectUUID, + project.Name, + project.Version, + ).Set(project.Metrics.InheritedRiskScore) // Initialize all the possible violation series with a 0 value so that it - // properly records increments from 0 -> 1 - for _, possibleType := range []string{"LICENSE", "OPERATIONAL", "SECURITY"} { - for _, possibleState := range []string{"INFO", "WARN", "FAIL"} { - for _, possibleAnalysis := range []dtrack.ViolationAnalysisState{ - dtrack.ViolationAnalysisStateApproved, - dtrack.ViolationAnalysisStateRejected, - dtrack.ViolationAnalysisStateNotSet, - // If there isn't any analysis for a policy - // violation then the value in the UI is - // actually empty. So let's represent that in - // these metrics as a possible analysis state. - "", - } { - for _, possibleSuppressed := range []string{"true", "false"} { - policyViolations.With(prometheus.Labels{ - "uuid": project.UUID.String(), - "name": project.Name, - "version": project.Version, - "type": possibleType, - "state": possibleState, - "analysis": string(possibleAnalysis), - "suppressed": possibleSuppressed, - }) + // properly records increments from 0 -> 1. + // Note: This accounts for 72 series per project. + if e.InitializeViolationMetrics { + for _, possibleType := range []string{"LICENSE", "OPERATIONAL", "SECURITY"} { + for _, possibleState := range []string{"INFO", "WARN", "FAIL"} { + for _, possibleAnalysis := range []string{ + string(dtrack.ViolationAnalysisStateApproved), + string(dtrack.ViolationAnalysisStateRejected), + string(dtrack.ViolationAnalysisStateNotSet), + "", + } { + for _, possibleSuppressed := range []string{"true", "false"} { + policyViolations.WithLabelValues( + projectUUID, + project.Name, + project.Version, + possibleType, + possibleState, + possibleAnalysis, + possibleSuppressed, + ).Set(0) + } } } } } - } - violations, err := e.fetchPolicyViolations(ctx) + return nil + }) if err != nil { return err } - for _, violation := range violations { + err = e.forEachPolicyViolation(ctx, func(violation dtrack.PolicyViolation) error { if _, ok := matchedProjects[violation.Project.UUID.String()]; !ok { - continue + return nil } var ( analysisState string @@ -327,51 +325,70 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe analysisState = string(analysis.State) suppressed = strconv.FormatBool(analysis.Suppressed) } - policyViolations.With(prometheus.Labels{ - "uuid": violation.Project.UUID.String(), - "name": violation.Project.Name, - "version": violation.Project.Version, - "type": violation.Type, - "state": violation.PolicyCondition.Policy.ViolationState, - "analysis": analysisState, - "suppressed": suppressed, - }).Inc() + policyViolations.WithLabelValues( + violation.Project.UUID.String(), + violation.Project.Name, + violation.Project.Version, + violation.Type, + violation.PolicyCondition.Policy.ViolationState, + analysisState, + suppressed, + ).Inc() + return nil + }) + if err != nil { + return err } return nil } -func (e *Exporter) fetchProjects(ctx context.Context) ([]dtrack.Project, error) { - return dtrack.FetchAll(func(po dtrack.PageOptions) (dtrack.Page[dtrack.Project], error) { - return e.Client.Project.GetAll(ctx, po) - }) +func (e *Exporter) forEachProject(ctx context.Context, fn func(dtrack.Project) error) error { + if len(e.ProjectTags) == 0 { + return dtrack.ForEach(func(po dtrack.PageOptions) (dtrack.Page[dtrack.Project], error) { + return e.Client.Project.GetAll(ctx, po) + }, fn) + } + + seen := make(map[string]struct{}) + for _, tag := range e.ProjectTags { + err := dtrack.ForEach(func(po dtrack.PageOptions) (dtrack.Page[dtrack.Project], error) { + return e.Client.Project.GetAllByTag(ctx, tag, po) + }, func(p dtrack.Project) error { + id := p.UUID.String() + if _, ok := seen[id]; ok { + return nil + } + seen[id] = struct{}{} + return fn(p) + }) + if err != nil { + return err + } + } + return nil } -func (e *Exporter) fetchPolicyViolations(ctx context.Context) ([]dtrack.PolicyViolation, error) { - return dtrack.FetchAll(func(po dtrack.PageOptions) (dtrack.Page[dtrack.PolicyViolation], error) { +func (e *Exporter) forEachPolicyViolation(ctx context.Context, fn func(dtrack.PolicyViolation) error) error { + return dtrack.ForEach(func(po dtrack.PageOptions) (dtrack.Page[dtrack.PolicyViolation], error) { return e.Client.PolicyViolation.GetAll(ctx, true, po) - }) + }, fn) } -func (e *Exporter) projectMatches(project dtrack.Project) bool { - // Filter by tags - if len(e.ProjectTags) > 0 { - found := false - for _, t := range project.Tags { - for _, filterTag := range e.ProjectTags { - if t.Name == filterTag { - found = true - break - } - } - if found { - break - } - } - if !found { - return false - } - } +func (e *Exporter) fetchProjects(ctx context.Context) ([]dtrack.Project, error) { + var projects []dtrack.Project + err := e.forEachProject(ctx, func(p dtrack.Project) error { + projects = append(projects, p) + return nil + }) + return projects, err +} - return true +func (e *Exporter) fetchPolicyViolations(ctx context.Context) ([]dtrack.PolicyViolation, error) { + var violations []dtrack.PolicyViolation + err := e.forEachPolicyViolation(ctx, func(v dtrack.PolicyViolation) error { + violations = append(violations, v) + return nil + }) + return violations, err } diff --git a/internal/exporter/exporter_test.go b/internal/exporter/exporter_test.go index 5490247..0f402d7 100644 --- a/internal/exporter/exporter_test.go +++ b/internal/exporter/exporter_test.go @@ -67,6 +67,37 @@ func TestFetchProjects_Pagination(t *testing.T) { } } +func TestFetchProjectsByTag_Pagination(t *testing.T) { + mux := http.NewServeMux() + server := httptest.NewServer(mux) + defer server.Close() + + wantProjects := []dtrack.Project{ + {UUID: uuid.New(), Name: "prod-project"}, + } + + mux.HandleFunc("/api/v1/project/tag/prod", func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("X-Total-Count", "1") + w.Header().Set("Content-type", "application/json") + json.NewEncoder(w).Encode(wantProjects) + }) + + client, _ := dtrack.NewClient(server.URL) + e := &Exporter{ + Client: client, + ProjectTags: []string{"prod"}, + } + + gotProjects, err := e.fetchProjects(context.Background()) + if err != nil { + t.Fatalf("unexpected error fetching projects: %s", err) + } + + if diff := cmp.Diff(wantProjects, gotProjects); diff != "" { + t.Errorf("unexpected projects:\n%s", diff) + } +} + func TestFetchPolicyViolations_Pagination(t *testing.T) { mux := http.NewServeMux() server := httptest.NewServer(mux) @@ -119,68 +150,6 @@ func TestFetchPolicyViolations_Pagination(t *testing.T) { } } -func TestProjectMatches(t *testing.T) { - tests := []struct { - name string - projectTags []string - project dtrack.Project - want bool - }{ - { - name: "no tags configured", - projectTags: []string{}, - project: dtrack.Project{Name: "test"}, - want: true, - }, - { - name: "project has matching tag", - projectTags: []string{"prod"}, - project: dtrack.Project{ - Name: "test", - Tags: []dtrack.Tag{{Name: "prod"}}, - }, - want: true, - }, - { - name: "project has multiple tags including matching one", - projectTags: []string{"prod"}, - project: dtrack.Project{ - Name: "test", - Tags: []dtrack.Tag{{Name: "web"}, {Name: "prod"}}, - }, - want: true, - }, - { - name: "project does not have matching tag", - projectTags: []string{"prod"}, - project: dtrack.Project{ - Name: "test", - Tags: []dtrack.Tag{{Name: "dev"}}, - }, - want: false, - }, - { - name: "project has no tags but filtering enabled", - projectTags: []string{"prod"}, - project: dtrack.Project{ - Name: "test", - }, - want: false, - }, - } - - for _, tt := range tests { - t.Run(tt.name, func(t *testing.T) { - e := &Exporter{ - ProjectTags: tt.projectTags, - } - if got := e.projectMatches(tt.project); got != tt.want { - t.Errorf("projectMatches() = %v, want %v", got, tt.want) - } - }) - } -} - func TestExporter_Run(t *testing.T) { mux := http.NewServeMux() server := httptest.NewServer(mux) diff --git a/main.go b/main.go index e25ddba..a32ab5b 100644 --- a/main.go +++ b/main.go @@ -32,13 +32,14 @@ func init() { func main() { var ( - webConfig = webflag.AddFlags(kingpin.CommandLine, ":9916") - metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() - dtAddress = kingpin.Flag("dtrack.address", fmt.Sprintf("Dependency-Track server address (can also be set with $%s)", envAddress)).Default("http://localhost:8080").Envar(envAddress).String() - dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() - dtProjectTags = kingpin.Flag("dtrack.project-tags", "Comma-separated list of project tags to filter on").String() - pollInterval = kingpin.Flag("dtrack.poll-interval", "Interval to poll Dependency-Track for metrics").Default("6h").Duration() - promlogConfig = promlog.Config{} + webConfig = webflag.AddFlags(kingpin.CommandLine, ":9916") + metricsPath = kingpin.Flag("web.metrics-path", "Path under which to expose metrics").Default("/metrics").String() + dtAddress = kingpin.Flag("dtrack.address", fmt.Sprintf("Dependency-Track server address (can also be set with $%s)", envAddress)).Default("http://localhost:8080").Envar(envAddress).String() + dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() + dtProjectTags = kingpin.Flag("dtrack.project-tags", "Comma-separated list of project tags to filter on").String() + pollInterval = kingpin.Flag("dtrack.poll-interval", "Interval to poll Dependency-Track for metrics").Default("6h").Duration() + dtInitializeViolationMetrics = kingpin.Flag("dtrack.initialize-violation-metrics", "Initialize all possible violation metric combinations to 0 (can also be set with $DEPENDENCY_TRACK_INITIALIZE_VIOLATION_METRICS)").Default("true").Envar("DEPENDENCY_TRACK_INITIALIZE_VIOLATION_METRICS").Bool() + promlogConfig = promlog.Config{} ) flag.AddFlags(kingpin.CommandLine, &promlogConfig) @@ -63,9 +64,10 @@ func main() { } e := exporter.Exporter{ - Client: c, - Logger: logger, - ProjectTags: projectTags, + Client: c, + Logger: logger, + ProjectTags: projectTags, + InitializeViolationMetrics: *dtInitializeViolationMetrics, } ctx, cancel := context.WithCancel(context.Background()) From 8a81e538206e0e573af30cf5422709671d9a9020 Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Tue, 13 Jan 2026 13:19:16 +0100 Subject: [PATCH 6/7] fix: fix argument parsing and tags label Signed-off-by: Azunna Ikonne --- internal/exporter/exporter.go | 8 +++----- main.go | 11 +++++++++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index e7b13cd..7c2da1f 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -236,11 +236,9 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe projectUUID := project.UUID.String() matchedProjects[projectUUID] = struct{}{} - var projTags strings.Builder - projTags.WriteByte(',') + var tags []string for _, t := range project.Tags { - projTags.WriteString(t.Name) - projTags.WriteByte(',') + tags = append(tags, t.Name) } info.WithLabelValues( @@ -249,7 +247,7 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe project.Version, project.Classifier, strconv.FormatBool(project.Active), - projTags.String(), + strings.Join(tags, ","), ).Set(1) severities := map[string]int{ diff --git a/main.go b/main.go index a32ab5b..27105b2 100644 --- a/main.go +++ b/main.go @@ -6,6 +6,7 @@ import ( "net/http" "os" "os/signal" + "strconv" "strings" "syscall" @@ -38,7 +39,7 @@ func main() { dtAPIKey = kingpin.Flag("dtrack.api-key", fmt.Sprintf("Dependency-Track API key (can also be set with $%s)", envAPIKey)).Envar(envAPIKey).Required().String() dtProjectTags = kingpin.Flag("dtrack.project-tags", "Comma-separated list of project tags to filter on").String() pollInterval = kingpin.Flag("dtrack.poll-interval", "Interval to poll Dependency-Track for metrics").Default("6h").Duration() - dtInitializeViolationMetrics = kingpin.Flag("dtrack.initialize-violation-metrics", "Initialize all possible violation metric combinations to 0 (can also be set with $DEPENDENCY_TRACK_INITIALIZE_VIOLATION_METRICS)").Default("true").Envar("DEPENDENCY_TRACK_INITIALIZE_VIOLATION_METRICS").Bool() + dtInitializeViolationMetrics = kingpin.Flag("dtrack.initialize-violation-metrics", "Initialize all possible violation metric combinations to 0").Default("true").String() promlogConfig = promlog.Config{} ) @@ -63,11 +64,17 @@ func main() { projectTags = strings.Split(*dtProjectTags, ",") } + initViolationMetrics, err := strconv.ParseBool(*dtInitializeViolationMetrics) + if err != nil { + level.Error(logger).Log("msg", "Error parsing dtrack.initialize-violation-metrics", "err", err) + os.Exit(1) + } + e := exporter.Exporter{ Client: c, Logger: logger, ProjectTags: projectTags, - InitializeViolationMetrics: *dtInitializeViolationMetrics, + InitializeViolationMetrics: initViolationMetrics, } ctx, cancel := context.WithCancel(context.Background()) From 71b88e7e83ca7777fa4cae3affe0109c134dc3d6 Mon Sep 17 00:00:00 2001 From: Azunna Ikonne Date: Tue, 13 Jan 2026 17:56:09 +0100 Subject: [PATCH 7/7] chore: slight modifications Signed-off-by: Azunna Ikonne --- internal/exporter/exporter.go | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 7c2da1f..65d81f8 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -283,10 +283,10 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe if e.InitializeViolationMetrics { for _, possibleType := range []string{"LICENSE", "OPERATIONAL", "SECURITY"} { for _, possibleState := range []string{"INFO", "WARN", "FAIL"} { - for _, possibleAnalysis := range []string{ - string(dtrack.ViolationAnalysisStateApproved), - string(dtrack.ViolationAnalysisStateRejected), - string(dtrack.ViolationAnalysisStateNotSet), + for _, possibleAnalysis := range []dtrack.ViolationAnalysisState{ + dtrack.ViolationAnalysisStateApproved, + dtrack.ViolationAnalysisStateRejected, + dtrack.ViolationAnalysisStateNotSet, "", } { for _, possibleSuppressed := range []string{"true", "false"} { @@ -296,7 +296,7 @@ func (e *Exporter) collectProjectMetrics(ctx context.Context, registry *promethe project.Version, possibleType, possibleState, - possibleAnalysis, + string(possibleAnalysis), possibleSuppressed, ).Set(0) }