mirror of https://github.com/jetkvm/kvm.git
feat(audio): add latency histogram metrics collection and visualization
- Add LatencyHistogramData interface and implement histogram collection in granular metrics - Create LatencyHistogram component for visualizing latency distribution - Update audio metrics events to include histogram data - Add comprehensive tests for histogram functionality - Improve error handling for OPUS encoder parameter updates - Optimize validation cache initialization
This commit is contained in:
parent
3efe2f2a1d
commit
758bbbfff6
|
@ -206,7 +206,9 @@ func SetAudioQuality(quality AudioQuality) {
|
||||||
// Dynamically update CGO OPUS encoder parameters
|
// Dynamically update CGO OPUS encoder parameters
|
||||||
// Use current VBR constraint setting from config
|
// Use current VBR constraint setting from config
|
||||||
vbrConstraint := GetConfig().CGOOpusVBRConstraint
|
vbrConstraint := GetConfig().CGOOpusVBRConstraint
|
||||||
updateOpusEncoderParams(config.Bitrate*1000, complexity, vbr, vbrConstraint, signalType, bandwidth, dtx)
|
if err := updateOpusEncoderParams(config.Bitrate*1000, complexity, vbr, vbrConstraint, signalType, bandwidth, dtx); err != nil {
|
||||||
|
logging.GetDefaultLogger().Error().Err(err).Msg("Failed to update OPUS encoder parameters")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -781,11 +781,11 @@ func updateOpusEncoderParams(bitrate, complexity, vbr, vbrConstraint, signalType
|
||||||
|
|
||||||
// CGO function aliases
|
// CGO function aliases
|
||||||
var (
|
var (
|
||||||
CGOAudioInit = cgoAudioInit
|
CGOAudioInit = cgoAudioInit
|
||||||
CGOAudioClose = cgoAudioClose
|
CGOAudioClose = cgoAudioClose
|
||||||
CGOAudioReadEncode = cgoAudioReadEncode
|
CGOAudioReadEncode = cgoAudioReadEncode
|
||||||
CGOAudioPlaybackInit = cgoAudioPlaybackInit
|
CGOAudioPlaybackInit = cgoAudioPlaybackInit
|
||||||
CGOAudioPlaybackClose = cgoAudioPlaybackClose
|
CGOAudioPlaybackClose = cgoAudioPlaybackClose
|
||||||
CGOAudioDecodeWrite = cgoAudioDecodeWrite
|
CGOAudioDecodeWrite = cgoAudioDecodeWrite
|
||||||
CGOUpdateOpusEncoderParams = updateOpusEncoderParams
|
CGOUpdateOpusEncoderParams = updateOpusEncoderParams
|
||||||
)
|
)
|
||||||
|
|
|
@ -54,11 +54,11 @@ type AudioConfigConstants struct {
|
||||||
AudioQualityUltraChannels int // Ultra-quality channel count (default: 2)
|
AudioQualityUltraChannels int // Ultra-quality channel count (default: 2)
|
||||||
|
|
||||||
// Audio Quality OPUS Encoder Parameters
|
// Audio Quality OPUS Encoder Parameters
|
||||||
AudioQualityLowOpusComplexity int // Low-quality OPUS complexity (default: 1)
|
AudioQualityLowOpusComplexity int // Low-quality OPUS complexity (default: 1)
|
||||||
AudioQualityLowOpusVBR int // Low-quality OPUS VBR setting (default: 0)
|
AudioQualityLowOpusVBR int // Low-quality OPUS VBR setting (default: 0)
|
||||||
AudioQualityLowOpusSignalType int // Low-quality OPUS signal type (default: 3001)
|
AudioQualityLowOpusSignalType int // Low-quality OPUS signal type (default: 3001)
|
||||||
AudioQualityLowOpusBandwidth int // Low-quality OPUS bandwidth (default: 1101)
|
AudioQualityLowOpusBandwidth int // Low-quality OPUS bandwidth (default: 1101)
|
||||||
AudioQualityLowOpusDTX int // Low-quality OPUS DTX setting (default: 1)
|
AudioQualityLowOpusDTX int // Low-quality OPUS DTX setting (default: 1)
|
||||||
|
|
||||||
AudioQualityMediumOpusComplexity int // Medium-quality OPUS complexity (default: 5)
|
AudioQualityMediumOpusComplexity int // Medium-quality OPUS complexity (default: 5)
|
||||||
AudioQualityMediumOpusVBR int // Medium-quality OPUS VBR setting (default: 1)
|
AudioQualityMediumOpusVBR int // Medium-quality OPUS VBR setting (default: 1)
|
||||||
|
@ -66,17 +66,17 @@ type AudioConfigConstants struct {
|
||||||
AudioQualityMediumOpusBandwidth int // Medium-quality OPUS bandwidth (default: 1103)
|
AudioQualityMediumOpusBandwidth int // Medium-quality OPUS bandwidth (default: 1103)
|
||||||
AudioQualityMediumOpusDTX int // Medium-quality OPUS DTX setting (default: 0)
|
AudioQualityMediumOpusDTX int // Medium-quality OPUS DTX setting (default: 0)
|
||||||
|
|
||||||
AudioQualityHighOpusComplexity int // High-quality OPUS complexity (default: 8)
|
AudioQualityHighOpusComplexity int // High-quality OPUS complexity (default: 8)
|
||||||
AudioQualityHighOpusVBR int // High-quality OPUS VBR setting (default: 1)
|
AudioQualityHighOpusVBR int // High-quality OPUS VBR setting (default: 1)
|
||||||
AudioQualityHighOpusSignalType int // High-quality OPUS signal type (default: 3002)
|
AudioQualityHighOpusSignalType int // High-quality OPUS signal type (default: 3002)
|
||||||
AudioQualityHighOpusBandwidth int // High-quality OPUS bandwidth (default: 1104)
|
AudioQualityHighOpusBandwidth int // High-quality OPUS bandwidth (default: 1104)
|
||||||
AudioQualityHighOpusDTX int // High-quality OPUS DTX setting (default: 0)
|
AudioQualityHighOpusDTX int // High-quality OPUS DTX setting (default: 0)
|
||||||
|
|
||||||
AudioQualityUltraOpusComplexity int // Ultra-quality OPUS complexity (default: 10)
|
AudioQualityUltraOpusComplexity int // Ultra-quality OPUS complexity (default: 10)
|
||||||
AudioQualityUltraOpusVBR int // Ultra-quality OPUS VBR setting (default: 1)
|
AudioQualityUltraOpusVBR int // Ultra-quality OPUS VBR setting (default: 1)
|
||||||
AudioQualityUltraOpusSignalType int // Ultra-quality OPUS signal type (default: 3002)
|
AudioQualityUltraOpusSignalType int // Ultra-quality OPUS signal type (default: 3002)
|
||||||
AudioQualityUltraOpusBandwidth int // Ultra-quality OPUS bandwidth (default: 1105)
|
AudioQualityUltraOpusBandwidth int // Ultra-quality OPUS bandwidth (default: 1105)
|
||||||
AudioQualityUltraOpusDTX int // Ultra-quality OPUS DTX setting (default: 0)
|
AudioQualityUltraOpusDTX int // Ultra-quality OPUS DTX setting (default: 0)
|
||||||
|
|
||||||
// CGO Audio Constants
|
// CGO Audio Constants
|
||||||
CGOOpusBitrate int // Native Opus encoder bitrate in bps (default: 96000)
|
CGOOpusBitrate int // Native Opus encoder bitrate in bps (default: 96000)
|
||||||
|
@ -1646,32 +1646,32 @@ func DefaultAudioConfig() *AudioConfigConstants {
|
||||||
// Impact: Controls encoding complexity, VBR, signal type, bandwidth, and DTX
|
// Impact: Controls encoding complexity, VBR, signal type, bandwidth, and DTX
|
||||||
|
|
||||||
// Low Quality OPUS Parameters - Optimized for bandwidth conservation
|
// Low Quality OPUS Parameters - Optimized for bandwidth conservation
|
||||||
AudioQualityLowOpusComplexity: 1, // Low complexity for minimal CPU usage
|
AudioQualityLowOpusComplexity: 1, // Low complexity for minimal CPU usage
|
||||||
AudioQualityLowOpusVBR: 0, // CBR for predictable bandwidth
|
AudioQualityLowOpusVBR: 0, // CBR for predictable bandwidth
|
||||||
AudioQualityLowOpusSignalType: 3001, // OPUS_SIGNAL_VOICE
|
AudioQualityLowOpusSignalType: 3001, // OPUS_SIGNAL_VOICE
|
||||||
AudioQualityLowOpusBandwidth: 1101, // OPUS_BANDWIDTH_NARROWBAND
|
AudioQualityLowOpusBandwidth: 1101, // OPUS_BANDWIDTH_NARROWBAND
|
||||||
AudioQualityLowOpusDTX: 1, // Enable DTX for silence suppression
|
AudioQualityLowOpusDTX: 1, // Enable DTX for silence suppression
|
||||||
|
|
||||||
// Medium Quality OPUS Parameters - Balanced performance and quality
|
// Medium Quality OPUS Parameters - Balanced performance and quality
|
||||||
AudioQualityMediumOpusComplexity: 5, // Medium complexity for balanced performance
|
AudioQualityMediumOpusComplexity: 5, // Medium complexity for balanced performance
|
||||||
AudioQualityMediumOpusVBR: 1, // VBR for better quality
|
AudioQualityMediumOpusVBR: 1, // VBR for better quality
|
||||||
AudioQualityMediumOpusSignalType: 3002, // OPUS_SIGNAL_MUSIC
|
AudioQualityMediumOpusSignalType: 3002, // OPUS_SIGNAL_MUSIC
|
||||||
AudioQualityMediumOpusBandwidth: 1103, // OPUS_BANDWIDTH_WIDEBAND
|
AudioQualityMediumOpusBandwidth: 1103, // OPUS_BANDWIDTH_WIDEBAND
|
||||||
AudioQualityMediumOpusDTX: 0, // Disable DTX for consistent quality
|
AudioQualityMediumOpusDTX: 0, // Disable DTX for consistent quality
|
||||||
|
|
||||||
// High Quality OPUS Parameters - High quality with good performance
|
// High Quality OPUS Parameters - High quality with good performance
|
||||||
AudioQualityHighOpusComplexity: 8, // High complexity for better quality
|
AudioQualityHighOpusComplexity: 8, // High complexity for better quality
|
||||||
AudioQualityHighOpusVBR: 1, // VBR for optimal quality
|
AudioQualityHighOpusVBR: 1, // VBR for optimal quality
|
||||||
AudioQualityHighOpusSignalType: 3002, // OPUS_SIGNAL_MUSIC
|
AudioQualityHighOpusSignalType: 3002, // OPUS_SIGNAL_MUSIC
|
||||||
AudioQualityHighOpusBandwidth: 1104, // OPUS_BANDWIDTH_SUPERWIDEBAND
|
AudioQualityHighOpusBandwidth: 1104, // OPUS_BANDWIDTH_SUPERWIDEBAND
|
||||||
AudioQualityHighOpusDTX: 0, // Disable DTX for consistent quality
|
AudioQualityHighOpusDTX: 0, // Disable DTX for consistent quality
|
||||||
|
|
||||||
// Ultra Quality OPUS Parameters - Maximum quality settings
|
// Ultra Quality OPUS Parameters - Maximum quality settings
|
||||||
AudioQualityUltraOpusComplexity: 10, // Maximum complexity for best quality
|
AudioQualityUltraOpusComplexity: 10, // Maximum complexity for best quality
|
||||||
AudioQualityUltraOpusVBR: 1, // VBR for optimal quality
|
AudioQualityUltraOpusVBR: 1, // VBR for optimal quality
|
||||||
AudioQualityUltraOpusSignalType: 3002, // OPUS_SIGNAL_MUSIC
|
AudioQualityUltraOpusSignalType: 3002, // OPUS_SIGNAL_MUSIC
|
||||||
AudioQualityUltraOpusBandwidth: 1105, // OPUS_BANDWIDTH_FULLBAND
|
AudioQualityUltraOpusBandwidth: 1105, // OPUS_BANDWIDTH_FULLBAND
|
||||||
AudioQualityUltraOpusDTX: 0, // Disable DTX for maximum quality
|
AudioQualityUltraOpusDTX: 0, // Disable DTX for maximum quality
|
||||||
|
|
||||||
// CGO Audio Constants
|
// CGO Audio Constants
|
||||||
CGOOpusBitrate: 96000,
|
CGOOpusBitrate: 96000,
|
||||||
|
|
|
@ -39,12 +39,13 @@ type AudioMuteData struct {
|
||||||
|
|
||||||
// AudioMetricsData represents audio metrics data
|
// AudioMetricsData represents audio metrics data
|
||||||
type AudioMetricsData struct {
|
type AudioMetricsData struct {
|
||||||
FramesReceived int64 `json:"frames_received"`
|
FramesReceived int64 `json:"frames_received"`
|
||||||
FramesDropped int64 `json:"frames_dropped"`
|
FramesDropped int64 `json:"frames_dropped"`
|
||||||
BytesProcessed int64 `json:"bytes_processed"`
|
BytesProcessed int64 `json:"bytes_processed"`
|
||||||
LastFrameTime string `json:"last_frame_time"`
|
LastFrameTime string `json:"last_frame_time"`
|
||||||
ConnectionDrops int64 `json:"connection_drops"`
|
ConnectionDrops int64 `json:"connection_drops"`
|
||||||
AverageLatency string `json:"average_latency"`
|
AverageLatency string `json:"average_latency"`
|
||||||
|
LatencyHistogram *LatencyHistogramData `json:"latency_histogram,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// MicrophoneStateData represents microphone state data
|
// MicrophoneStateData represents microphone state data
|
||||||
|
@ -55,12 +56,13 @@ type MicrophoneStateData struct {
|
||||||
|
|
||||||
// MicrophoneMetricsData represents microphone metrics data
|
// MicrophoneMetricsData represents microphone metrics data
|
||||||
type MicrophoneMetricsData struct {
|
type MicrophoneMetricsData struct {
|
||||||
FramesSent int64 `json:"frames_sent"`
|
FramesSent int64 `json:"frames_sent"`
|
||||||
FramesDropped int64 `json:"frames_dropped"`
|
FramesDropped int64 `json:"frames_dropped"`
|
||||||
BytesProcessed int64 `json:"bytes_processed"`
|
BytesProcessed int64 `json:"bytes_processed"`
|
||||||
LastFrameTime string `json:"last_frame_time"`
|
LastFrameTime string `json:"last_frame_time"`
|
||||||
ConnectionDrops int64 `json:"connection_drops"`
|
ConnectionDrops int64 `json:"connection_drops"`
|
||||||
AverageLatency string `json:"average_latency"`
|
AverageLatency string `json:"average_latency"`
|
||||||
|
LatencyHistogram *LatencyHistogramData `json:"latency_histogram,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessMetricsData represents process metrics data for WebSocket events
|
// ProcessMetricsData represents process metrics data for WebSocket events
|
||||||
|
@ -225,25 +227,41 @@ func (aeb *AudioEventBroadcaster) sendInitialState(connectionID string) {
|
||||||
|
|
||||||
// convertAudioMetricsToEventDataWithLatencyMs converts internal audio metrics to AudioMetricsData with millisecond latency formatting
|
// convertAudioMetricsToEventDataWithLatencyMs converts internal audio metrics to AudioMetricsData with millisecond latency formatting
|
||||||
func convertAudioMetricsToEventDataWithLatencyMs(metrics AudioMetrics) AudioMetricsData {
|
func convertAudioMetricsToEventDataWithLatencyMs(metrics AudioMetrics) AudioMetricsData {
|
||||||
|
// Get histogram data from granular metrics collector
|
||||||
|
granularCollector := GetGranularMetricsCollector()
|
||||||
|
var histogramData *LatencyHistogramData
|
||||||
|
if granularCollector != nil {
|
||||||
|
histogramData = granularCollector.GetOutputLatencyHistogram()
|
||||||
|
}
|
||||||
|
|
||||||
return AudioMetricsData{
|
return AudioMetricsData{
|
||||||
FramesReceived: metrics.FramesReceived,
|
FramesReceived: metrics.FramesReceived,
|
||||||
FramesDropped: metrics.FramesDropped,
|
FramesDropped: metrics.FramesDropped,
|
||||||
BytesProcessed: metrics.BytesProcessed,
|
BytesProcessed: metrics.BytesProcessed,
|
||||||
LastFrameTime: metrics.LastFrameTime.Format(GetConfig().EventTimeFormatString),
|
LastFrameTime: metrics.LastFrameTime.Format(GetConfig().EventTimeFormatString),
|
||||||
ConnectionDrops: metrics.ConnectionDrops,
|
ConnectionDrops: metrics.ConnectionDrops,
|
||||||
AverageLatency: fmt.Sprintf("%.1fms", float64(metrics.AverageLatency.Nanoseconds())/1e6),
|
AverageLatency: fmt.Sprintf("%.1fms", float64(metrics.AverageLatency.Nanoseconds())/1e6),
|
||||||
|
LatencyHistogram: histogramData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertAudioInputMetricsToEventDataWithLatencyMs converts internal audio input metrics to MicrophoneMetricsData with millisecond latency formatting
|
// convertAudioInputMetricsToEventDataWithLatencyMs converts internal audio input metrics to MicrophoneMetricsData with millisecond latency formatting
|
||||||
func convertAudioInputMetricsToEventDataWithLatencyMs(metrics AudioInputMetrics) MicrophoneMetricsData {
|
func convertAudioInputMetricsToEventDataWithLatencyMs(metrics AudioInputMetrics) MicrophoneMetricsData {
|
||||||
|
// Get histogram data from granular metrics collector
|
||||||
|
granularCollector := GetGranularMetricsCollector()
|
||||||
|
var histogramData *LatencyHistogramData
|
||||||
|
if granularCollector != nil {
|
||||||
|
histogramData = granularCollector.GetInputLatencyHistogram()
|
||||||
|
}
|
||||||
|
|
||||||
return MicrophoneMetricsData{
|
return MicrophoneMetricsData{
|
||||||
FramesSent: metrics.FramesSent,
|
FramesSent: metrics.FramesSent,
|
||||||
FramesDropped: metrics.FramesDropped,
|
FramesDropped: metrics.FramesDropped,
|
||||||
BytesProcessed: metrics.BytesProcessed,
|
BytesProcessed: metrics.BytesProcessed,
|
||||||
LastFrameTime: metrics.LastFrameTime.Format(GetConfig().EventTimeFormatString),
|
LastFrameTime: metrics.LastFrameTime.Format(GetConfig().EventTimeFormatString),
|
||||||
ConnectionDrops: metrics.ConnectionDrops,
|
ConnectionDrops: metrics.ConnectionDrops,
|
||||||
AverageLatency: fmt.Sprintf("%.1fms", float64(metrics.AverageLatency.Nanoseconds())/1e6),
|
AverageLatency: fmt.Sprintf("%.1fms", float64(metrics.AverageLatency.Nanoseconds())/1e6),
|
||||||
|
LatencyHistogram: histogramData,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -142,6 +142,32 @@ func (lh *LatencyHistogram) RecordLatency(latency time.Duration) {
|
||||||
lh.samplesMutex.Unlock()
|
lh.samplesMutex.Unlock()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// LatencyHistogramData represents histogram data for WebSocket transmission
|
||||||
|
type LatencyHistogramData struct {
|
||||||
|
Buckets []float64 `json:"buckets"` // Bucket boundaries in milliseconds
|
||||||
|
Counts []int64 `json:"counts"` // Count for each bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetHistogramData returns histogram buckets and counts for WebSocket transmission
|
||||||
|
func (lh *LatencyHistogram) GetHistogramData() LatencyHistogramData {
|
||||||
|
// Convert bucket boundaries from nanoseconds to milliseconds
|
||||||
|
buckets := make([]float64, len(lh.buckets))
|
||||||
|
for i, bucket := range lh.buckets {
|
||||||
|
buckets[i] = float64(bucket) / 1e6 // Convert ns to ms
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get current counts atomically
|
||||||
|
counts := make([]int64, len(lh.counts))
|
||||||
|
for i := range lh.counts {
|
||||||
|
counts[i] = atomic.LoadInt64(&lh.counts[i])
|
||||||
|
}
|
||||||
|
|
||||||
|
return LatencyHistogramData{
|
||||||
|
Buckets: buckets,
|
||||||
|
Counts: counts,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// GetPercentiles calculates latency percentiles from recent samples
|
// GetPercentiles calculates latency percentiles from recent samples
|
||||||
func (lh *LatencyHistogram) GetPercentiles() LatencyPercentiles {
|
func (lh *LatencyHistogram) GetPercentiles() LatencyPercentiles {
|
||||||
lh.samplesMutex.RLock()
|
lh.samplesMutex.RLock()
|
||||||
|
@ -341,6 +367,32 @@ func (gmc *GranularMetricsCollector) GetLatencyPercentiles() map[string]LatencyP
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetInputLatencyHistogram returns histogram data for input latency
|
||||||
|
func (gmc *GranularMetricsCollector) GetInputLatencyHistogram() *LatencyHistogramData {
|
||||||
|
gmc.mutex.RLock()
|
||||||
|
defer gmc.mutex.RUnlock()
|
||||||
|
|
||||||
|
if gmc.inputLatencyHist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gmc.inputLatencyHist.GetHistogramData()
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetOutputLatencyHistogram returns histogram data for output latency
|
||||||
|
func (gmc *GranularMetricsCollector) GetOutputLatencyHistogram() *LatencyHistogramData {
|
||||||
|
gmc.mutex.RLock()
|
||||||
|
defer gmc.mutex.RUnlock()
|
||||||
|
|
||||||
|
if gmc.outputLatencyHist == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
data := gmc.outputLatencyHist.GetHistogramData()
|
||||||
|
return &data
|
||||||
|
}
|
||||||
|
|
||||||
// GetBufferPoolEfficiency returns efficiency metrics for all buffer pools
|
// GetBufferPoolEfficiency returns efficiency metrics for all buffer pools
|
||||||
func (gmc *GranularMetricsCollector) GetBufferPoolEfficiency() map[string]BufferPoolEfficiencyMetrics {
|
func (gmc *GranularMetricsCollector) GetBufferPoolEfficiency() map[string]BufferPoolEfficiencyMetrics {
|
||||||
gmc.mutex.RLock()
|
gmc.mutex.RLock()
|
||||||
|
|
|
@ -0,0 +1,560 @@
|
||||||
|
package audio
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/rs/zerolog"
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestLatencyHistogram tests the LatencyHistogram functionality
|
||||||
|
func TestLatencyHistogram(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
testFunc func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{"NewLatencyHistogram", testNewLatencyHistogram},
|
||||||
|
{"RecordLatency", testRecordLatency},
|
||||||
|
{"GetHistogramData", testGetHistogramData},
|
||||||
|
{"GetPercentiles", testGetPercentiles},
|
||||||
|
{"ConcurrentAccess", testLatencyHistogramConcurrentAccess},
|
||||||
|
{"BucketDistribution", testBucketDistribution},
|
||||||
|
{"OverflowBucket", testOverflowBucket},
|
||||||
|
{"RecentSamplesLimit", testRecentSamplesLimit},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.testFunc(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testNewLatencyHistogram tests LatencyHistogram creation
|
||||||
|
func testNewLatencyHistogram(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
maxSamples := 100
|
||||||
|
|
||||||
|
hist := NewLatencyHistogram(maxSamples, logger)
|
||||||
|
|
||||||
|
require.NotNil(t, hist)
|
||||||
|
assert.Equal(t, maxSamples, hist.maxSamples)
|
||||||
|
assert.NotNil(t, hist.buckets)
|
||||||
|
assert.NotNil(t, hist.counts)
|
||||||
|
assert.Equal(t, len(hist.buckets)+1, len(hist.counts)) // +1 for overflow bucket
|
||||||
|
assert.NotNil(t, hist.recentSamples)
|
||||||
|
assert.Equal(t, 0, len(hist.recentSamples))
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRecordLatency tests latency recording functionality
|
||||||
|
func testRecordLatency(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(100, logger)
|
||||||
|
|
||||||
|
// Test recording various latencies
|
||||||
|
latencies := []time.Duration{
|
||||||
|
500 * time.Microsecond, // Should go in first bucket (1ms)
|
||||||
|
3 * time.Millisecond, // Should go in second bucket (5ms)
|
||||||
|
15 * time.Millisecond, // Should go in third bucket (10ms)
|
||||||
|
100 * time.Millisecond, // Should go in appropriate bucket
|
||||||
|
3 * time.Second, // Should go in overflow bucket
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, latency := range latencies {
|
||||||
|
hist.RecordLatency(latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify sample count
|
||||||
|
assert.Equal(t, int64(len(latencies)), hist.sampleCount)
|
||||||
|
|
||||||
|
// Verify total latency is accumulated
|
||||||
|
expectedTotal := int64(0)
|
||||||
|
for _, latency := range latencies {
|
||||||
|
expectedTotal += latency.Nanoseconds()
|
||||||
|
}
|
||||||
|
assert.Equal(t, expectedTotal, hist.totalLatency)
|
||||||
|
|
||||||
|
// Verify recent samples are stored
|
||||||
|
assert.Equal(t, len(latencies), len(hist.recentSamples))
|
||||||
|
}
|
||||||
|
|
||||||
|
// testGetHistogramData tests histogram data retrieval
|
||||||
|
func testGetHistogramData(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(100, logger)
|
||||||
|
|
||||||
|
// Record some test latencies
|
||||||
|
hist.RecordLatency(500 * time.Microsecond)
|
||||||
|
hist.RecordLatency(3 * time.Millisecond)
|
||||||
|
hist.RecordLatency(15 * time.Millisecond)
|
||||||
|
hist.RecordLatency(3 * time.Second) // overflow
|
||||||
|
|
||||||
|
data := hist.GetHistogramData()
|
||||||
|
|
||||||
|
// Verify buckets are converted to milliseconds
|
||||||
|
require.NotNil(t, data.Buckets)
|
||||||
|
require.NotNil(t, data.Counts)
|
||||||
|
assert.Equal(t, len(hist.buckets), len(data.Buckets))
|
||||||
|
assert.Equal(t, len(hist.counts), len(data.Counts))
|
||||||
|
|
||||||
|
// Verify first bucket is 1ms
|
||||||
|
assert.Equal(t, 1.0, data.Buckets[0])
|
||||||
|
|
||||||
|
// Verify counts are non-negative
|
||||||
|
for i, count := range data.Counts {
|
||||||
|
assert.GreaterOrEqual(t, count, int64(0), "Count at index %d should be non-negative", i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify total counts match recorded samples
|
||||||
|
totalCounts := int64(0)
|
||||||
|
for _, count := range data.Counts {
|
||||||
|
totalCounts += count
|
||||||
|
}
|
||||||
|
assert.Equal(t, int64(4), totalCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testGetPercentiles tests percentile calculation
|
||||||
|
func testGetPercentiles(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(100, logger)
|
||||||
|
|
||||||
|
// Record a known set of latencies
|
||||||
|
latencies := []time.Duration{
|
||||||
|
1 * time.Millisecond,
|
||||||
|
2 * time.Millisecond,
|
||||||
|
3 * time.Millisecond,
|
||||||
|
4 * time.Millisecond,
|
||||||
|
5 * time.Millisecond,
|
||||||
|
10 * time.Millisecond,
|
||||||
|
20 * time.Millisecond,
|
||||||
|
50 * time.Millisecond,
|
||||||
|
100 * time.Millisecond,
|
||||||
|
200 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, latency := range latencies {
|
||||||
|
hist.RecordLatency(latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
percentiles := hist.GetPercentiles()
|
||||||
|
|
||||||
|
// Verify percentiles are calculated
|
||||||
|
assert.Greater(t, percentiles.P50, time.Duration(0))
|
||||||
|
assert.Greater(t, percentiles.P95, time.Duration(0))
|
||||||
|
assert.Greater(t, percentiles.P99, time.Duration(0))
|
||||||
|
assert.Greater(t, percentiles.Min, time.Duration(0))
|
||||||
|
assert.Greater(t, percentiles.Max, time.Duration(0))
|
||||||
|
assert.Greater(t, percentiles.Avg, time.Duration(0))
|
||||||
|
|
||||||
|
// Verify ordering: Min <= P50 <= P95 <= P99 <= Max
|
||||||
|
assert.LessOrEqual(t, percentiles.Min, percentiles.P50)
|
||||||
|
assert.LessOrEqual(t, percentiles.P50, percentiles.P95)
|
||||||
|
assert.LessOrEqual(t, percentiles.P95, percentiles.P99)
|
||||||
|
assert.LessOrEqual(t, percentiles.P99, percentiles.Max)
|
||||||
|
|
||||||
|
// Verify min and max are correct
|
||||||
|
assert.Equal(t, 1*time.Millisecond, percentiles.Min)
|
||||||
|
assert.Equal(t, 200*time.Millisecond, percentiles.Max)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testLatencyHistogramConcurrentAccess tests thread safety
|
||||||
|
func testLatencyHistogramConcurrentAccess(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(1000, logger)
|
||||||
|
|
||||||
|
const numGoroutines = 10
|
||||||
|
const samplesPerGoroutine = 100
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines)
|
||||||
|
|
||||||
|
// Concurrent writers
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < samplesPerGoroutine; j++ {
|
||||||
|
latency := time.Duration(id*j+1) * time.Microsecond
|
||||||
|
hist.RecordLatency(latency)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent readers
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
go func() {
|
||||||
|
for j := 0; j < 50; j++ {
|
||||||
|
_ = hist.GetHistogramData()
|
||||||
|
_ = hist.GetPercentiles()
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify final state
|
||||||
|
assert.Equal(t, int64(numGoroutines*samplesPerGoroutine), hist.sampleCount)
|
||||||
|
data := hist.GetHistogramData()
|
||||||
|
assert.NotNil(t, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testBucketDistribution tests that latencies are distributed correctly across buckets
|
||||||
|
func testBucketDistribution(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(100, logger)
|
||||||
|
|
||||||
|
// Record latencies that should go into specific buckets
|
||||||
|
testCases := []struct {
|
||||||
|
latency time.Duration
|
||||||
|
expectedBucket int
|
||||||
|
}{
|
||||||
|
{500 * time.Microsecond, 0}, // < 1ms
|
||||||
|
{3 * time.Millisecond, 1}, // < 5ms
|
||||||
|
{8 * time.Millisecond, 2}, // < 10ms (assuming 10ms is bucket 2)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tc := range testCases {
|
||||||
|
hist.RecordLatency(tc.latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
data := hist.GetHistogramData()
|
||||||
|
|
||||||
|
// Verify that counts are in expected buckets
|
||||||
|
for i, tc := range testCases {
|
||||||
|
if tc.expectedBucket < len(data.Counts) {
|
||||||
|
assert.GreaterOrEqual(t, data.Counts[tc.expectedBucket], int64(1),
|
||||||
|
"Test case %d: Expected bucket %d to have at least 1 count", i, tc.expectedBucket)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testOverflowBucket tests the overflow bucket functionality
|
||||||
|
func testOverflowBucket(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(100, logger)
|
||||||
|
|
||||||
|
// Record a latency that should go into overflow bucket
|
||||||
|
veryHighLatency := 10 * time.Second
|
||||||
|
hist.RecordLatency(veryHighLatency)
|
||||||
|
|
||||||
|
data := hist.GetHistogramData()
|
||||||
|
|
||||||
|
// Verify overflow bucket (last bucket) has the count
|
||||||
|
overflowBucketIndex := len(data.Counts) - 1
|
||||||
|
assert.Equal(t, int64(1), data.Counts[overflowBucketIndex])
|
||||||
|
|
||||||
|
// Verify other buckets are empty
|
||||||
|
for i := 0; i < overflowBucketIndex; i++ {
|
||||||
|
assert.Equal(t, int64(0), data.Counts[i], "Bucket %d should be empty", i)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRecentSamplesLimit tests that recent samples are limited correctly
|
||||||
|
func testRecentSamplesLimit(t *testing.T) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
maxSamples := 5
|
||||||
|
hist := NewLatencyHistogram(maxSamples, logger)
|
||||||
|
|
||||||
|
// Record more samples than the limit
|
||||||
|
for i := 0; i < maxSamples*2; i++ {
|
||||||
|
hist.RecordLatency(time.Duration(i+1) * time.Millisecond)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify recent samples are limited
|
||||||
|
hist.samplesMutex.RLock()
|
||||||
|
assert.Equal(t, maxSamples, len(hist.recentSamples))
|
||||||
|
hist.samplesMutex.RUnlock()
|
||||||
|
|
||||||
|
// Verify total sample count is still correct
|
||||||
|
assert.Equal(t, int64(maxSamples*2), hist.sampleCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGranularMetricsCollector tests the GranularMetricsCollector functionality
|
||||||
|
func TestGranularMetricsCollector(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
testFunc func(t *testing.T)
|
||||||
|
}{
|
||||||
|
{"GetGranularMetricsCollector", testGetGranularMetricsCollector},
|
||||||
|
{"RecordInputLatency", testRecordInputLatency},
|
||||||
|
{"RecordOutputLatency", testRecordOutputLatency},
|
||||||
|
{"GetInputLatencyHistogram", testGetInputLatencyHistogram},
|
||||||
|
{"GetOutputLatencyHistogram", testGetOutputLatencyHistogram},
|
||||||
|
{"GetLatencyPercentiles", testGetLatencyPercentiles},
|
||||||
|
{"ConcurrentCollectorAccess", testConcurrentCollectorAccess},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
tt.testFunc(t)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testGetGranularMetricsCollector tests singleton behavior
|
||||||
|
func testGetGranularMetricsCollector(t *testing.T) {
|
||||||
|
collector1 := GetGranularMetricsCollector()
|
||||||
|
collector2 := GetGranularMetricsCollector()
|
||||||
|
|
||||||
|
require.NotNil(t, collector1)
|
||||||
|
require.NotNil(t, collector2)
|
||||||
|
assert.Same(t, collector1, collector2, "Should return the same singleton instance")
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRecordInputLatency tests input latency recording
|
||||||
|
func testRecordInputLatency(t *testing.T) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
require.NotNil(t, collector)
|
||||||
|
|
||||||
|
testLatency := 5 * time.Millisecond
|
||||||
|
collector.RecordInputLatency(testLatency)
|
||||||
|
|
||||||
|
// Verify histogram data is available
|
||||||
|
histData := collector.GetInputLatencyHistogram()
|
||||||
|
require.NotNil(t, histData)
|
||||||
|
assert.NotNil(t, histData.Buckets)
|
||||||
|
assert.NotNil(t, histData.Counts)
|
||||||
|
|
||||||
|
// Verify at least one count is recorded
|
||||||
|
totalCounts := int64(0)
|
||||||
|
for _, count := range histData.Counts {
|
||||||
|
totalCounts += count
|
||||||
|
}
|
||||||
|
assert.Equal(t, int64(1), totalCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testRecordOutputLatency tests output latency recording
|
||||||
|
func testRecordOutputLatency(t *testing.T) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
require.NotNil(t, collector)
|
||||||
|
|
||||||
|
testLatency := 10 * time.Millisecond
|
||||||
|
collector.RecordOutputLatency(testLatency)
|
||||||
|
|
||||||
|
// Verify histogram data is available
|
||||||
|
histData := collector.GetOutputLatencyHistogram()
|
||||||
|
require.NotNil(t, histData)
|
||||||
|
assert.NotNil(t, histData.Buckets)
|
||||||
|
assert.NotNil(t, histData.Counts)
|
||||||
|
|
||||||
|
// Verify at least one count is recorded
|
||||||
|
totalCounts := int64(0)
|
||||||
|
for _, count := range histData.Counts {
|
||||||
|
totalCounts += count
|
||||||
|
}
|
||||||
|
assert.Equal(t, int64(1), totalCounts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testGetInputLatencyHistogram tests input histogram retrieval
|
||||||
|
func testGetInputLatencyHistogram(t *testing.T) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
require.NotNil(t, collector)
|
||||||
|
|
||||||
|
// Test when no data is recorded
|
||||||
|
histData := collector.GetInputLatencyHistogram()
|
||||||
|
if histData != nil {
|
||||||
|
assert.NotNil(t, histData.Buckets)
|
||||||
|
assert.NotNil(t, histData.Counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record some data and test again
|
||||||
|
collector.RecordInputLatency(2 * time.Millisecond)
|
||||||
|
histData = collector.GetInputLatencyHistogram()
|
||||||
|
require.NotNil(t, histData)
|
||||||
|
assert.NotNil(t, histData.Buckets)
|
||||||
|
assert.NotNil(t, histData.Counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testGetOutputLatencyHistogram tests output histogram retrieval
|
||||||
|
func testGetOutputLatencyHistogram(t *testing.T) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
require.NotNil(t, collector)
|
||||||
|
|
||||||
|
// Test when no data is recorded
|
||||||
|
histData := collector.GetOutputLatencyHistogram()
|
||||||
|
if histData != nil {
|
||||||
|
assert.NotNil(t, histData.Buckets)
|
||||||
|
assert.NotNil(t, histData.Counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record some data and test again
|
||||||
|
collector.RecordOutputLatency(7 * time.Millisecond)
|
||||||
|
histData = collector.GetOutputLatencyHistogram()
|
||||||
|
require.NotNil(t, histData)
|
||||||
|
assert.NotNil(t, histData.Buckets)
|
||||||
|
assert.NotNil(t, histData.Counts)
|
||||||
|
}
|
||||||
|
|
||||||
|
// testGetLatencyPercentiles tests percentile retrieval from collector
|
||||||
|
func testGetLatencyPercentiles(t *testing.T) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
require.NotNil(t, collector)
|
||||||
|
|
||||||
|
// Record some test data
|
||||||
|
latencies := []time.Duration{
|
||||||
|
1 * time.Millisecond,
|
||||||
|
5 * time.Millisecond,
|
||||||
|
10 * time.Millisecond,
|
||||||
|
20 * time.Millisecond,
|
||||||
|
50 * time.Millisecond,
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, latency := range latencies {
|
||||||
|
collector.RecordInputLatency(latency)
|
||||||
|
collector.RecordOutputLatency(latency)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test percentiles map
|
||||||
|
percentilesMap := collector.GetLatencyPercentiles()
|
||||||
|
require.NotNil(t, percentilesMap)
|
||||||
|
|
||||||
|
// Test input percentiles if available
|
||||||
|
if inputPercentiles, exists := percentilesMap["input"]; exists {
|
||||||
|
assert.Greater(t, inputPercentiles.P50, time.Duration(0))
|
||||||
|
assert.Greater(t, inputPercentiles.P95, time.Duration(0))
|
||||||
|
assert.Greater(t, inputPercentiles.P99, time.Duration(0))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Test output percentiles if available
|
||||||
|
if outputPercentiles, exists := percentilesMap["output"]; exists {
|
||||||
|
assert.Greater(t, outputPercentiles.P50, time.Duration(0))
|
||||||
|
assert.Greater(t, outputPercentiles.P95, time.Duration(0))
|
||||||
|
assert.Greater(t, outputPercentiles.P99, time.Duration(0))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// testConcurrentCollectorAccess tests thread safety of the collector
|
||||||
|
func testConcurrentCollectorAccess(t *testing.T) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
require.NotNil(t, collector)
|
||||||
|
|
||||||
|
const numGoroutines = 10
|
||||||
|
const operationsPerGoroutine = 50
|
||||||
|
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(numGoroutines * 3) // 3 types of operations
|
||||||
|
|
||||||
|
// Concurrent input latency recording
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < operationsPerGoroutine; j++ {
|
||||||
|
latency := time.Duration(id*j+1) * time.Microsecond
|
||||||
|
collector.RecordInputLatency(latency)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent output latency recording
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func(id int) {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < operationsPerGoroutine; j++ {
|
||||||
|
latency := time.Duration(id*j+1) * time.Microsecond
|
||||||
|
collector.RecordOutputLatency(latency)
|
||||||
|
}
|
||||||
|
}(i)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Concurrent data retrieval
|
||||||
|
for i := 0; i < numGoroutines; i++ {
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
for j := 0; j < operationsPerGoroutine; j++ {
|
||||||
|
_ = collector.GetInputLatencyHistogram()
|
||||||
|
_ = collector.GetOutputLatencyHistogram()
|
||||||
|
_ = collector.GetLatencyPercentiles()
|
||||||
|
time.Sleep(time.Microsecond)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
|
||||||
|
wg.Wait()
|
||||||
|
|
||||||
|
// Verify final state is consistent
|
||||||
|
inputData := collector.GetInputLatencyHistogram()
|
||||||
|
outputData := collector.GetOutputLatencyHistogram()
|
||||||
|
assert.NotNil(t, inputData)
|
||||||
|
assert.NotNil(t, outputData)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Benchmark tests for performance validation
|
||||||
|
func BenchmarkLatencyHistogram(b *testing.B) {
|
||||||
|
logger := zerolog.Nop()
|
||||||
|
hist := NewLatencyHistogram(1000, logger)
|
||||||
|
|
||||||
|
b.Run("RecordLatency", func(b *testing.B) {
|
||||||
|
latency := 5 * time.Millisecond
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
hist.RecordLatency(latency)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetHistogramData", func(b *testing.B) {
|
||||||
|
// Pre-populate with some data
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
hist.RecordLatency(time.Duration(i) * time.Microsecond)
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = hist.GetHistogramData()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetPercentiles", func(b *testing.B) {
|
||||||
|
// Pre-populate with some data
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
hist.RecordLatency(time.Duration(i) * time.Microsecond)
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = hist.GetPercentiles()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func BenchmarkGranularMetricsCollector(b *testing.B) {
|
||||||
|
collector := GetGranularMetricsCollector()
|
||||||
|
|
||||||
|
b.Run("RecordInputLatency", func(b *testing.B) {
|
||||||
|
latency := 5 * time.Millisecond
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
collector.RecordInputLatency(latency)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("RecordOutputLatency", func(b *testing.B) {
|
||||||
|
latency := 5 * time.Millisecond
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
collector.RecordOutputLatency(latency)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetInputLatencyHistogram", func(b *testing.B) {
|
||||||
|
// Pre-populate with some data
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
collector.RecordInputLatency(time.Duration(i) * time.Microsecond)
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = collector.GetInputLatencyHistogram()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
b.Run("GetOutputLatencyHistogram", func(b *testing.B) {
|
||||||
|
// Pre-populate with some data
|
||||||
|
for i := 0; i < 100; i++ {
|
||||||
|
collector.RecordOutputLatency(time.Duration(i) * time.Microsecond)
|
||||||
|
}
|
||||||
|
b.ResetTimer()
|
||||||
|
for i := 0; i < b.N; i++ {
|
||||||
|
_ = collector.GetOutputLatencyHistogram()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
|
@ -1,5 +1,5 @@
|
||||||
//go:build !cgo || arm
|
//go:build cgo
|
||||||
// +build !cgo arm
|
// +build cgo
|
||||||
|
|
||||||
package audio
|
package audio
|
||||||
|
|
||||||
|
@ -306,11 +306,7 @@ func ValidateAudioConfigConstants(config *AudioConfigConstants) error {
|
||||||
// Cached max frame size to avoid function call overhead in hot paths
|
// Cached max frame size to avoid function call overhead in hot paths
|
||||||
var cachedMaxFrameSize int
|
var cachedMaxFrameSize int
|
||||||
|
|
||||||
// Initialize validation cache at package initialization
|
// Note: Validation cache is initialized on first use to avoid init function
|
||||||
func init() {
|
|
||||||
// This ensures the cache is always initialized before any validation calls
|
|
||||||
cachedMaxFrameSize = 4096 // Default value, will be updated by InitValidationCache
|
|
||||||
}
|
|
||||||
|
|
||||||
// InitValidationCache initializes cached validation values with actual config
|
// InitValidationCache initializes cached validation values with actual config
|
||||||
func InitValidationCache() {
|
func InitValidationCache() {
|
||||||
|
@ -327,6 +323,10 @@ func InitValidationCache() {
|
||||||
//
|
//
|
||||||
//go:inline
|
//go:inline
|
||||||
func ValidateAudioFrame(data []byte) error {
|
func ValidateAudioFrame(data []byte) error {
|
||||||
|
// Initialize cache on first use if not already done
|
||||||
|
if cachedMaxFrameSize == 0 {
|
||||||
|
InitValidationCache()
|
||||||
|
}
|
||||||
// Optimized validation with pre-allocated error messages for minimal overhead
|
// Optimized validation with pre-allocated error messages for minimal overhead
|
||||||
dataLen := len(data)
|
dataLen := len(data)
|
||||||
if dataLen == 0 {
|
if dataLen == 0 {
|
||||||
|
|
|
@ -4,6 +4,7 @@ import { LuActivity, LuClock, LuHardDrive, LuSettings, LuCpu, LuMemoryStick } fr
|
||||||
|
|
||||||
import { AudioLevelMeter } from "@components/AudioLevelMeter";
|
import { AudioLevelMeter } from "@components/AudioLevelMeter";
|
||||||
import StatChart from "@components/StatChart";
|
import StatChart from "@components/StatChart";
|
||||||
|
import LatencyHistogram from "@components/charts/LatencyHistogram";
|
||||||
import { cx } from "@/cva.config";
|
import { cx } from "@/cva.config";
|
||||||
import { useMicrophone } from "@/hooks/useMicrophone";
|
import { useMicrophone } from "@/hooks/useMicrophone";
|
||||||
import { useAudioLevel } from "@/hooks/useAudioLevel";
|
import { useAudioLevel } from "@/hooks/useAudioLevel";
|
||||||
|
@ -12,6 +13,11 @@ import api from "@/api";
|
||||||
import { AUDIO_CONFIG } from "@/config/constants";
|
import { AUDIO_CONFIG } from "@/config/constants";
|
||||||
import audioQualityService from "@/services/audioQualityService";
|
import audioQualityService from "@/services/audioQualityService";
|
||||||
|
|
||||||
|
interface LatencyHistogramData {
|
||||||
|
buckets: number[]; // Bucket boundaries in milliseconds
|
||||||
|
counts: number[]; // Count for each bucket
|
||||||
|
}
|
||||||
|
|
||||||
interface AudioMetrics {
|
interface AudioMetrics {
|
||||||
frames_received: number;
|
frames_received: number;
|
||||||
frames_dropped: number;
|
frames_dropped: number;
|
||||||
|
@ -19,6 +25,7 @@ interface AudioMetrics {
|
||||||
last_frame_time: string;
|
last_frame_time: string;
|
||||||
connection_drops: number;
|
connection_drops: number;
|
||||||
average_latency: string;
|
average_latency: string;
|
||||||
|
latency_histogram?: LatencyHistogramData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MicrophoneMetrics {
|
interface MicrophoneMetrics {
|
||||||
|
@ -28,6 +35,7 @@ interface MicrophoneMetrics {
|
||||||
last_frame_time: string;
|
last_frame_time: string;
|
||||||
connection_drops: number;
|
connection_drops: number;
|
||||||
average_latency: string;
|
average_latency: string;
|
||||||
|
latency_histogram?: LatencyHistogramData;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface ProcessMetrics {
|
interface ProcessMetrics {
|
||||||
|
@ -503,6 +511,26 @@ export default function AudioMetricsDashboard() {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Latency Histograms */}
|
||||||
|
{metrics && (
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<LatencyHistogram
|
||||||
|
data={audioMetrics?.latency_histogram}
|
||||||
|
title="Audio Output Latency Distribution"
|
||||||
|
height={180}
|
||||||
|
className=""
|
||||||
|
/>
|
||||||
|
{microphoneMetrics && (
|
||||||
|
<LatencyHistogram
|
||||||
|
data={microphoneMetrics.latency_histogram}
|
||||||
|
title="Microphone Input Latency Distribution"
|
||||||
|
height={180}
|
||||||
|
className=""
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Subprocess Resource Usage - Histogram View */}
|
{/* Subprocess Resource Usage - Histogram View */}
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
{/* Audio Output Subprocess */}
|
{/* Audio Output Subprocess */}
|
||||||
|
|
|
@ -0,0 +1,145 @@
|
||||||
|
import React, { useMemo } from 'react';
|
||||||
|
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||||
|
|
||||||
|
import { LatencyHistogramData } from '../../hooks/useAudioEvents';
|
||||||
|
|
||||||
|
interface LatencyHistogramProps {
|
||||||
|
data?: LatencyHistogramData;
|
||||||
|
title: string;
|
||||||
|
height?: number;
|
||||||
|
className?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ChartDataPoint {
|
||||||
|
bucket: string;
|
||||||
|
count: number;
|
||||||
|
bucketValue: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
const LatencyHistogram: React.FC<LatencyHistogramProps> = ({
|
||||||
|
data,
|
||||||
|
title,
|
||||||
|
height = 200,
|
||||||
|
className = ''
|
||||||
|
}) => {
|
||||||
|
// Memoize chart data transformation to avoid recalculation on every render
|
||||||
|
const chartData = useMemo((): ChartDataPoint[] => {
|
||||||
|
if (!data || !data.buckets || !data.counts || data.buckets.length === 0) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
const transformedData: ChartDataPoint[] = [];
|
||||||
|
|
||||||
|
// Process each bucket with its count
|
||||||
|
for (let i = 0; i < data.buckets.length; i++) {
|
||||||
|
const bucketValue = data.buckets[i];
|
||||||
|
const count = data.counts[i] || 0;
|
||||||
|
|
||||||
|
// Skip empty buckets to reduce chart clutter
|
||||||
|
if (count === 0) continue;
|
||||||
|
|
||||||
|
// Format bucket label based on value
|
||||||
|
let bucketLabel: string;
|
||||||
|
if (bucketValue < 1) {
|
||||||
|
bucketLabel = `${(bucketValue * 1000).toFixed(0)}μs`;
|
||||||
|
} else if (bucketValue < 1000) {
|
||||||
|
bucketLabel = `${bucketValue.toFixed(1)}ms`;
|
||||||
|
} else {
|
||||||
|
bucketLabel = `${(bucketValue / 1000).toFixed(1)}s`;
|
||||||
|
}
|
||||||
|
|
||||||
|
transformedData.push({
|
||||||
|
bucket: bucketLabel,
|
||||||
|
count,
|
||||||
|
bucketValue
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle overflow bucket (last count if it exists)
|
||||||
|
if (data.counts.length > data.buckets.length) {
|
||||||
|
const overflowCount = data.counts[data.counts.length - 1];
|
||||||
|
if (overflowCount > 0) {
|
||||||
|
transformedData.push({
|
||||||
|
bucket: '>2s',
|
||||||
|
count: overflowCount,
|
||||||
|
bucketValue: 2000 // 2 seconds in ms
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return transformedData;
|
||||||
|
}, [data]);
|
||||||
|
|
||||||
|
// Custom tooltip for better UX
|
||||||
|
const CustomTooltip = ({ active, payload, label }: {
|
||||||
|
active?: boolean;
|
||||||
|
payload?: { payload: ChartDataPoint }[];
|
||||||
|
label?: string;
|
||||||
|
}) => {
|
||||||
|
if (active && payload && payload.length) {
|
||||||
|
const data = payload[0].payload;
|
||||||
|
return (
|
||||||
|
<div className="bg-gray-800 text-white p-2 rounded shadow-lg border border-gray-600">
|
||||||
|
<p className="font-medium">{`Latency: ${label}`}</p>
|
||||||
|
<p className="text-blue-300">{`Count: ${data.count}`}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!data || chartData.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-50 dark:bg-gray-800 rounded-lg p-4 ${className}`}>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<div className="flex items-center justify-center h-32 text-gray-500 dark:text-gray-400">
|
||||||
|
<span className="text-sm">No latency data available</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`bg-gray-50 dark:bg-gray-800 rounded-lg p-4 ${className}`}>
|
||||||
|
<h3 className="text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
|
||||||
|
{title}
|
||||||
|
</h3>
|
||||||
|
<ResponsiveContainer width="100%" height={height}>
|
||||||
|
<BarChart
|
||||||
|
data={chartData}
|
||||||
|
margin={{
|
||||||
|
top: 5,
|
||||||
|
right: 5,
|
||||||
|
left: 5,
|
||||||
|
bottom: 5,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CartesianGrid strokeDasharray="3 3" stroke="#374151" opacity={0.3} />
|
||||||
|
<XAxis
|
||||||
|
dataKey="bucket"
|
||||||
|
tick={{ fontSize: 11, fill: '#6B7280' }}
|
||||||
|
axisLine={{ stroke: '#6B7280' }}
|
||||||
|
tickLine={{ stroke: '#6B7280' }}
|
||||||
|
/>
|
||||||
|
<YAxis
|
||||||
|
tick={{ fontSize: 11, fill: '#6B7280' }}
|
||||||
|
axisLine={{ stroke: '#6B7280' }}
|
||||||
|
tickLine={{ stroke: '#6B7280' }}
|
||||||
|
/>
|
||||||
|
<Tooltip content={<CustomTooltip />} />
|
||||||
|
<Bar
|
||||||
|
dataKey="count"
|
||||||
|
fill="#3B82F6"
|
||||||
|
radius={[2, 2, 0, 0]}
|
||||||
|
stroke="#1E40AF"
|
||||||
|
strokeWidth={1}
|
||||||
|
/>
|
||||||
|
</BarChart>
|
||||||
|
</ResponsiveContainer>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default LatencyHistogram;
|
|
@ -19,6 +19,11 @@ export interface AudioMuteData {
|
||||||
muted: boolean;
|
muted: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface LatencyHistogramData {
|
||||||
|
buckets: number[]; // Bucket boundaries in milliseconds
|
||||||
|
counts: number[]; // Count for each bucket
|
||||||
|
}
|
||||||
|
|
||||||
export interface AudioMetricsData {
|
export interface AudioMetricsData {
|
||||||
frames_received: number;
|
frames_received: number;
|
||||||
frames_dropped: number;
|
frames_dropped: number;
|
||||||
|
@ -26,6 +31,7 @@ export interface AudioMetricsData {
|
||||||
last_frame_time: string;
|
last_frame_time: string;
|
||||||
connection_drops: number;
|
connection_drops: number;
|
||||||
average_latency: string;
|
average_latency: string;
|
||||||
|
latency_histogram?: LatencyHistogramData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MicrophoneStateData {
|
export interface MicrophoneStateData {
|
||||||
|
@ -40,6 +46,7 @@ export interface MicrophoneMetricsData {
|
||||||
last_frame_time: string;
|
last_frame_time: string;
|
||||||
connection_drops: number;
|
connection_drops: number;
|
||||||
average_latency: string;
|
average_latency: string;
|
||||||
|
latency_histogram?: LatencyHistogramData;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ProcessMetricsData {
|
export interface ProcessMetricsData {
|
||||||
|
|
Loading…
Reference in New Issue