Conversation
|
|
||
| onceRuntimeMetrics.Do(func() { | ||
| runtimeMetricsRunning = true | ||
| }) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit 0aeba9f. Configure here.
There was a problem hiding this comment.
Cursor Bugbot has reviewed your changes and found 2 potential issues.
There are 3 total unresolved issues (including 1 from previous review).
❌ 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.
| 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)) |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit e35aa47. Configure here.
| for j := uint64(0); j < count; j++ { | ||
| meter.Distribution(runtimeMetricsKeys[i].Key, value, WithUnit(runtimeMetricsKeys[i].Unit)) | ||
| } | ||
| } |
There was a problem hiding this comment.
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)
Reviewed by Cursor Bugbot for commit e35aa47. Configure here.
| for j := uint64(0); j < count; j++ { | ||
| meter.Distribution(runtimeMetricsKeys[i].Key, value, WithUnit(runtimeMetricsKeys[i].Unit)) | ||
| } |
There was a problem hiding this comment.
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.
| now func() time.Time | ||
| } | ||
|
|
||
| var maxProcs = float64(runtime.GOMAXPROCS(-1)) |
There was a problem hiding this comment.
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
| // 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 |
There was a problem hiding this comment.
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.
| // 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 |
There was a problem hiding this comment.
I agree that we should have only a single collector running at once, but an atomic.Bool is better here to allow restarts.
| // CollectGCMetrics enables the collection of GC metrics. | ||
| // Default is false (disabled). | ||
| CollectGCMetrics bool |
There was a problem hiding this comment.
I would name this something along the lines of CollectOptionalMetrics. We might add more than gc metrics in the future.
| // 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"}, | ||
| ) | ||
| } |
There was a problem hiding this comment.
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.
|
|
||
| if config.Context == nil { | ||
| config.Context = SetHubOnContext(context.Background(), hub) | ||
| } |
There was a problem hiding this comment.
We need to set the hub for both contexts
| {"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}, |
There was a problem hiding this comment.
These should be go.memory.used and have attributes that define the detailed_type.
| 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 | ||
| } |
There was a problem hiding this comment.
We can just get this from /cpu/classes/total:cpu-seconds and /cpu/classes/idle:cpu-seconds
| for j := 0; j < len(hist.Counts); j++ { | ||
| count := hist.Counts[j] |
There was a problem hiding this comment.
Both bugbot comments are correct here, and I don't think it's that trivial to properly support the histograms here.
| case MetricTypeCounter: | ||
| meter.Count(runtimeMetricsKeys[i].Key, int64(value), WithUnit(runtimeMetricsKeys[i].Unit)) |


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
SetHubOnContextshould besentry.SetHubOnContext, etc). Then use it, like so:Issues
Changelog Entry Instructions
To add a custom changelog entry, uncomment the section above. Supports:
For more details: custom changelog entries
Reminders
feat:,fix:,ref:,meta:)