Skip to content

feat(collectors): periodically refresh google.projects.filter to pick up new/removed projects without a restart - #540

Merged
kgeckhart merged 5 commits into
prometheus-community:masterfrom
ntmspavan:fix/issue-538-projects-filter-refresh
Sep 21, 2026
Merged

kgeckhart merged 5 commits into
prometheus-community:masterfrom
ntmspavan:fix/issue-538-projects-filter-refresh

Conversation

@ntmspavan

@ntmspavan ntmspavan commented Aug 24, 2026

Copy link
Copy Markdown
Contributor

Problem & Impact

google.projects.filter is resolved to a concrete project list once at startup. In long-lived Kubernetes deployments, projects added to or removed from the matching org/folder are never reflected until the pod restarts — new projects silently go unmonitored and deleted ones are queried needlessly.

Fixes #538

Solution

Adds an opt-in google.projects.filter-refresh-interval flag (default 0, disabled — fully backward compatible). When set and google.projects.filter is configured, a background goroutine re-resolves the filter on that interval and atomically swaps the in-memory project list (lock-free atomic.Pointer[[]string], safe under concurrent scrapes). A transient API error or an unexpectedly empty result never overwrites a known-good list. The HTTP handler now rebuilds its collector registry per scrape (as the ?collect=-filtered path already did) so the refreshed list actually reaches /metrics.

Verification

  • go build ./...
  • go vet ./...
  • go test ./... -race (new tests cover refresh success/error/empty-result handling, concurrent access, and a regression test for WithCache pointer sharing)

Live Testing

Verified end-to-end against a real GCP organization (not just unit tests), using a binary built from this branch (fix/issue-538-projects-filter-refresh, commit 5a92ca8).

Tool version: stackdriver_exporter built from commit 5a92ca81002d04a83fcc820e0fedf8feb24fc37f, go1.25.8, darwin/arm64.

Setup: created two throwaway GCP projects under a real organization and ran the exporter against the org with:

--google.projects.filter='parent.id:<org-id> lifecycleState:ACTIVE'
--google.projects.filter-refresh-interval=15s
--monitoring.metrics-prefixes=compute.googleapis.com/

Before: only project A exists

$ curl -s localhost:9255/metrics | grep -oE 'project_id="[^"]+"' | sort -u
project_id="sdexp-refresh-a-1788844202"

Add project B — no exporter restart

Created a second project (sdexp-refresh-b-1788844331) under the same org while the exporter kept running (same PID throughout). GCP's Resource Manager list API is eventually consistent — it took ~75s for the new project to become visible to projects.list, which the background refresh then picked up on its next tick:

time=...T22:11:55 msg="Starting stackdriver_exporter" ...
time=...T22:12:11 msg="refreshed project list from google.projects.filter" count=1
time=...T22:12:26 msg="refreshed project list from google.projects.filter" count=1
time=...T22:12:41 msg="refreshed project list from google.projects.filter" count=1
time=...T22:12:56 msg="refreshed project list from google.projects.filter" count=1
time=...T22:13:11 msg="refreshed project list from google.projects.filter" count=2   <-- project B picked up here
$ curl -s localhost:9255/metrics | grep -oE 'project_id="[^"]+"' | sort -u
project_id="sdexp-refresh-a-1788844202"
project_id="sdexp-refresh-b-1788844331"

Remove project B — no exporter restart

Deleted project B (still the same running process). The next refresh dropped it automatically:

time=...T22:14:11 msg="refreshed project list from google.projects.filter" count=2
time=...T22:14:26 msg="refreshed project list from google.projects.filter" count=1   <-- project B dropped here
$ curl -s localhost:9255/metrics | grep -oE 'project_id="[^"]+"' | sort -u
project_id="sdexp-refresh-a-1788844202"

This is the exact bug from #538 reproduced and fixed: previously, this whole cycle would have required restarting the exporter for either the addition or removal of project B to ever show up in /metrics. Here it self-heals within one refresh interval, with the exporter process never restarted (single PID for the full test).

…up new/removed projects without a restart

google.projects.filter was resolved to a concrete project list once at
startup, so projects added to or removed from the matching org/folder
were never reflected until the exporter restarted. Adds an opt-in
google.projects.filter-refresh-interval flag (default 0s, disabled) that
re-evaluates the filter on a background ticker and atomically swaps the
in-memory project list, and updates the HTTP handler to rebuild its
collector registry per scrape so the refreshed list actually reaches
/metrics.

Fixes prometheus-community#538

