Skip to content

feat: Runtime metrics integration - #1400

Open
aldy505 wants to merge 7 commits into
getsentry:masterfrom
aldy505:feat/runtime-metrics
Open

aldy505 wants to merge 7 commits into
getsentry:masterfrom
aldy505:feat/runtime-metrics

Conversation

@aldy505

@aldy505 aldy505 commented Aug 29, 2026

Copy link
Copy Markdown
Contributor

Description

This is a proof of concept that I talked really briefly on Slack. Might or might not be merged. NodeJS has this integration, and I thought, this is easy and really possible in Go. Let's just have it!

To use or test this feature/integration, just copy and paste the code into your codebase, and fix all the import bugs (stuff like SetHubOnContext should be sentry.SetHubOnContext, etc). Then use it, like so:

// optional, but recommended. otherwise how would you handle graceful shutdown?
ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
defer cancel()

if err := sentry.Init(sentry.ClientOptions{}); err != nil {
  panic("yada yada yada")
}
defer sentry.FlushWithContext(ctx)
go sentry.StartRuntimeMetrics(sentry.RuntimeMetricsConfig{Context: ctx})

// the rest of your program

Issues

Changelog Entry Instructions

To add a custom changelog entry, uncomment the section above. Supports:

  • Single entry: just write text
  • Multiple entries: use bullet points
  • Nested bullets: indent 4+ spaces

For more details: custom changelog entries

Reminders

Comment thread runtime_metrics.go Outdated
Comment thread runtime_metrics.go Outdated
Comment thread runtime_metrics.go Outdated
Comment thread runtime_metrics.go Outdated
Comment thread runtime_metrics.go

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Stale Bugbot comment from a previous run.

Comment thread runtime_metrics.go

onceRuntimeMetrics.Do(func() {
runtimeMetricsRunning = true
})

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Broken single-start race

High Severity

StartRuntimeMetrics moved the collector loop out of sync.Once and now relies on an unsynchronized runtimeMetricsRunning check. Concurrent callers can both pass that check, then both start loops that share runtimeMetricsSamples, which runtime/metrics.Read forbids.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit 0aeba9f. Configure here.

@cursor cursor Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cursor Bugbot has reviewed your changes and found 2 potential issues.

There are 3 total unresolved issues (including 1 from previous review).

Fix All in Cursor

❌ Bugbot Autofix is OFF. To automatically fix reported issues with cloud agents, enable autofix in the Cursor dashboard.

Want reviews to match your repository better? Bugbot Learning can learn team-specific rules from PR activity. A team admin can enable Learning in the Cursor dashboard.

Reviewed by Cursor Bugbot for commit e35aa47. Configure here.

Comment thread runtime_metrics.go
case MetricTypeGauge:
meter.Gauge(runtimeMetricsKeys[i].Key, value, WithUnit(runtimeMetricsKeys[i].Unit))
case MetricTypeCounter:
meter.Count(runtimeMetricsKeys[i].Key, int64(value), WithUnit(runtimeMetricsKeys[i].Unit))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Cumulative counters sent as increments

High Severity

/gc/heap/allocs:bytes and /gc/cycles/total:gc-cycles are lifetime totals, but each scrape passes the raw reading into Count, which increments. Sentry then sums those values, so go.memory.allocated and go.memory.gc.cycles inflate far past the real totals.

Additional Locations (2)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e35aa47. Configure here.

