diff --git a/README.md b/README.md index be9916e46..801ea68fa 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,41 @@ func init() { ### Collecting Metrics Once the collector has been initialized with `instana.InitCollector`, application metrics such as memory, CPU consumption, active goroutine count etc will be automatically collected and reported to the Agent without further actions or configurations to the SDK. -This data is then already available in the dashboard. + +#### Metrics Transmission Interval + +Metrics are transmitted to the Instana Agent at a configurable interval. The interval depends on the deployment environment. + +##### Standard (Host Agent) Deployments + +The interval is configured through the Instana Agent's `configuration.yaml` file. + +**Configuration:** + +In the agent's `configuration.yaml`: +```yaml +# Configure metrics transmission interval for Go applications +com.instana.plugin.golang: + poll_rate: 5 # seconds +``` + +**Valid Values:** + +The accepted values are: `1`, `5`, `10`, `20`, `30`, `60`, `120`, `180`, `240`, `300`, `360`, `420`, `480`, `540`, `600` (seconds). + +- Default: `1` second (if not configured or if an invalid value is provided) + +**Behavior:** +- If `poll_rate` is not configured or is `<= 0`, defaults to `1` second. +- If `poll_rate` is a positive value not in the canonical set above, a warning is logged and the value is used as-is. Range enforcement is the responsibility of the Instana Agent. +- Configuration is read from the agent once, during the initial handshake when the Go tracer starts up. + +> [!IMPORTANT] +> The `poll_rate` value is applied **only at Go tracer startup**. If you change `poll_rate` in the agent's `configuration.yaml` after the tracer is already running, the new value will **not** take effect until the Go application is restarted. This applies even if the Instana Agent itself is restarted — the tracer will continue using the interval it received during its own initial handshake. + +##### Serverless Deployments (AWS Fargate/ECS, AWS Lambda, Google Cloud Run, Azure Functions) + +In serverless environments, the Go tracer communicates directly with the Instana Serverless Acceptor and does not perform the host agent handshake. As a result, the `poll_rate` setting in `configuration.yaml` has no effect. The metrics transmission interval is fixed at **1 second** and cannot be configured. ### Tracing Calls diff --git a/agent.go b/agent.go index 84fbcbb0a..a010f1424 100644 --- a/agent.go +++ b/agent.go @@ -54,6 +54,9 @@ type agentResponse struct { ExtraHTTPHeaders []string `json:"extra-http-headers"` Disable []map[string]bool `json:"disable"` } `json:"tracing"` + PluginConfig struct { + PollRate int `json:"poll_rate"` // Poll rate in seconds + } `json:"plugin.golang"` } func (a *agentResponse) getExtraHTTPHeaders() []string { diff --git a/agent_test.go b/agent_test.go index b7de5034e..1ae431774 100644 --- a/agent_test.go +++ b/agent_test.go @@ -744,3 +744,76 @@ func TestAgent_IPv4vsIPv6(t *testing.T) { }) } } + +// TestNoopAgent_Methods verifies that all noopAgent methods return expected zero/nil +// values and do not panic. These are the fallback implementations before sensor init. +func TestNoopAgent_Methods(t *testing.T) { + tests := []struct { + name string + call func(noopAgent) error + wantErr bool + }{ + { + name: "SendMetrics returns nil", + call: func(a noopAgent) error { return a.SendMetrics(acceptor.Metrics{}) }, + wantErr: false, + }, + { + name: "SendEvent returns nil", + call: func(a noopAgent) error { return a.SendEvent(&EventData{}) }, + wantErr: false, + }, + { + name: "SendSpans returns nil", + call: func(a noopAgent) error { return a.SendSpans(nil) }, + wantErr: false, + }, + { + name: "SendProfiles returns nil", + call: func(a noopAgent) error { return a.SendProfiles(nil) }, + wantErr: false, + }, + { + name: "Flush returns nil", + call: func(a noopAgent) error { return a.Flush(context.Background()) }, + wantErr: false, + }, + } + + var a noopAgent + assert.False(t, a.Ready(), "noopAgent.Ready() must always return false") + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + err := tt.call(a) + if tt.wantErr { + assert.Error(t, err) + } else { + assert.NoError(t, err) + } + }) + } +} + +// TestAgentS_Flush verifies that agentS.Flush is a no-op that always returns nil. +func TestAgentS_Flush(t *testing.T) { + agent := &agentS{logger: defaultLogger} + assert.NoError(t, agent.Flush(context.Background())) +} + +// TestAgentS_SetLogger verifies that setLogger replaces the agent logger. +func TestAgentS_SetLogger(t *testing.T) { + agent := &agentS{logger: defaultLogger} + newLogger := &testLogger{} + agent.setLogger(newLogger) + assert.Equal(t, newLogger, agent.logger) +} + +// TestAgentS_SendMetrics_Error verifies that SendMetrics propagates a connection error +// and triggers a reset when the underlying agentComm cannot reach the host. +func TestAgentS_SendMetrics_Error(t *testing.T) { + agent := newAgent("test-service", "127.0.0.1", 1, defaultLogger) + agent.agentComm = newAgentCommunicator("127.0.0.1", "1", &fromS{EntityID: "123"}, defaultLogger) + + assert.Error(t, agent.SendMetrics(acceptor.Metrics{})) +} diff --git a/fsm.go b/fsm.go index 4601028ad..216bfce9d 100644 --- a/fsm.go +++ b/fsm.go @@ -29,6 +29,21 @@ const ( maximumRetries = 3 ) +// validPollRates is the canonical set of accepted poll_rate values (in seconds) as +// defined by the Instana Agent configuration schema. The go tracer does not enforce +// this set — it only warns when an unexpected value is received. +var validPollRates = []int{1, 5, 10, 20, 30, 60, 120, 180, 240, 300, 360, 420, 480, 540, 600} + +// isValidPollRate reports whether seconds is a member of the canonical validPollRates set. +func isValidPollRate(seconds int) bool { + for _, v := range validPollRates { + if v == seconds { + return true + } + } + return false +} + type fsmS struct { agentComm *agentCommunicator fsm *f.FSM @@ -268,10 +283,38 @@ func (r *fsmS) applyHostAgentSettings(resp agentResponse) { } r.applyDisableTracingConfig(resp) + r.applyMetricsPollRateConfig(resp) r.logger.Debug("CollectableHTTPHeaders used: ", sensor.options.Tracer.CollectableHTTPHeaders) } +// applyMetricsPollRateConfig applies the metrics poll rate configuration from agent response. +// If the received poll_rate is not a member of the canonical set defined by validPollRates, +// a warning is logged but the value is still applied — range enforcement is the +// responsibility of the Instana Agent. +func (r *fsmS) applyMetricsPollRateConfig(resp agentResponse) { + s, err := getSensor() + if err != nil { + r.logger.Debug("Sensor not initialized, skipping poll_rate configuration") + return + } + + // If no poll rate is provided by agent, use default (1 second) + if resp.PluginConfig.PollRate <= 0 { + r.logger.Debug("No poll_rate configuration received from agent, using default 1 second") + s.options.Metrics.setTransmissionInterval(defaultTransmissionInterval) + return + } + + if !isValidPollRate(resp.PluginConfig.PollRate) { + r.logger.Warn("poll_rate value from agent (", resp.PluginConfig.PollRate, ") is not in the canonical set ", + validPollRates, ". The value will be used as-is; ensure the Instana Agent configuration is correct.") + } + + r.logger.Debug("Applying metrics poll_rate configuration from agent: ", resp.PluginConfig.PollRate, " second(s)") + s.options.Metrics.setTransmissionInterval(resp.PluginConfig.PollRate) +} + func (r *fsmS) applyDisableTracingConfig(resp agentResponse) { // Do nothing if we have no configuration from the agent if len(resp.Tracing.Disable) == 0 { @@ -420,6 +463,17 @@ func (r *fsmS) reset() { func (r *fsmS) ready(_ context.Context, e *f.Event) { go delayed.flush() + s, err := getSensor() + if err != nil { + r.logger.Error(err.Error()) + return + } + interval := s.options.Metrics.getTransmissionInterval() + if interval <= 0 { + s.options.Metrics.setTransmissionInterval(defaultTransmissionInterval) + interval = s.options.Metrics.getTransmissionInterval() + } + s.meter.Run(interval) } func (r *fsmS) cpuSetFileContent(pid int) string { diff --git a/fsm_test.go b/fsm_test.go index 6c8e69581..1f45821c1 100644 --- a/fsm_test.go +++ b/fsm_test.go @@ -21,6 +21,7 @@ import ( type testLogger struct { infoMsg string + warnMsg string errMsg string } @@ -28,7 +29,9 @@ func (tl *testLogger) Debug(v ...interface{}) {} func (tl *testLogger) Info(v ...interface{}) { tl.infoMsg = fmt.Sprint(v...) } -func (tl *testLogger) Warn(v ...interface{}) {} +func (tl *testLogger) Warn(v ...interface{}) { + tl.warnMsg = fmt.Sprint(v...) +} func (tl *testLogger) Error(v ...interface{}) { tl.errMsg = fmt.Sprint(v...) } @@ -635,3 +638,152 @@ func TestApplyDisableTracingConfig(t *testing.T) { }) } } + +func Test_fsmS_applyMetricsPollRateConfig(t *testing.T) { + tests := []struct { + name string + pollRate int + expectedSecs int + expectWarn bool + }{ + { + name: "Canonical 1 second — no warning", + pollRate: 1, + expectedSecs: 1, + expectWarn: false, + }, + { + name: "Canonical 5 seconds — no warning", + pollRate: 5, + expectedSecs: 5, + expectWarn: false, + }, + { + name: "Canonical 10 seconds — no warning", + pollRate: 10, + expectedSecs: 10, + expectWarn: false, + }, + { + name: "Canonical 60 seconds — no warning", + pollRate: 60, + expectedSecs: 60, + expectWarn: false, + }, + { + name: "Canonical 600 seconds — no warning", + pollRate: 600, + expectedSecs: 600, + expectWarn: false, + }, + { + name: "Non-canonical positive value (7s) — applied as-is with warning", + pollRate: 7, + expectedSecs: 7, + expectWarn: true, + }, + { + name: "Large positive value (5000s) — applied as-is with warning", + pollRate: 5000, + expectedSecs: 5000, + expectWarn: true, + }, + { + name: "Zero — uses default (1s), no warning", + pollRate: 0, + expectedSecs: 1, + expectWarn: false, + }, + { + name: "Negative value (-5) — uses default (1s), no warning", + pollRate: -5, + expectedSecs: 1, + expectWarn: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Initialize sensor with default options + sensor = newSensor(DefaultOptions()) + defer func() { sensor = nil }() + + tLogger := &testLogger{} + fsm := &fsmS{ + logger: tLogger, + } + + resp := agentResponse{ + PluginConfig: struct { + PollRate int `json:"poll_rate"` + }{ + PollRate: tt.pollRate, + }, + } + + fsm.applyMetricsPollRateConfig(resp) + + interval := sensor.options.Metrics.getTransmissionInterval() + assert.Equal(t, time.Duration(tt.expectedSecs)*time.Second, interval) + + if tt.expectWarn { + assert.NotEmpty(t, tLogger.warnMsg, "expected a warning to be logged for non-canonical poll_rate %d", tt.pollRate) + } else { + assert.Empty(t, tLogger.warnMsg, "expected no warning for poll_rate %d", tt.pollRate) + } + }) + } +} + +// Test_fsmS_applyMetricsPollRateConfig_NoSensor verifies that applyMetricsPollRateConfig +// is a no-op (does not panic) when the global sensor has not been initialized. +func Test_fsmS_applyMetricsPollRateConfig_NoSensor(t *testing.T) { + // Ensure no global sensor is set. + origSensor := sensor + sensor = nil + defer func() { sensor = origSensor }() + + fsm := &fsmS{logger: &testLogger{}} + resp := agentResponse{} + + // Must not panic even though getSensor() will return an error. + assert.NotPanics(t, func() { + fsm.applyMetricsPollRateConfig(resp) + }) +} + +// Test_fsmS_ready_NoSensor verifies that ready() logs an error and returns early +// when the global sensor has not been initialized (getSensor returns an error). +func Test_fsmS_ready_NoSensor(t *testing.T) { + origSensor := sensor + sensor = nil + defer func() { sensor = origSensor }() + + tLogger := &testLogger{} + fsm := &fsmS{logger: tLogger} + + assert.NotPanics(t, func() { + fsm.ready(context.Background(), nil) + }) + assert.NotEmpty(t, tLogger.errMsg, "expected error to be logged when sensor is nil") +} + +// Test_fsmS_ready_IntervalZero verifies that ready() applies the default interval +// when the sensor's transmission interval has not been set (zero value). +func Test_fsmS_ready_IntervalZero(t *testing.T) { + sensor = newSensor(DefaultOptions()) + // Interval is zero by default (not set by FSM/agent yet). + assert.Equal(t, time.Duration(0), sensor.options.Metrics.getTransmissionInterval()) + defer func() { + sensor.meter.Stop() + sensor = nil + }() + + fsm := &fsmS{logger: &testLogger{}} + assert.NotPanics(t, func() { + fsm.ready(context.Background(), nil) + }) + + // After ready(), the interval must have been set to the default. + assert.Equal(t, defaultTransmissionInterval*time.Second, sensor.options.Metrics.getTransmissionInterval()) +} diff --git a/meter.go b/meter.go index ef77bca6e..5ff40ed32 100644 --- a/meter.go +++ b/meter.go @@ -5,11 +5,18 @@ package instana import ( "runtime" + "sync" + "sync/atomic" "time" "github.com/instana/go-sensor/acceptor" ) +const ( + // defaultTransmissionInterval is the fallback metrics transmission interval in seconds. + defaultTransmissionInterval = 1 +) + // SnapshotS struct to hold snapshot data type SnapshotS acceptor.RuntimeInfo @@ -23,43 +30,100 @@ type MetricsS acceptor.Metrics type EntityData acceptor.GoProcessData type meterS struct { - numGC uint32 - done chan struct{} + numGC atomic.Uint32 + once sync.Once + stopOnce sync.Once + done chan struct{} +} + +// MetricsOptions contains configuration for metrics collection and transmission. +// This configuration is managed internally and populated from agent configuration. +type MetricsOptions struct { + mu sync.RWMutex + transmissionInterval time.Duration +} + +// getTransmissionInterval returns the current metrics transmission interval. +// This value is configured through the agent's configuration.yaml file. +func (m *MetricsOptions) getTransmissionInterval() time.Duration { + m.mu.RLock() + defer m.mu.RUnlock() + + return m.transmissionInterval +} + +// setTransmissionInterval sets the metrics transmission interval. +// This is an internal method called when agent configuration is received during +// the initial handshake. The only local constraint enforced here is that the value +// must be positive (> 0); range and canonical-set validation is the responsibility +// of the Instana Agent. Non-positive values fall back to defaultTransmissionInterval. +func (m *MetricsOptions) setTransmissionInterval(seconds int) { + var interval time.Duration + + if seconds <= 0 { + defaultLogger.Error("poll_rate value from agent (", seconds, ") is not positive. Using default of ", + defaultTransmissionInterval, " second.") + interval = defaultTransmissionInterval * time.Second + } else { + interval = time.Duration(seconds) * time.Second + defaultLogger.Info("Metrics transmission interval set to ", seconds, " second(s) from agent configuration") + } + + m.mu.Lock() + defer m.mu.Unlock() + m.transmissionInterval = interval } func newMeter(logger LeveledLogger) *meterS { logger.Debug("initializing meter") return &meterS{ - done: make(chan struct{}, 1), + done: make(chan struct{}), } } +// Run starts the metrics collection loop at the given interval. +// It is safe to call Run multiple times — only the first call starts the loop; +// subsequent calls (e.g. on agent reconnect) are ignored so the running loop +// continues uninterrupted with the original interval. +// The interval is fixed at the first call; changing poll_rate in the agent +// configuration after startup requires an application restart to take effect. func (m *meterS) Run(collectInterval time.Duration) { - ticker := time.NewTicker(collectInterval) - defer ticker.Stop() - for { - select { - case <-m.done: - return - case <-ticker.C: - if isAgentReady() { - go func() { - s, err := getSensor() - if err != nil { - defaultLogger.Error("meter: ", err.Error()) - return + if m == nil { + return + } + m.once.Do(func() { + go func() { + ticker := time.NewTicker(collectInterval) + defer ticker.Stop() + for { + select { + case <-m.done: + return + case <-ticker.C: + if isAgentReady() { + go func() { + s, err := getSensor() + if err != nil { + defaultLogger.Error("meter: ", err.Error()) + return + } + + _ = s.Agent().SendMetrics(m.collectMetrics()) + }() } - - _ = s.Agent().SendMetrics(m.collectMetrics()) - }() + } } - } - } + }() + }) } +// Stop shuts down the metrics collection loop. Safe to call multiple times. func (m *meterS) Stop() { - m.done <- struct{}{} + if m == nil { + return + } + m.stopOnce.Do(func() { close(m.done) }) } func (m *meterS) collectMemoryMetrics() acceptor.MemoryStats { @@ -82,9 +146,9 @@ func (m *meterS) collectMemoryMetrics() acceptor.MemoryStats { NumGC: memStats.NumGC, GCCPUFraction: memStats.GCCPUFraction} - if m.numGC < memStats.NumGC { + if m.numGC.Load() < memStats.NumGC { ret.PauseNs = memStats.PauseNs[(memStats.NumGC+255)%256] - m.numGC = memStats.NumGC + m.numGC.Store(memStats.NumGC) } return ret diff --git a/meter_test.go b/meter_test.go index edcbdb1d0..53b0e65d1 100644 --- a/meter_test.go +++ b/meter_test.go @@ -7,126 +7,208 @@ import ( "sync" "testing" "time" + + "github.com/stretchr/testify/assert" ) -func TestMeterS_Stop(t *testing.T) { - // Create a new meter +// TestNewMeter verifies the meter is properly initialised. +func TestNewMeter(t *testing.T) { m := newMeter(defaultLogger) - // Track if Run is still executing - var wg sync.WaitGroup - wg.Add(1) + assert.NotNil(t, m) + assert.NotNil(t, m.done) + assert.Equal(t, uint32(0), m.numGC.Load()) +} - // Start the meter in a goroutine - go func() { - defer wg.Done() - m.Run(100 * time.Millisecond) - }() +// TestMeterRun_StartsOnce ensures the collection goroutine is only started once +// regardless of how many times Run is called — matching the agent-reconnect use case +// where the FSM calls Run again but the loop must continue uninterrupted. +func TestMeterRun_StartsOnce(t *testing.T) { + m := newMeter(defaultLogger) - // Let it run for a bit - time.Sleep(300 * time.Millisecond) + m.Run(50 * time.Millisecond) + m.Run(50 * time.Millisecond) + m.Run(50 * time.Millisecond) - // Stop the meter - m.Stop() + time.Sleep(80 * time.Millisecond) - // Wait for Run to exit with a timeout - done := make(chan struct{}) - go func() { - wg.Wait() - close(done) - }() + assert.NotPanics(t, m.Stop) select { - case <-done: - // Success - Run exited after Stop was called - case <-time.After(2 * time.Second): - t.Fatal("meter.Run() did not exit after Stop() was called") + case <-m.done: + // expected: channel is closed + default: + t.Fatal("done channel should be closed after Stop()") } } -func TestMeterS_Run_StopImmediately(t *testing.T) { - // Create a new meter +// TestMeterRun_LoopExitsOnStop verifies the collection goroutine stops when Stop is called. +func TestMeterRun_LoopExitsOnStop(t *testing.T) { m := newMeter(defaultLogger) + m.Run(50 * time.Millisecond) + time.Sleep(80 * time.Millisecond) - // Track if Run is still executing - var wg sync.WaitGroup - wg.Add(1) - - // Start the meter in a goroutine - go func() { - defer wg.Done() - m.Run(100 * time.Millisecond) - }() - - // Stop immediately without waiting - m.Stop() - - // Wait for Run to exit with a timeout - done := make(chan struct{}) + stopped := make(chan struct{}) go func() { - wg.Wait() - close(done) + m.Stop() + close(stopped) }() select { - case <-done: - // Success - Run exited after Stop was called + case <-stopped: + // expected case <-time.After(2 * time.Second): - t.Fatal("meter.Run() did not exit after immediate Stop() was called") + t.Fatal("Stop() did not return in time") } } -func TestMeterS_CollectMetrics(t *testing.T) { - // Create a new meter +// TestMeterRun_ConcurrentCallsSafe verifies concurrent calls to Run are race-free. +func TestMeterRun_ConcurrentCallsSafe(t *testing.T) { m := newMeter(defaultLogger) - // Collect metrics - metrics := m.collectMetrics() + var wg sync.WaitGroup + for i := 0; i < 10; i++ { + wg.Add(1) + go func() { + defer wg.Done() + m.Run(100 * time.Millisecond) + }() + } + wg.Wait() + + assert.NotPanics(t, m.Stop) +} - // Verify metrics are collected - if metrics.Goroutine <= 0 { - t.Errorf("Expected positive goroutine count, got %d", metrics.Goroutine) +// TestMeterStop covers idempotency and nil-receiver safety of Stop, and calling +// Stop before Run has been called. +func TestMeterStop(t *testing.T) { + tests := []struct { + name string + setup func() *meterS + stop func(*meterS) + }{ + { + name: "idempotent: multiple Stop calls do not panic", + setup: func() *meterS { m := newMeter(defaultLogger); m.Run(100 * time.Millisecond); return m }, + stop: func(m *meterS) { m.Stop(); m.Stop(); m.Stop() }, + }, + { + name: "safe when Run was never called", + setup: func() *meterS { return newMeter(defaultLogger) }, + stop: func(m *meterS) { m.Stop() }, + }, + { + name: "nil receiver is a no-op", + setup: func() *meterS { return nil }, + stop: func(m *meterS) { m.Stop() }, + }, } - if metrics.MemoryStats.Alloc == 0 { - t.Error("Expected non-zero memory allocation") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := tt.setup() + assert.NotPanics(t, func() { tt.stop(m) }) + }) } } -func TestMeterS_CollectMemoryMetrics(t *testing.T) { - // Create a new meter - m := newMeter(defaultLogger) +// TestMeterRun_NilReceiver verifies Run is a no-op when called on a nil *meterS. +func TestMeterRun_NilReceiver(t *testing.T) { + var m *meterS + assert.NotPanics(t, func() { m.Run(50 * time.Millisecond) }) +} - // Collect memory metrics - memStats := m.collectMemoryMetrics() +// TestMetricsOptions_GetTransmissionInterval_Default verifies that an unconfigured +// MetricsOptions returns zero (callers are responsible for applying the default). +func TestMetricsOptions_GetTransmissionInterval_Default(t *testing.T) { + opts := &MetricsOptions{} + assert.Equal(t, time.Duration(0), opts.getTransmissionInterval()) +} - // Verify memory stats are collected - if memStats.Alloc == 0 { - t.Error("Expected non-zero Alloc") +// TestMetricsOptions_SetTransmissionInterval verifies that positive values are stored +// as-is and non-positive values fall back to the default (1 second). +func TestMetricsOptions_SetTransmissionInterval(t *testing.T) { + tests := []struct { + name string + seconds int + expected time.Duration + }{ + {"minimum canonical value (1s)", 1, 1 * time.Second}, + {"canonical 5s", 5, 5 * time.Second}, + {"canonical 60s", 60, 60 * time.Second}, + {"canonical 300s", 300, 300 * time.Second}, + {"canonical 600s", 600, 600 * time.Second}, + {"non-canonical positive value stored as-is (7s)", 7, 7 * time.Second}, + {"large positive value stored as-is (1000s)", 1000, 1000 * time.Second}, + {"zero uses default (1s)", 0, 1 * time.Second}, + {"negative uses default (1s)", -1, 1 * time.Second}, } - if memStats.Sys == 0 { - t.Error("Expected non-zero Sys") + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + opts := &MetricsOptions{} + opts.setTransmissionInterval(tt.seconds) + assert.Equal(t, tt.expected, opts.getTransmissionInterval()) + }) } +} - if memStats.HeapAlloc == 0 { - t.Error("Expected non-zero HeapAlloc") - } +// TestMeterCollectMetrics verifies that metric collection returns non-zero values. +func TestMeterCollectMetrics(t *testing.T) { + m := newMeter(defaultLogger) + metrics := m.collectMetrics() + + assert.Greater(t, metrics.Goroutine, 0) + assert.NotZero(t, metrics.MemoryStats.Alloc) } -func TestMeterS_NewMeter(t *testing.T) { - // Create a new meter +// TestMeterCollectMemoryMetrics verifies that memory stats are populated. +func TestMeterCollectMemoryMetrics(t *testing.T) { m := newMeter(defaultLogger) + mem := m.collectMemoryMetrics() - if m == nil { - t.Fatal("Expected non-nil meter") - } + assert.NotZero(t, mem.Alloc) + assert.NotZero(t, mem.Sys) + assert.NotZero(t, mem.HeapAlloc) +} - if m.done == nil { - t.Error("Expected done channel to be initialized") - } +// TestMeterRun_SendMetrics_SensorNil verifies the meter loop skips SendMetrics when +// the global sensor is nil (agent not ready). The loop stays alive and stops cleanly. +func TestMeterRun_SendMetrics_SensorNil(t *testing.T) { + m := newMeter(defaultLogger) + m.Run(20 * time.Millisecond) + // Give several ticks to fire; none should panic because isAgentReady returns false. + time.Sleep(80 * time.Millisecond) + assert.NotPanics(t, m.Stop) +} - if m.numGC != 0 { - t.Errorf("Expected initial numGC to be 0, got %d", m.numGC) +// TestMeterRun_SendMetrics_AgentReady verifies that the tick handler enters the +// SendMetrics path when a ready sensor is present, without panic. +func TestMeterRun_SendMetrics_AgentReady(t *testing.T) { + // Build a sensor with a ready mock agent. + mock := &sensorS{ + options: DefaultOptions(), + meter: newMeter(defaultLogger), } + mock.setLogger(defaultLogger) + mock.setAgent(alwaysReadyClient{}) + + // Protect all writes to sensor with muSensor so isAgentReady() (which holds + // muSensor.RLock) does not race with this goroutine. + muSensor.Lock() + orig := sensor + sensor = mock + muSensor.Unlock() + + m := newMeter(defaultLogger) + m.Run(20 * time.Millisecond) + time.Sleep(80 * time.Millisecond) + + // Stop the meter BEFORE restoring sensor so the background goroutine is dead + // before we mutate the shared variable again. + assert.NotPanics(t, m.Stop) + + muSensor.Lock() + sensor = orig + muSensor.Unlock() } diff --git a/options.go b/options.go index 974e1a66e..d8da61489 100644 --- a/options.go +++ b/options.go @@ -40,6 +40,8 @@ type Options struct { MaxBufferedProfiles int // IncludeProfilerFrames is whether to include profiler calls into the profile or not IncludeProfilerFrames bool + // Metrics contains metrics collection and transmission configuration. + Metrics MetricsOptions // Tracer contains tracer-specific configuration used by all tracers Tracer TracerOptions // AgentClient client to communicate with the agent. In most cases, there is no need to provide it. diff --git a/sensor.go b/sensor.go index 5c564d361..959942315 100644 --- a/sensor.go +++ b/sensor.go @@ -115,6 +115,7 @@ func newSensor(options *Options) *sensorS { } var agent AgentClient + var isServerless bool if options.AgentClient != nil { agent = options.AgentClient @@ -122,32 +123,24 @@ func newSensor(options *Options) *sensorS { if agentEndpoint := os.Getenv("INSTANA_ENDPOINT_URL"); agentEndpoint != "" && agent == nil { s.logger.Debug("INSTANA_ENDPOINT_URL= is set, switching to the serverless mode") + isServerless = true - timeout, err := parseInstanaTimeout(os.Getenv("INSTANA_TIMEOUT")) - if err != nil { - s.logger.Warn("malformed INSTANA_TIMEOUT value, falling back to the default one: ", err) - timeout = defaultServerlessTimeout - } - - client, err := acceptor.NewHTTPClient(timeout) - if err != nil { - if err == acceptor.ErrMalformedProxyURL { - s.logger.Warn(err) - } else { - s.logger.Error("failed to initialize acceptor HTTP client, falling back to the default one: ", err) - client = http.DefaultClient - } - } - + client := s.initServerlessHTTPClient() agent = newServerlessAgent(s.serviceOrBinaryName(), agentEndpoint, os.Getenv("INSTANA_AGENT_KEY"), client, s.logger) } + s.meter = newMeter(s.logger) if agent == nil { agent = newAgent(s.serviceOrBinaryName(), s.options.AgentHost, s.options.AgentPort, s.logger) } s.setAgent(agent) - s.meter = newMeter(s.logger) + + // For serverless agents, start the meter immediately since they don't use the FSM + if isServerless { + s.options.Metrics.setTransmissionInterval(defaultTransmissionInterval) + s.meter.Run(s.options.Metrics.getTransmissionInterval()) + } return s } @@ -198,6 +191,26 @@ func (r *sensorS) Agent() AgentClient { return r.agent } +func (r *sensorS) initServerlessHTTPClient() *http.Client { + timeout, err := parseInstanaTimeout(os.Getenv("INSTANA_TIMEOUT")) + if err != nil { + r.logger.Warn("malformed INSTANA_TIMEOUT value, falling back to the default one: ", err) + timeout = defaultServerlessTimeout + } + + client, err := acceptor.NewHTTPClient(timeout) + if err != nil { + if err == acceptor.ErrMalformedProxyURL { + r.logger.Warn(err) + } else { + r.logger.Error("failed to initialize acceptor HTTP client, falling back to the default one: ", err) + client = http.DefaultClient + } + } + + return client +} + func (r *sensorS) serviceOrBinaryName() string { if r == nil { return "" @@ -231,9 +244,6 @@ func InitSensor(options *Options) { // configure auto-profiling configureAutoProfiling(options) - // start collecting metrics - go sensor.meter.Run(1 * time.Second) - sensor.logger.Debug("initialized Instana sensor v", Version) }