Signed-off-by: Pavan Nalam <pavan.nalam3693@gmail.com>
@ntmspavan
ntmspavan force-pushed the fix/issue-538-projects-filter-refresh branch from 5a92ca8 to a61d4cd Compare September 8, 2026 05:51
Comment thread stackdriver_exporter.go
@kgeckhart kgeckhart changed the title fix(collectors): periodically refresh google.projects.filter to pick up new/removed projects without a restart feat(collectors): periodically refresh google.projects.filter to pick up new/removed projects without a restart Sep 18, 2026
Signed-off-by: Pavan Nalam <pavan.nalam3693@gmail.com>
@ntmspavan
ntmspavan force-pushed the fix/issue-538-projects-filter-refresh branch from d4daade to 2d40e35 Compare September 18, 2026 16:35
…ck while mutating

Get took an RLock (shared among concurrent callers) but still mutated
shared state on every call: deleting the map entry on expiry, or
writing entry.expiry on a hit. Two goroutines calling Get on the same
key concurrently could race on entry.expiry, or both call delete on
the same map concurrently, which Go maps do not support and can panic
the process with "fatal error: concurrent map writes".

This was pre-existing but rarely exercised, since only the ?collect=
filtered scrape path went through the cache per request. The prior
commit in this branch made the default /metrics path do the same on
every scrape, so any overlapping scrapes (HA Prometheus replicas,
manual curl during a scheduled scrape, etc.) now hit this far more
easily. Every path in Get mutates state, so there is no actual
read-only case for RLock to protect; switched to a plain Mutex,
matching what Store already used.

Added a concurrent-access regression test that reproduces the data
race under -race against the old RLock code and passes cleanly with
the fix.

Signed-off-by: Pavan Nalam <pavan.nalam3693@gmail.com>
@ntmspavan
ntmspavan force-pushed the fix/issue-538-projects-filter-refresh branch from 5f10d3b to 4fa4a40 Compare September 18, 2026 16:57
Comment thread collectors/runtime.go Outdated
@@ -222,9 +268,12 @@ func getProjectIDsFromFilter(ctx context.Context, filter string) ([]string, erro

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

ATM the refresh has no timeout on it and if something stalls on the google side the refresh will be blocked with no real indication of why.

I think the best way to resolve this is create a function to build the cloudresourcemanager service similar to the monitoringservice,

func createMonitoringService(ctx context.Context, cfg *config.Config) (*monitoring.Service, error) {
. AFAICT this is safe to create once and hold like we do with the monitoringservice. This should allow us to construct it in the same way with the googleclient timeout + retries. I think universedomain should be skipped.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kgeckhart for the feedback! Addressed in latest commit:

  • Added createResourceManagerService with consistent timeout/retry logic and CloudPlatformReadOnlyScope.
  • Initialized service once in NewRuntime to reuse across refresh intervals instead of reconstructing per call.

…and retries

getProjectIDsFromFilter constructed a bare cloudresourcemanager.Service
with cloudresourcemanager.NewService(ctx) on every call: no timeout, no
retry handling. It ran once at startup and again on every
google.projects.filter-refresh-interval tick, so a stall on Google's
side during a refresh would block with no indication why, and the
service was needlessly rebuilt from scratch each refresh.

Adds createResourceManagerService in service.go, mirroring
createMonitoringService: same cfg.HTTPTimeout, MaxRetries,
RetryStatuses, BackoffJitter, and MaxBackoff wiring via rehttp, using
CloudPlatformReadOnlyScope (least-privilege) instead of the broader
default scope the bare constructor fell back to, and skipping
UniverseDomain. NewRuntime now constructs it once, only when
ProjectsFilter is set, and closes over it for both the initial
resolution and every later refresh via the existing discoverProjectIDs
seam.

Signed-off-by: Pavan Nalam <pavan.nalam3693@gmail.com>
…* timeout/retry settings

The stackdriver.http-timeout, stackdriver.max-retries, and
stackdriver.retry-statuses descriptions read as Monitoring-API-only,
but createResourceManagerService now applies the same settings to
resolving and refreshing google.projects.filter.

Signed-off-by: Pavan Nalam <pavan.nalam3693@gmail.com>
@ntmspavan
ntmspavan force-pushed the fix/issue-538-projects-filter-refresh branch from d5e752c to 44eedd8 Compare September 21, 2026 14:54
@kgeckhart
kgeckhart merged commit 76cad32 into prometheus-community:master Sep 21, 2026
17 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

google.projects.filter is only evaluated once at startup — new/removed child projects aren't picked up without a restart

3 participants