// Licensed to the LF AI & Data foundation under one // or more contributor license agreements. See the NOTICE file // distributed with this work for additional information // regarding copyright ownership. The ASF licenses this file // to you under the Apache License, Version 2.0 (the // "License"); you may not use this file except in compliance // with the License. You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. package milvusclient import ( "context" "crypto/sha256" "encoding/hex" "encoding/json" "os" "sort" "sync" "sync/atomic" "time" "github.com/google/uuid" "github.com/milvus-io/milvus-proto/go-api/v3/commonpb" "github.com/milvus-io/milvus-proto/go-api/v3/milvuspb" "github.com/milvus-io/milvus/client/v2/common" "github.com/milvus-io/milvus/pkg/v3/util/merr" ) // TelemetryConfig holds configurable settings for client telemetry type TelemetryConfig struct { // Enabled controls whether telemetry collection is active Enabled bool // HeartbeatInterval is how often to send heartbeats to server (default: 30 seconds) // Snapshot period aligns with heartbeat interval HeartbeatInterval time.Duration // SamplingRate is the sampling rate for all operations (0.0-1.0, default: 1.0 = 100%) // Can be dynamically adjusted SamplingRate float64 // ErrorMaxCount is the maximum number of errors to keep ErrorMaxCount int } // DefaultTelemetryConfig returns the default telemetry configuration func DefaultTelemetryConfig() *TelemetryConfig { return &TelemetryConfig{ Enabled: true, HeartbeatInterval: 30 * time.Second, SamplingRate: 1.0, // 100% sampling by default ErrorMaxCount: 100, } } // Metrics holds aggregated metrics for operations (local type for internal use) type Metrics struct { RequestCount int64 SuccessCount int64 ErrorCount int64 AvgLatencyMs float64 P99LatencyMs float64 MaxLatencyMs float64 } // OperationMetrics holds metrics for a specific operation type (local type for internal use) type OperationMetrics struct { Operation string Global *Metrics CollectionMetrics map[string]*Metrics } // ClientCommand represents a command from the server (local type for command handler) type ClientCommand struct { CommandId string CommandType string Payload []byte CreateTime int64 Persistent bool TargetScope string } // CommandReply represents a reply to a server command (local type for command handler) type CommandReply struct { CommandId string Success bool ErrorMessage string Payload []byte } // ErrorInfo stores error details for client-side error tracking type ErrorInfo struct { Timestamp int64 `json:"timestamp"` // Unix timestamp in milliseconds Operation string `json:"operation"` // Operation that failed (e.g., "Search", "Insert") ErrorMsg string `json:"error_msg"` // Error message Collection string `json:"collection,omitempty"` // Collection name (optional) RequestID string `json:"request_id,omitempty"` // Request ID for tracing } // ErrorCollectorImpl implements error collection for client operations type ErrorCollectorImpl struct { mu sync.RWMutex errors []*ErrorInfo maxCount int index int // Ring buffer index } // NewErrorCollector creates a new error collector func NewErrorCollector(maxCount int) *ErrorCollectorImpl { // Ensure maxCount is at least 1 to avoid empty slice if maxCount <= 0 { maxCount = 100 // default to 100 if not specified } return &ErrorCollectorImpl{ errors: make([]*ErrorInfo, maxCount), maxCount: maxCount, } } // RecordError records an error in the circular buffer func (ec *ErrorCollectorImpl) RecordError(operation, collection, errorMsg, requestID string) { ec.mu.Lock() defer ec.mu.Unlock() ec.errors[ec.index] = &ErrorInfo{ Timestamp: time.Now().UnixMilli(), Operation: operation, ErrorMsg: errorMsg, Collection: collection, RequestID: requestID, } ec.index = (ec.index + 1) % ec.maxCount } // GetRecentErrors returns the most recent errors (up to maxCount) func (ec *ErrorCollectorImpl) GetRecentErrors(maxCount int) []*ErrorInfo { ec.mu.RLock() defer ec.mu.RUnlock() var result []*ErrorInfo // Start from most recent (index-1) and go backwards for i := 0; i < ec.maxCount && len(result) < maxCount; i++ { idx := (ec.index - 1 - i + ec.maxCount) % ec.maxCount err := ec.errors[idx] if err == nil { continue // Empty slot } result = append(result, err) } return result } // OperationMetricsCollector collects metrics for a single operation type type OperationMetricsCollector struct { mu sync.RWMutex // Global metrics requestCount int64 successCount int64 errorCount int64 totalLatency int64 // in microseconds for precision maxLatency int64 // max latency in microseconds // Latency samples for P99 calculation latencySamples []int64 sampleIndex int bufferSize int // totalSamples tracks the count of recorded samples, allowing us to distinguish // genuine 0µs latencies from uninitialized buffer slots (which are also 0) totalSamples int64 // Per-collection metrics collectionMetrics map[string]*CollectionMetrics } // CollectionMetrics holds metrics for a specific collection type CollectionMetrics struct { requestCount int64 successCount int64 errorCount int64 totalLatency int64 maxLatency int64 // max latency in microseconds latencySamples []int64 sampleIndex int bufferSize int totalSamples int64 } // NewOperationMetricsCollector creates a new metrics collector // Uses fixed latency sample buffer size of 1000 for P99 calculation func NewOperationMetricsCollector() *OperationMetricsCollector { return &OperationMetricsCollector{ latencySamples: make([]int64, 1000), bufferSize: 1000, collectionMetrics: make(map[string]*CollectionMetrics), } } // Record records a single operation result func (c *OperationMetricsCollector) Record(collection string, latencyUs int64, success bool) { c.mu.Lock() defer c.mu.Unlock() c.requestCount++ c.totalLatency += latencyUs // Track max latency if latencyUs > c.maxLatency { c.maxLatency = latencyUs } // Store latency sample for P99 c.latencySamples[c.sampleIndex] = latencyUs c.sampleIndex = (c.sampleIndex + 1) % c.bufferSize c.totalSamples++ if success { c.successCount++ } else { c.errorCount++ } // Per-collection metrics if collection != "" { cm, ok := c.collectionMetrics[collection] if !ok { cm = &CollectionMetrics{ latencySamples: make([]int64, 1000), bufferSize: 1000, } c.collectionMetrics[collection] = cm } cm.requestCount++ cm.totalLatency += latencyUs // Track max latency for collection if latencyUs > cm.maxLatency { cm.maxLatency = latencyUs } // Store latency sample for P99 cm.latencySamples[cm.sampleIndex] = latencyUs cm.sampleIndex = (cm.sampleIndex + 1) % cm.bufferSize cm.totalSamples++ if success { cm.successCount++ } else { cm.errorCount++ } } } // GetMetrics returns the current metrics and resets counters // IMPORTANT: P99 is calculated here atomically before clearing the sample buffer // This prevents the race condition where sendHeartbeat could calculate P99 from a cleared buffer func (c *OperationMetricsCollector) GetMetrics() *Metrics { c.mu.Lock() defer c.mu.Unlock() if c.requestCount == 0 { return nil } avgLatency := float64(c.totalLatency) / float64(c.requestCount) / 1000.0 // P99 is calculated HERE before totalSamples is reset to 0 // This ensures accurate P99 even if sendHeartbeat is called during snapshot creation p99Latency := c.calculateP99() / 1000.0 // convert to ms maxLatency := float64(c.maxLatency) / 1000.0 // convert to ms metrics := &Metrics{ RequestCount: c.requestCount, SuccessCount: c.successCount, ErrorCount: c.errorCount, AvgLatencyMs: avgLatency, P99LatencyMs: p99Latency, MaxLatencyMs: maxLatency, } // Reset counters for next period c.requestCount = 0 c.successCount = 0 c.errorCount = 0 c.totalLatency = 0 c.maxLatency = 0 c.sampleIndex = 0 c.totalSamples = 0 return metrics } // GetMetricsSnapshot returns current metrics WITHOUT resetting counters func (c *OperationMetricsCollector) GetMetricsSnapshot() *Metrics { c.mu.RLock() defer c.mu.RUnlock() if c.requestCount == 0 { return nil } avgLatency := float64(c.totalLatency) / float64(c.requestCount) / 1000.0 // convert to ms p99Latency := c.calculateP99() / 1000.0 // convert to ms maxLatency := float64(c.maxLatency) / 1000.0 // convert to ms return &Metrics{ RequestCount: c.requestCount, SuccessCount: c.successCount, ErrorCount: c.errorCount, AvgLatencyMs: avgLatency, P99LatencyMs: p99Latency, MaxLatencyMs: maxLatency, } } // GetCollectionMetrics returns per-collection metrics func (c *OperationMetricsCollector) GetCollectionMetrics() map[string]*Metrics { c.mu.Lock() defer c.mu.Unlock() result := make(map[string]*Metrics) for collection, cm := range c.collectionMetrics { if cm.requestCount == 0 { continue } avgLatency := float64(cm.totalLatency) / float64(cm.requestCount) / 1000.0 p99Latency := calculateP99FromSamples(cm.latencySamples, cm.totalSamples, cm.bufferSize) / 1000.0 maxLatency := float64(cm.maxLatency) / 1000.0 result[collection] = &Metrics{ RequestCount: cm.requestCount, SuccessCount: cm.successCount, ErrorCount: cm.errorCount, AvgLatencyMs: avgLatency, P99LatencyMs: p99Latency, MaxLatencyMs: maxLatency, } } // Reset collection metrics c.collectionMetrics = make(map[string]*CollectionMetrics) return result } // calculateP99FromSamples calculates P99 latency from a samples buffer func calculateP99FromSamples(samples []int64, totalSamples int64, bufferSize int) float64 { if totalSamples == 0 { return 0 } count := int(totalSamples) if count > bufferSize { count = bufferSize } sorted := make([]int64, count) if totalSamples <= int64(bufferSize) { copy(sorted, samples[:count]) } else { copy(sorted, samples) } // Sort using standard library (introsort, more efficient for larger buffers) sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) index := int(float64(len(sorted)) * 0.99) if index >= len(sorted) { index = len(sorted) - 1 } return float64(sorted[index]) } // GetP99Latency calculates P99 latency from current samples (in milliseconds) // Used primarily for testing; in production, P99 is computed in GetMetrics() during snapshot creation func (c *OperationMetricsCollector) GetP99Latency() float64 { c.mu.RLock() defer c.mu.RUnlock() return c.calculateP99() / 1000.0 // convert to ms } // calculateP99 calculates P99 latency from samples (caller must hold lock) // Uses totalSamples counter to distinguish genuine 0µs latencies from uninitialized buffer slots func (c *OperationMetricsCollector) calculateP99() float64 { if c.totalSamples == 0 { return 0 } // Determine number of valid samples // When buffer wraps (totalSamples > bufferSize), use only the most recent bufferSize samples count := int(c.totalSamples) if count > c.bufferSize { count = c.bufferSize } // Copy valid samples sorted := make([]int64, count) if c.totalSamples <= int64(c.bufferSize) { // Buffer not full, samples are 0 to count-1 copy(sorted, c.latencySamples[:count]) } else { // Buffer full (wrapped), take all copy(sorted, c.latencySamples) } // Sort using standard library (introsort, more efficient for larger buffers) sort.Slice(sorted, func(i, j int) bool { return sorted[i] < sorted[j] }) // Calculate P99 index := int(float64(len(sorted)) * 0.99) if index >= len(sorted) { index = len(sorted) - 1 } return float64(sorted[index]) } // ClientTelemetryManager manages client-side telemetry type ClientTelemetryManager struct { config *TelemetryConfig configMu sync.RWMutex // Protects config access client *Client // Unique client ID (UUID), generated once at startup, stable across reconnections clientID string // Metrics collectors per operation mu sync.RWMutex collectors map[string]*OperationMetricsCollector // Historical snapshots snapshotsMu sync.RWMutex snapshots []*MetricsSnapshot // State tracking configHash string configHashMu sync.RWMutex // Protects configHash access pendingReplies []*commonpb.CommandReply pendingRepliesMu sync.Mutex lastCommandTimestamp atomic.Int64 // Command handlers commandHandlers map[string]CommandHandler commandHandlersMu sync.RWMutex // Executed commands tracking - prevents duplicate execution of commands // Key: command ID, Value: CreateTime of the command // Commands with CreateTime < lastCommandTimestamp are filtered by timestamp comparison // This map only tracks commands at or after lastCommandTimestamp for same-millisecond deduplication executedCommands map[string]int64 executedCommandsMu sync.RWMutex // Collection-level metrics tracking enabledCollections map[string]bool allCollectionsEnabled bool // When true, all collections are enabled (wildcard "*") enabledCollectionsMu sync.RWMutex // Error collection for show_errors command errorCollector *ErrorCollectorImpl // Background goroutines stopCh chan struct{} wg sync.WaitGroup closed atomic.Bool // Startup state - indicates if telemetry manager has started ready atomic.Bool // Deterministic sampling counter samplingCounter uint64 } // CommandHandler handles a specific command type from the server type CommandHandler func(cmd *ClientCommand) *CommandReply // MetricsSnapshot represents a snapshot of metrics at a specific time type MetricsSnapshot struct { Timestamp int64 // Unix timestamp in milliseconds (start of snapshot period) EndTime int64 // Unix timestamp in milliseconds (end of snapshot period) Metrics []*OperationMetrics // Metrics for all operations } // NewClientTelemetryManager creates a new client telemetry manager func NewClientTelemetryManager(client *Client, config *TelemetryConfig) *ClientTelemetryManager { if config == nil { config = DefaultTelemetryConfig() } tm := &ClientTelemetryManager{ config: config, client: client, clientID: uuid.New().String(), collectors: make(map[string]*OperationMetricsCollector), commandHandlers: make(map[string]CommandHandler), executedCommands: make(map[string]int64), enabledCollections: make(map[string]bool), errorCollector: NewErrorCollector(config.ErrorMaxCount), stopCh: make(chan struct{}), } tm.registerDefaultHandlers() return tm } // Start starts the background heartbeat goroutine. // Sends first heartbeat immediately, then every HeartbeatInterval. func (m *ClientTelemetryManager) Start() { m.configMu.RLock() enabled := m.config.Enabled m.configMu.RUnlock() if !enabled { m.ready.Store(true) // Mark ready even if disabled return } // Mark as ready immediately - no blocking initial heartbeat m.ready.Store(true) // Start background heartbeat loop (snapshot creation is done inside heartbeatLoop) m.wg.Add(1) go m.heartbeatLoop() } // IsReady returns true if the client has completed initial setup func (m *ClientTelemetryManager) IsReady() bool { return m.ready.Load() } func (m *ClientTelemetryManager) buildClientInfo() *commonpb.ClientInfo { hostname, _ := os.Hostname() clientInfo := &commonpb.ClientInfo{ SdkType: "GoMilvusClient", SdkVersion: common.SDKVersion, LocalTime: time.Now().String(), Host: hostname, Reserved: map[string]string{ "client_id": m.clientID, // Always send stable UUID to prevent ID collisions }, } if m.client != nil { if m.client.config != nil { clientInfo.User = m.client.config.Username } if dbName := m.client.getCurrentDB(); dbName != "" { clientInfo.Reserved["db_name"] = dbName } } return clientInfo } // Stop stops the background heartbeat goroutine func (m *ClientTelemetryManager) Stop() { if m.closed.Swap(true) { return // already closed } close(m.stopCh) m.wg.Wait() } // heartbeatLoop runs the background heartbeat // Sends first heartbeat immediately on start, then every HeartbeatInterval // The loop dynamically adapts to HeartbeatInterval changes from server-pushed config func (m *ClientTelemetryManager) heartbeatLoop() { defer m.wg.Done() // Send first heartbeat immediately m.createSnapshot() m.sendHeartbeat() // Use time.After instead of ticker to dynamically adapt to interval changes // This allows server-pushed config to take effect immediately for { interval := m.getHeartbeatInterval() select { case <-m.stopCh: return case <-time.After(interval): m.createSnapshot() m.sendHeartbeat() } } } // sendHeartbeat sends a heartbeat to the server func (m *ClientTelemetryManager) sendHeartbeat() { m.configMu.RLock() enabled := m.config.Enabled m.configMu.RUnlock() if !enabled { return } if m.client == nil || m.client.service == nil { return } // Get metrics from the latest snapshot (P99 already calculated during snapshot creation) var metrics []*commonpb.OperationMetrics latestSnapshot := m.GetLatestSnapshot() if latestSnapshot != nil { enabledCollections, allEnabled := m.snapshotEnabledCollections() // Convert snapshot metrics to proto format for _, opMetrics := range latestSnapshot.Metrics { protoCollMetrics := make(map[string]*commonpb.Metrics) // Only include metrics for enabled collections // Use "*" wildcard to enable all collections for coll, cm := range opMetrics.CollectionMetrics { if allEnabled || enabledCollections[coll] { protoCollMetrics[coll] = &commonpb.Metrics{ RequestCount: cm.RequestCount, SuccessCount: cm.SuccessCount, ErrorCount: cm.ErrorCount, AvgLatencyMs: cm.AvgLatencyMs, P99LatencyMs: cm.P99LatencyMs, } } } metrics = append(metrics, &commonpb.OperationMetrics{ Operation: opMetrics.Operation, Global: &commonpb.Metrics{ RequestCount: opMetrics.Global.RequestCount, SuccessCount: opMetrics.Global.SuccessCount, ErrorCount: opMetrics.Global.ErrorCount, AvgLatencyMs: opMetrics.Global.AvgLatencyMs, P99LatencyMs: opMetrics.Global.P99LatencyMs, // Use P99 from snapshot }, CollectionMetrics: protoCollMetrics, }) } } // Get pending command replies (snapshot only) replies := m.getPendingProtoRepliesSnapshot() clientInfo := m.buildClientInfo() // Build request m.configHashMu.RLock() configHash := m.configHash m.configHashMu.RUnlock() req := &milvuspb.ClientHeartbeatRequest{ ClientInfo: clientInfo, ReportTimestamp: time.Now().UnixMilli(), Metrics: metrics, CommandReplies: replies, ConfigHash: configHash, LastCommandTimestamp: m.lastCommandTimestamp.Load(), } // Send heartbeat with 30s fixed interval (no retry - telemetry is best effort) ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) defer cancel() resp, err := m.client.telemetryService.ClientHeartbeat(ctx, req) if err != nil { // Log error but continue - telemetry is best-effort return } if !merr.Ok(resp.GetStatus()) { return } // Clear sent replies only after successful heartbeat m.clearPendingProtoReplies(len(replies)) // Process commands from server m.processProtoCommands(resp.GetCommands()) } func (m *ClientTelemetryManager) getHeartbeatInterval() time.Duration { m.configMu.RLock() interval := m.config.HeartbeatInterval m.configMu.RUnlock() if interval <= 0 { return 30 * time.Second } return interval } // snapshotEnabledCollections returns a snapshot of enabled collections config. // Returns (nil, true) when all collections are enabled via wildcard "*". // Returns (map, false) when specific collections are enabled. // Returns (nil, false) when no collections are enabled. func (m *ClientTelemetryManager) snapshotEnabledCollections() (map[string]bool, bool) { m.enabledCollectionsMu.RLock() defer m.enabledCollectionsMu.RUnlock() if m.allCollectionsEnabled { return nil, true // All collections enabled } if len(m.enabledCollections) == 0 { return nil, false // No collections enabled } snapshot := make(map[string]bool, len(m.enabledCollections)) for coll, enabled := range m.enabledCollections { snapshot[coll] = enabled } return snapshot, false } const samplingDenominator = 10000 func (m *ClientTelemetryManager) shouldSample(samplingRate float64) bool { if samplingRate >= 1.0 { return true } if samplingRate <= 0.0 { return false } threshold := uint64(samplingRate * float64(samplingDenominator)) if threshold == 0 { return false } counter := atomic.AddUint64(&m.samplingCounter, 1) return counter%samplingDenominator < threshold } // collectProtoMetrics collects all operation metrics and converts to proto format func (m *ClientTelemetryManager) collectProtoMetrics() []*commonpb.OperationMetrics { m.mu.RLock() defer m.mu.RUnlock() enabledCollections, allEnabled := m.snapshotEnabledCollections() var result []*commonpb.OperationMetrics for opName, collector := range m.collectors { globalMetrics := collector.GetMetrics() if globalMetrics == nil { continue } collMetrics := collector.GetCollectionMetrics() protoCollMetrics := make(map[string]*commonpb.Metrics) // Only include metrics for enabled collections // Use "*" wildcard to enable all collections for coll, cm := range collMetrics { if allEnabled || enabledCollections[coll] { protoCollMetrics[coll] = &commonpb.Metrics{ RequestCount: cm.RequestCount, SuccessCount: cm.SuccessCount, ErrorCount: cm.ErrorCount, AvgLatencyMs: cm.AvgLatencyMs, P99LatencyMs: cm.P99LatencyMs, } } } result = append(result, &commonpb.OperationMetrics{ Operation: opName, Global: &commonpb.Metrics{ RequestCount: globalMetrics.RequestCount, SuccessCount: globalMetrics.SuccessCount, ErrorCount: globalMetrics.ErrorCount, AvgLatencyMs: globalMetrics.AvgLatencyMs, P99LatencyMs: globalMetrics.P99LatencyMs, }, CollectionMetrics: protoCollMetrics, }) } return result } // getPendingProtoRepliesSnapshot returns a snapshot of pending replies without clearing. // Clearing is done only after a successful heartbeat to avoid losing replies on failures. func (m *ClientTelemetryManager) getPendingProtoRepliesSnapshot() []*commonpb.CommandReply { m.pendingRepliesMu.Lock() defer m.pendingRepliesMu.Unlock() if len(m.pendingReplies) == 0 { return nil } replies := make([]*commonpb.CommandReply, len(m.pendingReplies)) copy(replies, m.pendingReplies) return replies } // clearPendingProtoReplies removes the oldest sent replies after a successful heartbeat. func (m *ClientTelemetryManager) clearPendingProtoReplies(sentCount int) { if sentCount <= 0 { return } m.pendingRepliesMu.Lock() defer m.pendingRepliesMu.Unlock() if sentCount >= len(m.pendingReplies) { m.pendingReplies = nil return } m.pendingReplies = m.pendingReplies[sentCount:] } // processProtoCommands processes commands received from the server (proto format) // Commands are only executed once using timestamp-based deduplication: // - Commands with CreateTime < lastCommandTimestamp are filtered by timestamp (already processed) // - Commands with CreateTime >= lastCommandTimestamp use ID-based tracking for same-millisecond deduplication func (m *ClientTelemetryManager) processProtoCommands(commands []*commonpb.ClientCommand) { hasPersistent := false lastTS := m.lastCommandTimestamp.Load() maxCommandTS := lastTS // First, process all commands for _, cmd := range commands { localCmd := &ClientCommand{ CommandId: cmd.GetCommandId(), CommandType: cmd.GetCommandType(), Payload: cmd.GetPayload(), CreateTime: cmd.GetCreateTime(), Persistent: cmd.GetPersistent(), TargetScope: cmd.GetTargetScope(), } if localCmd.Persistent { hasPersistent = true } if cmd.GetCreateTime() > maxCommandTS { maxCommandTS = cmd.GetCreateTime() } // Timestamp-based deduplication: commands older than lastTS are already processed if cmd.GetCreateTime() < lastTS { // Already processed in a previous cycle - send ACK but skip execution m.pendingRepliesMu.Lock() m.pendingReplies = append(m.pendingReplies, &commonpb.CommandReply{ CommandId: localCmd.CommandId, Success: true, }) m.pendingRepliesMu.Unlock() continue } // For commands at or after lastTS, check map for same-millisecond duplicates m.executedCommandsMu.RLock() _, alreadyExecuted := m.executedCommands[localCmd.CommandId] m.executedCommandsMu.RUnlock() if alreadyExecuted { // Skip execution but still generate a success reply // This ensures server knows the command was received (idempotent ACK) m.pendingRepliesMu.Lock() m.pendingReplies = append(m.pendingReplies, &commonpb.CommandReply{ CommandId: localCmd.CommandId, Success: true, }) m.pendingRepliesMu.Unlock() continue } // Handle the command reply := m.handleCommand(localCmd) // Track command with its timestamp for later cleanup m.executedCommandsMu.Lock() m.executedCommands[localCmd.CommandId] = cmd.GetCreateTime() m.executedCommandsMu.Unlock() if reply != nil { m.pendingRepliesMu.Lock() m.pendingReplies = append(m.pendingReplies, &commonpb.CommandReply{ CommandId: reply.CommandId, Success: reply.Success, ErrorMessage: reply.ErrorMessage, Payload: reply.Payload, }) m.pendingRepliesMu.Unlock() } } // Clean up old entries from executedCommands map // Commands with CreateTime <= lastTS are now filtered by timestamp comparison // Using <= ensures commands with same millisecond timestamp are also cleaned up m.executedCommandsMu.Lock() for cmdID, ts := range m.executedCommands { if ts <= lastTS { delete(m.executedCommands, cmdID) } } m.executedCommandsMu.Unlock() // Update config hash AFTER all commands are processed // This ensures partial processing doesn't lead to lost configs on reconnect if hasPersistent { m.configHashMu.Lock() m.configHash = m.calculateProtoConfigHash(commands) m.configHashMu.Unlock() } m.updateLastCommandTimestamp(maxCommandTS) } // calculateProtoConfigHash calculates a hash for persistent commands (proto format) // Includes payload in hash to detect configuration changes func (m *ClientTelemetryManager) calculateProtoConfigHash(commands []*commonpb.ClientCommand) string { if len(commands) == 0 { return "" } var persistentCmds []*commonpb.ClientCommand for _, cmd := range commands { if cmd.GetPersistent() { persistentCmds = append(persistentCmds, cmd) } } if len(persistentCmds) == 0 { return "" } // Sort by command ID to ensure consistent ordering sort.Slice(persistentCmds, func(i, j int) bool { return persistentCmds[i].GetCommandId() < persistentCmds[j].GetCommandId() }) h := sha256.New() for _, cmd := range persistentCmds { // Include command ID, type, AND payload in hash h.Write([]byte(cmd.GetCommandId())) h.Write([]byte(cmd.GetCommandType())) h.Write(cmd.GetPayload()) // Include payload to detect configuration changes } return hex.EncodeToString(h.Sum(nil))[:16] } func (m *ClientTelemetryManager) updateLastCommandTimestamp(ts int64) { if ts <= 0 { return } for { current := m.lastCommandTimestamp.Load() if ts <= current { return } if m.lastCommandTimestamp.CompareAndSwap(current, ts) { return } } } // collectMetrics collects all operation metrics (local types, for testing) func (m *ClientTelemetryManager) collectMetrics() []*OperationMetrics { m.mu.RLock() defer m.mu.RUnlock() var result []*OperationMetrics for opName, collector := range m.collectors { globalMetrics := collector.GetMetrics() if globalMetrics == nil { continue } collMetrics := collector.GetCollectionMetrics() result = append(result, &OperationMetrics{ Operation: opName, Global: globalMetrics, CollectionMetrics: collMetrics, }) } return result } // getPendingReplies gets and clears pending command replies (local types, for testing) func (m *ClientTelemetryManager) getPendingReplies() []*CommandReply { m.pendingRepliesMu.Lock() defer m.pendingRepliesMu.Unlock() var result []*CommandReply for _, r := range m.pendingReplies { result = append(result, &CommandReply{ CommandId: r.GetCommandId(), Success: r.GetSuccess(), ErrorMessage: r.GetErrorMessage(), Payload: r.GetPayload(), }) } m.pendingReplies = nil return result } // processCommands processes commands (local types, for testing) // Commands are only executed once using timestamp-based deduplication: // - Commands with CreateTime < lastCommandTimestamp are filtered by timestamp (already processed) // - Commands with CreateTime >= lastCommandTimestamp use ID-based tracking for same-millisecond deduplication func (m *ClientTelemetryManager) processCommands(commands []*ClientCommand) { hasPersistent := false lastTS := m.lastCommandTimestamp.Load() maxCommandTS := lastTS // First, process all commands for _, cmd := range commands { if cmd.Persistent { hasPersistent = true } if cmd.CreateTime > maxCommandTS { maxCommandTS = cmd.CreateTime } // Timestamp-based deduplication: commands older than lastTS are already processed if cmd.CreateTime < lastTS { // Already processed in a previous cycle - skip continue } // For commands at or after lastTS, check map for same-millisecond duplicates m.executedCommandsMu.RLock() _, alreadyExecuted := m.executedCommands[cmd.CommandId] m.executedCommandsMu.RUnlock() if alreadyExecuted { // Skip execution but still generate a success reply continue } // Handle the command reply := m.handleCommand(cmd) // Track command with its timestamp for later cleanup m.executedCommandsMu.Lock() m.executedCommands[cmd.CommandId] = cmd.CreateTime m.executedCommandsMu.Unlock() if reply != nil { m.pendingRepliesMu.Lock() m.pendingReplies = append(m.pendingReplies, &commonpb.CommandReply{ CommandId: reply.CommandId, Success: reply.Success, ErrorMessage: reply.ErrorMessage, Payload: reply.Payload, }) m.pendingRepliesMu.Unlock() } } // Clean up old entries from executedCommands map // Commands with CreateTime <= lastTS are now filtered by timestamp comparison // Using <= ensures commands with same millisecond timestamp are also cleaned up m.executedCommandsMu.Lock() for cmdID, ts := range m.executedCommands { if ts <= lastTS { delete(m.executedCommands, cmdID) } } m.executedCommandsMu.Unlock() // Update config hash AFTER all commands are processed // This ensures partial processing doesn't lead to lost configs on reconnect if hasPersistent { m.configHashMu.Lock() m.configHash = m.calculateConfigHash(commands) m.configHashMu.Unlock() } m.updateLastCommandTimestamp(maxCommandTS) } // handleCommand handles a single command func (m *ClientTelemetryManager) handleCommand(cmd *ClientCommand) *CommandReply { m.commandHandlersMu.RLock() handler, ok := m.commandHandlers[cmd.CommandType] m.commandHandlersMu.RUnlock() if !ok { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "unknown command type: " + cmd.CommandType, } } return handler(cmd) } // calculateConfigHash calculates a hash for persistent commands (local types) func (m *ClientTelemetryManager) calculateConfigHash(commands []*ClientCommand) string { if len(commands) == 0 { return "" } var commandStrs []string for _, cmd := range commands { if cmd.Persistent { commandStrs = append(commandStrs, cmd.CommandId+":"+cmd.CommandType) } } if len(commandStrs) == 0 { return "" } sort.Strings(commandStrs) h := sha256.New() for _, str := range commandStrs { h.Write([]byte(str)) } return hex.EncodeToString(h.Sum(nil))[:16] } // RegisterCommandHandler registers a handler for a command type func (m *ClientTelemetryManager) RegisterCommandHandler(cmdType string, handler CommandHandler) { m.commandHandlersMu.Lock() defer m.commandHandlersMu.Unlock() m.commandHandlers[cmdType] = handler } // HandlePushConfigCommand applies a push_config command payload. func (m *ClientTelemetryManager) HandlePushConfigCommand(cmd *ClientCommand) *CommandReply { return m.handlePushConfig(cmd) } // HandleCollectionMetricsCommand handles collection-level metrics configuration. func (m *ClientTelemetryManager) HandleCollectionMetricsCommand(cmd *ClientCommand) *CommandReply { return m.handleCollectionMetrics(cmd) } // HandleShowErrorsCommand handles the show_errors command to return last N errors. func (m *ClientTelemetryManager) HandleShowErrorsCommand(cmd *ClientCommand) *CommandReply { return m.handleShowErrors(cmd) } // RecordOperation records an operation for telemetry func (m *ClientTelemetryManager) RecordOperation(operation, collection string, startTime time.Time, err error) { m.configMu.RLock() enabled := m.config.Enabled samplingRate := m.config.SamplingRate m.configMu.RUnlock() if !enabled { return } if !m.shouldSample(samplingRate) { return } latencyUs := time.Since(startTime).Microseconds() success := err == nil m.mu.RLock() collector, ok := m.collectors[operation] m.mu.RUnlock() if !ok { m.mu.Lock() collector, ok = m.collectors[operation] if !ok { collector = NewOperationMetricsCollector() m.collectors[operation] = collector } m.mu.Unlock() } // Check if collection-level metrics are enabled for this collection // By default, collection metrics are DISABLED. They must be explicitly enabled // via the collection_metrics command. Use "*" wildcard to enable all collections. collectionToRecord := "" if collection != "" { m.enabledCollectionsMu.RLock() enabled := m.allCollectionsEnabled || m.enabledCollections[collection] m.enabledCollectionsMu.RUnlock() if enabled { collectionToRecord = collection } } collector.Record(collectionToRecord, latencyUs, success) // Record error for last 100 errors tracking if err != nil && m.errorCollector != nil { m.errorCollector.RecordError(operation, collection, err.Error(), "") } } // RecordOperationWithRequestID records an operation with optional request ID for error tracing func (m *ClientTelemetryManager) RecordOperationWithRequestID(operation, collection, requestID string, startTime time.Time, err error) { m.configMu.RLock() enabled := m.config.Enabled samplingRate := m.config.SamplingRate m.configMu.RUnlock() if !enabled { return } if !m.shouldSample(samplingRate) { return } latencyUs := time.Since(startTime).Microseconds() success := err == nil m.mu.RLock() collector, ok := m.collectors[operation] m.mu.RUnlock() if !ok { m.mu.Lock() collector, ok = m.collectors[operation] if !ok { collector = NewOperationMetricsCollector() m.collectors[operation] = collector } m.mu.Unlock() } // Check if collection-level metrics are enabled for this collection // By default, collection metrics are DISABLED. They must be explicitly enabled // via the collection_metrics command. Use "*" wildcard to enable all collections. collectionToRecord := "" if collection != "" { m.enabledCollectionsMu.RLock() enabled := m.allCollectionsEnabled || m.enabledCollections[collection] m.enabledCollectionsMu.RUnlock() if enabled { collectionToRecord = collection } } collector.Record(collectionToRecord, latencyUs, success) // Record error for last 100 errors tracking if err != nil && m.errorCollector != nil { m.errorCollector.RecordError(operation, collection, err.Error(), requestID) } } // GetClientID returns the unique client ID (UUID) func (m *ClientTelemetryManager) GetClientID() string { return m.clientID } // GetConfigHash returns the current config hash func (m *ClientTelemetryManager) GetConfigHash() string { m.configHashMu.RLock() defer m.configHashMu.RUnlock() return m.configHash } // SetConfigHash sets the config hash (for testing) func (m *ClientTelemetryManager) SetConfigHash(hash string) { m.configHashMu.Lock() defer m.configHashMu.Unlock() m.configHash = hash } // createSnapshot creates a new metrics snapshot and adds it to the history // P99 is calculated atomically in collectMetrics() -> GetMetrics() -> calculateP99() // This eliminates the race condition where sendHeartbeat could read from a cleared sample buffer // Note: QPS calculation uses fixed heartbeat interval as window (configured via HeartbeatInterval) func (m *ClientTelemetryManager) createSnapshot() { m.configMu.RLock() enabled := m.config.Enabled heartbeatInterval := m.config.HeartbeatInterval m.configMu.RUnlock() if !enabled { return } // Collect current metrics (and reset counters) // P99 is calculated here, before samples are cleared metrics := m.collectMetrics() now := time.Now().UnixMilli() snapshot := &MetricsSnapshot{ Timestamp: now - heartbeatInterval.Milliseconds(), // Start of the snapshot period EndTime: now, // End of the snapshot period Metrics: metrics, } // Add to snapshot list (keep only the most recent 120 = 1 hour at 30s intervals) m.snapshotsMu.Lock() m.snapshots = append(m.snapshots, snapshot) if len(m.snapshots) > 120 { m.snapshots = m.snapshots[len(m.snapshots)-120:] } m.snapshotsMu.Unlock() } // GetMetricsSnapshots returns all historical snapshots func (m *ClientTelemetryManager) GetMetricsSnapshots() []*MetricsSnapshot { m.snapshotsMu.RLock() defer m.snapshotsMu.RUnlock() // Return a copy to avoid external modification result := make([]*MetricsSnapshot, len(m.snapshots)) copy(result, m.snapshots) return result } // GetLatestSnapshot returns the most recent snapshot, or nil if none exists func (m *ClientTelemetryManager) GetLatestSnapshot() *MetricsSnapshot { m.snapshotsMu.RLock() defer m.snapshotsMu.RUnlock() if len(m.snapshots) == 0 { return nil } return m.snapshots[len(m.snapshots)-1] } // registerDefaultHandlers registers default command handlers func (m *ClientTelemetryManager) registerDefaultHandlers() { // Config handler - dynamically modify client telemetry configuration m.RegisterCommandHandler("push_config", m.handlePushConfig) // Collection metrics handler - enable/disable fine-grained collection metrics m.RegisterCommandHandler("collection_metrics", m.handleCollectionMetrics) // Show errors handler - return last N errors m.RegisterCommandHandler("show_errors", m.handleShowErrors) // Show latency history handler - return historical latency data m.RegisterCommandHandler("show_latency_history", m.handleShowLatencyHistory) // Get config handler - return client configuration m.RegisterCommandHandler("get_config", m.handleGetConfig) } // ConfigPayload represents the payload for push_config command type ConfigPayload struct { Enabled *bool `json:"enabled,omitempty"` HeartbeatInterval *int64 `json:"heartbeat_interval_ms,omitempty"` SamplingRate *float64 `json:"sampling_rate,omitempty"` TTLSeconds int64 `json:"ttl_seconds,omitempty"` } // CollectionMetricsPayload represents the payload for collection_metrics command type CollectionMetricsPayload struct { Enabled bool `json:"enabled"` Collections []string `json:"collections,omitempty"` MetricsTypes []string `json:"metrics_types,omitempty"` // e.g., "qps", "latency" } // handlePushConfig handles dynamic configuration updates func (m *ClientTelemetryManager) handlePushConfig(cmd *ClientCommand) *CommandReply { var payload ConfigPayload if len(cmd.Payload) > 0 { if err := json.Unmarshal(cmd.Payload, &payload); err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "failed to parse config payload: " + err.Error(), } } } // Apply configuration changes with write lock m.configMu.Lock() if payload.Enabled != nil { m.config.Enabled = *payload.Enabled } if payload.HeartbeatInterval != nil { if *payload.HeartbeatInterval <= 0 { m.configMu.Unlock() return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "heartbeat_interval_ms must be positive", } } m.config.HeartbeatInterval = time.Duration(*payload.HeartbeatInterval) * time.Millisecond } if payload.SamplingRate != nil { samplingRate := *payload.SamplingRate if samplingRate < 0.0 { samplingRate = 0.0 } else if samplingRate > 1.0 { samplingRate = 1.0 } m.config.SamplingRate = samplingRate } m.configMu.Unlock() return &CommandReply{ CommandId: cmd.CommandId, Success: true, } } // GetConfigResponse represents the response for get_config command type GetConfigResponse struct { // UserConfig contains the client configuration provided by the user (secrets excluded) UserConfig map[string]interface{} `json:"user_config"` } // handleGetConfig handles the get_config command to return client configuration func (m *ClientTelemetryManager) handleGetConfig(cmd *ClientCommand) *CommandReply { response := &GetConfigResponse{ UserConfig: make(map[string]interface{}), } // Extract user config from client (exclude secrets) if m.client != nil && m.client.config != nil { cfg := m.client.config response.UserConfig["address"] = cfg.Address response.UserConfig["username"] = cfg.Username // Password and APIKey are excluded for security response.UserConfig["db_name"] = cfg.DBName response.UserConfig["enable_tls_auth"] = cfg.EnableTLSAuth response.UserConfig["server_version"] = cfg.ServerVersion // RetryRateLimit settings if cfg.RetryRateLimit != nil { response.UserConfig["retry_max_retry"] = cfg.RetryRateLimit.MaxRetry response.UserConfig["retry_max_backoff_ms"] = cfg.RetryRateLimit.MaxBackoff.Milliseconds() } // Current database (may differ from initial DBName if UseDatabase was called) response.UserConfig["current_db"] = m.client.getCurrentDB() } // Add current telemetry config m.configMu.RLock() response.UserConfig["telemetry_enabled"] = m.config.Enabled response.UserConfig["telemetry_heartbeat_interval_ms"] = m.config.HeartbeatInterval.Milliseconds() response.UserConfig["telemetry_sampling_rate"] = m.config.SamplingRate m.configMu.RUnlock() // Add enabled collections info (always use []string for consistent JSON parsing) m.enabledCollectionsMu.RLock() if m.allCollectionsEnabled { response.UserConfig["enabled_collections"] = []string{"*"} } else { collections := make([]string, 0, len(m.enabledCollections)) for coll := range m.enabledCollections { collections = append(collections, coll) } response.UserConfig["enabled_collections"] = collections } response.UserConfig["all_collections_enabled"] = m.allCollectionsEnabled m.enabledCollectionsMu.RUnlock() // Marshal response responseJSON, err := json.Marshal(response) if err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "failed to marshal config response: " + err.Error(), } } return &CommandReply{ CommandId: cmd.CommandId, Success: true, Payload: responseJSON, } } // handleCollectionMetrics handles collection-level metrics configuration func (m *ClientTelemetryManager) handleCollectionMetrics(cmd *ClientCommand) *CommandReply { // Empty payload means "list enabled collections" - return current state if len(cmd.Payload) == 0 { m.enabledCollectionsMu.RLock() allEnabled := m.allCollectionsEnabled collections := make([]string, 0, len(m.enabledCollections)) for coll := range m.enabledCollections { collections = append(collections, coll) } m.enabledCollectionsMu.RUnlock() response := map[string]interface{}{ "enabled_collections": collections, "all_collections_enabled": allEnabled, } responsePayload, _ := json.Marshal(response) return &CommandReply{ CommandId: cmd.CommandId, Success: true, Payload: responsePayload, } } var payload CollectionMetricsPayload if err := json.Unmarshal(cmd.Payload, &payload); err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "failed to parse collection_metrics payload: " + err.Error(), } } m.enabledCollectionsMu.Lock() defer m.enabledCollectionsMu.Unlock() // Check for wildcard "*" in collections hasWildcard := false for _, coll := range payload.Collections { if coll == "*" { hasWildcard = true break } } if payload.Enabled { // Enable collection-level metrics if len(payload.Collections) == 0 { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "collections list cannot be empty when enabled=true", } } if hasWildcard { // Enable all collections via wildcard m.allCollectionsEnabled = true } else { // Enable specific collections for _, coll := range payload.Collections { m.enabledCollections[coll] = true } } } else { // Disable collection-level metrics if hasWildcard || len(payload.Collections) == 0 { // Disable all collections m.allCollectionsEnabled = false m.enabledCollections = make(map[string]bool) } else { // Disable specific collections for _, coll := range payload.Collections { delete(m.enabledCollections, coll) } } } return &CommandReply{ CommandId: cmd.CommandId, Success: true, } } // GetRecentErrors returns the most recent N errors // This method allows external access to the error history for debugging func (m *ClientTelemetryManager) GetRecentErrors(maxCount int) []*ErrorInfo { if m.errorCollector == nil { return nil } return m.errorCollector.GetRecentErrors(maxCount) } // maxErrorPayloadSize is the maximum size of error payload (1MB) const maxErrorPayloadSize = 1 * 1024 * 1024 // handleShowErrors handles the show_errors command to return last N errors func (m *ClientTelemetryManager) handleShowErrors(cmd *ClientCommand) *CommandReply { reply := &CommandReply{ CommandId: cmd.CommandId, Success: true, } // Parse payload to get max count var payload struct { MaxCount int `json:"max_count,omitempty"` } maxCount := 100 // default if len(cmd.Payload) > 0 { if err := json.Unmarshal(cmd.Payload, &payload); err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "failed to parse show_errors payload: " + err.Error(), } } if payload.MaxCount > 0 { maxCount = payload.MaxCount } } // Get recent errors from error collector var errors []*ErrorInfo if m.errorCollector != nil { errors = m.errorCollector.GetRecentErrors(maxCount) } // Convert errors to JSON and include in reply payload // Truncate if payload exceeds 1MB if len(errors) > 0 { errorsJSON, err := json.Marshal(errors) if err == nil { // If payload exceeds 1MB, truncate errors until it fits for len(errorsJSON) > maxErrorPayloadSize && len(errors) > 1 { // Remove oldest errors (at the end of the list) to reduce size errors = errors[:len(errors)/2] // Binary reduction for efficiency errorsJSON, err = json.Marshal(errors) if err != nil { break } } // Final check - if still too large with only 1 error, truncate the error message if len(errorsJSON) > maxErrorPayloadSize && len(errors) == 1 { // Truncate the error message itself maxMsgLen := maxErrorPayloadSize - 200 // Leave room for JSON structure if len(errors[0].ErrorMsg) > maxMsgLen { errors[0].ErrorMsg = errors[0].ErrorMsg[:maxMsgLen] + "...(truncated)" errorsJSON, _ = json.Marshal(errors) } } reply.Payload = errorsJSON } } return reply } // LatencyHistoryPayload represents the payload for show_latency_history command type LatencyHistoryPayload struct { StartTime string `json:"start_time"` // RFC3339 format EndTime string `json:"end_time"` // RFC3339 format Detail bool `json:"detail"` // When true, return all snapshots instead of aggregated metrics (default: false) } // LatencyHistoryResponse represents the response for show_latency_history command type LatencyHistoryResponse struct { Snapshots []*SnapshotResponse `json:"snapshots"` TotalSnapshots int `json:"total_snapshots"` } // SnapshotResponse represents a single snapshot in the latency history response type SnapshotResponse struct { Timestamp int64 `json:"timestamp"` // Unix timestamp in milliseconds (start) EndTime int64 `json:"end_time"` // Unix timestamp in milliseconds (end) Metrics map[string]*MetricsResponse `json:"metrics"` // Operation name -> metrics } // MetricsResponse represents metrics for a single operation type in the response type MetricsResponse struct { RequestCount int64 `json:"request_count"` SuccessCount int64 `json:"success_count,omitempty"` ErrorCount int64 `json:"error_count,omitempty"` AvgLatencyMs float64 `json:"avg_latency_ms"` P99LatencyMs float64 `json:"p99_latency_ms"` MaxLatencyMs float64 `json:"max_latency_ms"` } // AggregatedMetrics represents aggregated metrics across multiple snapshots type AggregatedMetrics struct { StartTime int64 `json:"start_time"` // Unix timestamp in milliseconds EndTime int64 `json:"end_time"` // Unix timestamp in milliseconds Metrics map[string]*MetricsResponse `json:"metrics"` // Operation name -> aggregated metrics } // AggregatedLatencyHistoryResponse represents the aggregated response for show_latency_history type AggregatedLatencyHistoryResponse struct { Aggregated *AggregatedMetrics `json:"aggregated"` SnapshotCount int `json:"snapshot_count"` // Number of snapshots aggregated } // GetHistoricalLatency returns snapshots within the specified time range func (m *ClientTelemetryManager) GetHistoricalLatency(startTime, endTime time.Time) []*MetricsSnapshot { m.snapshotsMu.RLock() defer m.snapshotsMu.RUnlock() startMs := startTime.UnixMilli() endMs := endTime.UnixMilli() var result []*MetricsSnapshot for _, snapshot := range m.snapshots { // Include snapshot if its period overlaps with the query range if snapshot.EndTime >= startMs && snapshot.Timestamp <= endMs { result = append(result, snapshot) } } return result } // handleShowLatencyHistory handles the show_latency_history command func (m *ClientTelemetryManager) handleShowLatencyHistory(cmd *ClientCommand) *CommandReply { var payload LatencyHistoryPayload if len(cmd.Payload) == 0 { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "payload is required with start_time and end_time", } } if err := json.Unmarshal(cmd.Payload, &payload); err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "failed to parse show_latency_history payload: " + err.Error(), } } // Parse times startTime, err := time.Parse(time.RFC3339, payload.StartTime) if err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "invalid start_time format, expected RFC3339: " + err.Error(), } } endTime, err := time.Parse(time.RFC3339, payload.EndTime) if err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "invalid end_time format, expected RFC3339: " + err.Error(), } } // Validate time range (max 1 hour) if endTime.Sub(startTime) > time.Hour { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "time range cannot exceed 1 hour", } } if endTime.Before(startTime) { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "end_time must be after start_time", } } // Get historical snapshots snapshots := m.GetHistoricalLatency(startTime, endTime) var responseJSON []byte if payload.Detail { // Return all snapshots (when detail=true) response := &LatencyHistoryResponse{ Snapshots: make([]*SnapshotResponse, 0, len(snapshots)), TotalSnapshots: len(snapshots), } for _, snapshot := range snapshots { snapshotResp := &SnapshotResponse{ Timestamp: snapshot.Timestamp, EndTime: snapshot.EndTime, Metrics: make(map[string]*MetricsResponse), } for _, opMetrics := range snapshot.Metrics { if opMetrics.Global != nil { snapshotResp.Metrics[opMetrics.Operation] = &MetricsResponse{ RequestCount: opMetrics.Global.RequestCount, SuccessCount: opMetrics.Global.SuccessCount, ErrorCount: opMetrics.Global.ErrorCount, AvgLatencyMs: opMetrics.Global.AvgLatencyMs, P99LatencyMs: opMetrics.Global.P99LatencyMs, MaxLatencyMs: opMetrics.Global.MaxLatencyMs, } } } response.Snapshots = append(response.Snapshots, snapshotResp) } responseJSON, err = json.Marshal(response) } else { // Return aggregated metrics (default) response := m.aggregateSnapshots(snapshots, startTime.UnixMilli(), endTime.UnixMilli()) responseJSON, err = json.Marshal(response) } if err != nil { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "failed to marshal response: " + err.Error(), } } // Check payload size (max 1MB) if len(responseJSON) > maxErrorPayloadSize { return &CommandReply{ CommandId: cmd.CommandId, Success: false, ErrorMessage: "response too large, try a smaller time range", } } return &CommandReply{ CommandId: cmd.CommandId, Success: true, Payload: responseJSON, } } // aggregateSnapshots aggregates multiple snapshots into a single response // Uses weighted average for latencies (weighted by request count) func (m *ClientTelemetryManager) aggregateSnapshots(snapshots []*MetricsSnapshot, startTime, endTime int64) *AggregatedLatencyHistoryResponse { if len(snapshots) == 0 { return &AggregatedLatencyHistoryResponse{ Aggregated: &AggregatedMetrics{ StartTime: startTime, EndTime: endTime, Metrics: make(map[string]*MetricsResponse), }, SnapshotCount: 0, } } // Aggregate metrics by operation type aggregator struct { requestCount int64 successCount int64 errorCount int64 weightedAvgSum float64 // sum of (avg_latency * request_count) weightedP99Sum float64 // sum of (p99_latency * request_count) maxLatency float64 } aggregators := make(map[string]*aggregator) for _, snapshot := range snapshots { for _, opMetrics := range snapshot.Metrics { if opMetrics.Global == nil { continue } agg, ok := aggregators[opMetrics.Operation] if !ok { agg = &aggregator{} aggregators[opMetrics.Operation] = agg } agg.requestCount += opMetrics.Global.RequestCount agg.successCount += opMetrics.Global.SuccessCount agg.errorCount += opMetrics.Global.ErrorCount agg.weightedAvgSum += opMetrics.Global.AvgLatencyMs * float64(opMetrics.Global.RequestCount) agg.weightedP99Sum += opMetrics.Global.P99LatencyMs * float64(opMetrics.Global.RequestCount) if opMetrics.Global.MaxLatencyMs > agg.maxLatency { agg.maxLatency = opMetrics.Global.MaxLatencyMs } } } // Convert to response format metrics := make(map[string]*MetricsResponse) for op, agg := range aggregators { avgLatency := 0.0 p99Latency := 0.0 if agg.requestCount > 0 { avgLatency = agg.weightedAvgSum / float64(agg.requestCount) p99Latency = agg.weightedP99Sum / float64(agg.requestCount) } metrics[op] = &MetricsResponse{ RequestCount: agg.requestCount, SuccessCount: agg.successCount, ErrorCount: agg.errorCount, AvgLatencyMs: avgLatency, P99LatencyMs: p99Latency, MaxLatencyMs: agg.maxLatency, } } return &AggregatedLatencyHistoryResponse{ Aggregated: &AggregatedMetrics{ StartTime: startTime, EndTime: endTime, Metrics: metrics, }, SnapshotCount: len(snapshots), } }