Files
milvus/client/milvusclient/telemetry.go
T
congqixiaandGitHub c8dd73314c enhance: [GoSDK] migrate Go client module path to v3 (#51539)
Related to #51419

Update the Go client module path, imports, dependencies, tests,
examples, and documentation from client/v2 to client/v3.

Set the SDK version to 3.0.0 in preparation for the Milvus 3.0 release.

Signed-off-by: Congqi Xia <congqi.xia@zilliz.com>
2026-07-17 22:48:41 +08:00

1891 lines
55 KiB
Go

// 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/v3/common"
"github.com/milvus-io/milvus/client/v3/internal/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),
}
}