Comment thread runtime_metrics.go
for j := uint64(0); j < count; j++ {
meter.Distribution(runtimeMetricsKeys[i].Key, value, WithUnit(runtimeMetricsKeys[i].Unit))
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Histogram re-emits all historical pauses

High Severity

/sched/pauses/total/gc:seconds is a cumulative histogram, but every interval expands every bucket count into individual Distribution samples. That re-reports every GC pause since start and can emit an unbounded number of metrics as counts grow.

Additional Locations (1)
Fix in Cursor Fix in Web

Reviewed by Cursor Bugbot for commit e35aa47. Configure here.

Comment thread runtime_metrics.go
Comment on lines +242 to +244
for j := uint64(0); j < count; j++ {
meter.Distribution(runtimeMetricsKeys[i].Key, value, WithUnit(runtimeMetricsKeys[i].Unit))
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The GC histogram collection loop re-emits all historical data points on every tick, causing unbounded memory and CPU usage over time as the cumulative count grows.
Severity: CRITICAL

Suggested Fix

The implementation should track the delta between metric readings. Store the previous cumulative counts for each histogram bucket. On each tick, calculate the difference between the new cumulative count and the stored previous count. Iterate only new_count - old_count times to emit only the new events that have occurred since the last collection. Update the stored counts after processing.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: runtime_metrics.go#L242-L244

Potential issue: The loop processing GC histogram metrics at `runtime_metrics.go:242`
iterates `count` times, where `count` is a cumulative total of GC events since program
start. Because this count is never reset, the loop re-emits the entire history of GC
pauses on every collection tick. For a long-running service, this leads to unbounded
growth in loop iterations, memory allocations for `attrs` maps, and CPU usage,
eventually causing the metrics goroutine to hang and consume excessive memory.

Comment thread runtime_metrics.go
Comment thread runtime_metrics.go
now func() time.Time
}

var maxProcs = float64(runtime.GOMAXPROCS(-1))

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Bug: The maxProcs variable is cached at startup and not updated. If GOMAXPROCS changes at runtime, CPU utilization metrics will become incorrect.
Severity: HIGH

Suggested Fix

Instead of caching the value in a package-level variable, call runtime.GOMAXPROCS(-1) inside the GetCPUUtilization function on each invocation. This ensures the calculation always uses the current number of available processors.

Prompt for AI Agent
Review the code at the location below. A potential bug has been identified by an AI
agent. Verify if this is a real issue. If it is, propose a fix; if not, explain why it's
not valid.

Location: runtime_metrics.go#L98

Potential issue: The `maxProcs` variable is initialized once at package load time on
line 98 and is never updated. However, in containerized environments, the Go runtime
(version 1.25+) can automatically adjust the `GOMAXPROCS` value at runtime if CPU limits
change. When this happens, the CPU utilization calculation will use the stale `maxProcs`
value, leading to significantly under- or over-reported metrics. This makes monitoring
and capacity planning based on this metric unreliable in modern deployment environments.

Also affects:

  • runtime_metrics.go:119~122

Comment thread runtime_metrics.go
Comment on lines +21 to +24
// Interval is the interval at which the runtime metrics are collected.
// Default is 30 seconds. You don't want to set this too low, as it will
// trigger a lot of "stop-the-world" activity.
Interval time.Duration

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.

This is misleading since the runtime/metrics library doesn't actually stop the world. Also the only thing this changes is collection frequency, and I don't think the the overhead is that high in general. Let's still keep the default at 30s, but I don't think that setting it to even 5s would be a problem.

Comment thread runtime_metrics.go
Comment on lines +71 to +81
// To ensure that the runtime metrics integration is only started once.
// From the Go docs:
//
// > It is safe to execute multiple Read calls concurrently, but their arguments
// > must share no underlying memory. When in doubt, create a new []Sample from
// > scratch, which is always safe, though may be inefficient.
var onceRuntimeMetrics = sync.Once{}

// A simple marker to guarantee that the runtime metrics integration is only
// started once.
var runtimeMetricsRunning = false

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.

I agree that we should have only a single collector running at once, but an atomic.Bool is better here to allow restarts.

Comment thread runtime_metrics.go
Comment on lines +29 to +31
// CollectGCMetrics enables the collection of GC metrics.
// Default is false (disabled).
CollectGCMetrics bool

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.

I would name this something along the lines of CollectOptionalMetrics. We might add more than gc metrics in the future.

Comment thread runtime_metrics.go
Comment on lines +148 to +161
// Handle opt-in metrics
if config.CollectGCMetrics {
runtimeMetricsKeys = append(
runtimeMetricsKeys,
runtimeMetricKeyMap{"go.memory.gc.cycles", "cycles", MetricTypeCounter},
runtimeMetricKeyMap{"go.memory.gc.pause", UnitSecond, MetricTypeDistribution},
)

runtimeMetricsSamples = append(
runtimeMetricsSamples,
runtime_metrics.Sample{Name: "/gc/cycles/total:gc-cycles"},
runtime_metrics.Sample{Name: "/sched/pauses/total/gc:seconds"},
)
}

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.

If we allow collector restarts that I recommend above, this becomes harder to manage and seem kinda hacky. I'd prefer we explicitly handle both, or at least clear the memory first rather than append.

Comment thread runtime_metrics.go

if config.Context == nil {
config.Context = SetHubOnContext(context.Background(), hub)
}

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.

We need to set the hub for both contexts

Comment thread runtime_metrics.go
Comment on lines +51 to +56
{"go.memory.used.total", UnitByte, MetricTypeGauge},
{"go.memory.used.heap.objects", UnitByte, MetricTypeGauge},
{"go.memory.used.heap.free", UnitByte, MetricTypeGauge},
{"go.memory.used.heap.unused", UnitByte, MetricTypeGauge},
{"go.memory.used.heap.stacks", UnitByte, MetricTypeGauge},
{"go.memory.used.other", UnitByte, MetricTypeGauge},

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.

These should be go.memory.used and have attributes that define the detailed_type.

Comment thread runtime_metrics.go
Comment on lines +98 to +137
var maxProcs = float64(runtime.GOMAXPROCS(-1))

func (t *cpuUtilizationTracker) GetCPUUtilization(currentCPUSeconds float64) float64 {
now := t.now
if now == nil {
now = time.Now
}
nowTime := now()

// First sample — can't calculate yet
if t.lastSampleTime.IsZero() {
t.lastCPUSeconds = currentCPUSeconds
t.lastSampleTime = nowTime
return 0.0
}

// Calculate deltas
cpuDelta := currentCPUSeconds - t.lastCPUSeconds
wallClockDelta := nowTime.Sub(t.lastSampleTime).Seconds()

// Normalize by GOMAXPROCS to get utilization percentage
// cpuDelta represents CPU-seconds consumed across all GOMAXPROCS goroutines
// wallClockDelta is real time that passed
// Divide by GOMAXPROCS to account for parallel CPUs
utilization := cpuDelta / (wallClockDelta * maxProcs)

// Clamp to [0.0, 1.0] (shouldn't exceed unless there's jitter)
if utilization > 1.0 {
utilization = 1.0
}
if utilization < 0.0 {
utilization = 0.0
}

// Update state for next sample
t.lastCPUSeconds = currentCPUSeconds
t.lastSampleTime = nowTime

return utilization
}

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.

We can just get this from /cpu/classes/total:cpu-seconds and /cpu/classes/idle:cpu-seconds

Comment thread runtime_metrics.go
Comment on lines +242 to +243
for j := 0; j < len(hist.Counts); j++ {
count := hist.Counts[j]

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.

Both bugbot comments are correct here, and I don't think it's that trivial to properly support the histograms here.

Comment thread runtime_metrics.go
Comment on lines +217 to +218
case MetricTypeCounter:
meter.Count(runtimeMetricsKeys[i].Key, int64(value), WithUnit(runtimeMetricsKeys[i].Unit))

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.

Counters need deltas.

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.

2 participants