4949 lines
185 KiB
Go
4949 lines
185 KiB
Go
package capacity
|
||
|
||
import (
|
||
"bufio"
|
||
"fmt"
|
||
"math"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
|
||
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics"
|
||
)
|
||
|
||
type Status string
|
||
|
||
const (
|
||
StatusOK Status = "ok"
|
||
StatusDegraded Status = "degraded"
|
||
)
|
||
|
||
type Report struct {
|
||
Status Status `json:"status"`
|
||
CheckedAt time.Time `json:"checked_at"`
|
||
Totals Totals `json:"totals"`
|
||
Services []ServiceCheck `json:"services"`
|
||
GatewayFields *GatewayFieldsDiagnostics `json:"gateway_fields,omitempty"`
|
||
ProtocolFlow *ProtocolFlowDiagnostics `json:"protocol_flow,omitempty"`
|
||
IdentityResolution *IdentityResolutionDiagnostics `json:"identity_resolution,omitempty"`
|
||
PipelineLatency *PipelineLatencyDiagnostics `json:"pipeline_latency,omitempty"`
|
||
DailyMileageDiagnostics *DailyMileageDiagnostics `json:"daily_mileage_diagnostics,omitempty"`
|
||
DailyMileageSourceQuality *DailyMileageSourceQualityDiagnostics `json:"daily_mileage_source_quality,omitempty"`
|
||
DailyMileageSourceSelection *DailyMileageSourceSelectionDiagnostics `json:"daily_mileage_source_selection,omitempty"`
|
||
DataSourceDiagnostics *DataSourceDiagnostics `json:"data_source_diagnostics,omitempty"`
|
||
DataSourceKindDiagnostics *DataSourceDiagnostics `json:"data_source_kind_diagnostics,omitempty"`
|
||
JT808IdentityGaps *JT808IdentityGapDiagnostics `json:"jt808_identity_gaps,omitempty"`
|
||
Findings []string `json:"findings"`
|
||
}
|
||
|
||
type Totals struct {
|
||
ActiveConnections int64 `json:"active_connections"`
|
||
KafkaLag float64 `json:"kafka_lag"`
|
||
BridgeConsumerPending float64 `json:"bridge_consumer_pending"`
|
||
BridgeAckPending float64 `json:"bridge_ack_pending"`
|
||
BridgeBatchPendingMessages float64 `json:"bridge_batch_pending_messages"`
|
||
FieldsProjectorKafkaLag float64 `json:"fields_projector_kafka_lag"`
|
||
FieldsProjectorBatchPending float64 `json:"fields_projector_batch_pending_messages"`
|
||
FieldsProjectorRetryPending float64 `json:"fields_projector_retry_pending_messages"`
|
||
FastWriterConsumerPending float64 `json:"fast_writer_consumer_pending"`
|
||
FastWriterAckPending float64 `json:"fast_writer_ack_pending"`
|
||
FastWriterBatchPending float64 `json:"fast_writer_batch_pending_messages"`
|
||
HistoryBatchPending float64 `json:"history_batch_pending_messages"`
|
||
HistoryRowsPending float64 `json:"history_rows_pending"`
|
||
HistoryRetryPending float64 `json:"history_retry_pending_messages"`
|
||
RealtimeBatchPending float64 `json:"realtime_batch_pending_messages"`
|
||
RealtimeAsyncQueuePending float64 `json:"realtime_async_queue_pending"`
|
||
RealtimeRetryPending float64 `json:"realtime_retry_pending_messages"`
|
||
StatRetryPending float64 `json:"stat_retry_pending_messages"`
|
||
IdentityBatchPending float64 `json:"identity_batch_pending_messages"`
|
||
IdentityFactsPending float64 `json:"identity_batch_pending_facts"`
|
||
IdentityRetryPending float64 `json:"identity_retry_pending_messages"`
|
||
JT808RegistrationWriteQueuePending float64 `json:"jt808_registration_write_queue_pending"`
|
||
JT808RegistrationWriteErrors float64 `json:"jt808_registration_write_errors"`
|
||
}
|
||
|
||
type ServiceCheck struct {
|
||
Name string `json:"name"`
|
||
Metrics map[string]float64 `json:"metrics"`
|
||
}
|
||
|
||
type GatewayFieldsDiagnostics struct {
|
||
Protocols []GatewayProtocolFields `json:"protocols,omitempty"`
|
||
}
|
||
|
||
type GatewayProtocolFields struct {
|
||
Protocol string `json:"protocol"`
|
||
RawOK float64 `json:"raw_ok"`
|
||
RealtimeCandidates float64 `json:"realtime_candidates"`
|
||
FieldsOK float64 `json:"fields_ok"`
|
||
FieldsPublished float64 `json:"fields_published"`
|
||
FieldsDelegated float64 `json:"fields_delegated"`
|
||
FieldsSkippedNonRealtime float64 `json:"fields_skipped_non_realtime"`
|
||
FieldsSkippedMissing float64 `json:"fields_skipped_missing"`
|
||
FieldsPublishError float64 `json:"fields_publish_error"`
|
||
MissingRatio float64 `json:"missing_ratio"`
|
||
LatestFieldCount *float64 `json:"latest_field_count,omitempty"`
|
||
Status string `json:"status"`
|
||
Reason string `json:"reason,omitempty"`
|
||
}
|
||
|
||
type ProtocolFlowDiagnostics struct {
|
||
Protocols []ProtocolFlowProtocol `json:"protocols,omitempty"`
|
||
}
|
||
|
||
type ProtocolFlowProtocol struct {
|
||
Protocol string `json:"protocol"`
|
||
RawOK float64 `json:"raw_ok"`
|
||
RealtimeCandidates float64 `json:"realtime_candidates"`
|
||
FieldsPublished float64 `json:"fields_published"`
|
||
LatestFieldCount *float64 `json:"latest_field_count,omitempty"`
|
||
IdentityTotal int64 `json:"identity_total"`
|
||
IdentitySkipped int64 `json:"identity_skipped"`
|
||
IdentityResolved int64 `json:"identity_resolved"`
|
||
IdentityNonResolved int64 `json:"identity_non_resolved"`
|
||
IdentityNonResolvedRatio float64 `json:"identity_non_resolved_ratio"`
|
||
StatWritesOK float64 `json:"stat_writes_ok"`
|
||
StatMileageCandidateWrites float64 `json:"stat_mileage_candidate_writes"`
|
||
StatSamplesFound float64 `json:"stat_samples_found"`
|
||
StatSamplesWritten float64 `json:"stat_samples_written"`
|
||
StatSamplesActionableSkipped float64 `json:"stat_samples_actionable_skipped"`
|
||
StatSkippedMissingFields float64 `json:"stat_skipped_missing_fields"`
|
||
StatSkippedMissingVIN float64 `json:"stat_skipped_missing_vin"`
|
||
StatSkippedMissingMileage float64 `json:"stat_skipped_missing_mileage"`
|
||
StatSkippedNonMileageFrame float64 `json:"stat_skipped_non_mileage_frame"`
|
||
StatSkippedNonPositiveMileage float64 `json:"stat_skipped_non_positive_mileage"`
|
||
StatSkippedMissingTime float64 `json:"stat_skipped_missing_time"`
|
||
StatEventTimeFutureAdjusted float64 `json:"stat_event_time_future_adjusted"`
|
||
StatSkippedMissingSource float64 `json:"stat_skipped_missing_source"`
|
||
StatSkippedSameMileage float64 `json:"stat_skipped_same_mileage"`
|
||
StatSourceWritten float64 `json:"stat_source_written"`
|
||
StatSourceThrottled float64 `json:"stat_source_throttled"`
|
||
StatSourceMissingEndpoint float64 `json:"stat_source_missing_endpoint"`
|
||
StatSourceSkippedUnmanaged float64 `json:"stat_source_skipped_unmanaged"`
|
||
StatProjectionAttempted float64 `json:"stat_projection_attempted"`
|
||
StatProjectionWritten float64 `json:"stat_projection_written"`
|
||
StatProjectionThrottled float64 `json:"stat_projection_throttled"`
|
||
Status string `json:"status"`
|
||
Reason string `json:"reason,omitempty"`
|
||
}
|
||
|
||
type IdentityResolutionDiagnostics struct {
|
||
Total int64 `json:"total"`
|
||
SkippedCount int64 `json:"skipped_count"`
|
||
ResolvedCount int64 `json:"resolved_count"`
|
||
NonResolvedCount int64 `json:"non_resolved_count"`
|
||
UnresolvedCount int64 `json:"unresolved_count"`
|
||
TimeoutCount int64 `json:"timeout_count"`
|
||
ErrorCount int64 `json:"error_count"`
|
||
OtherNonResolvedCount int64 `json:"other_non_resolved_count"`
|
||
NonResolvedRatio float64 `json:"non_resolved_ratio"`
|
||
StaleCacheCount int64 `json:"stale_cache_count"`
|
||
StaleCacheRatio float64 `json:"stale_cache_ratio"`
|
||
CacheEntries map[string]int64 `json:"cache_entries,omitempty"`
|
||
Issues []IdentityIssueCount `json:"issues,omitempty"`
|
||
Protocols []IdentityProtocolResolution `json:"protocols,omitempty"`
|
||
}
|
||
|
||
type IdentityIssueCount struct {
|
||
Protocol string `json:"protocol"`
|
||
Status string `json:"status"`
|
||
MessageID string `json:"message_id"`
|
||
Reason string `json:"reason"`
|
||
Count int64 `json:"count"`
|
||
}
|
||
|
||
type IdentityProtocolResolution struct {
|
||
Protocol string `json:"protocol"`
|
||
Total int64 `json:"total"`
|
||
SkippedCount int64 `json:"skipped_count"`
|
||
ResolvedCount int64 `json:"resolved_count"`
|
||
NonResolvedCount int64 `json:"non_resolved_count"`
|
||
UnresolvedCount int64 `json:"unresolved_count"`
|
||
TimeoutCount int64 `json:"timeout_count"`
|
||
ErrorCount int64 `json:"error_count"`
|
||
OtherNonResolvedCount int64 `json:"other_non_resolved_count"`
|
||
NonResolvedRatio float64 `json:"non_resolved_ratio"`
|
||
StaleCacheCount int64 `json:"stale_cache_count"`
|
||
StaleCacheRatio float64 `json:"stale_cache_ratio"`
|
||
CacheStatusCounts map[string]int64 `json:"cache_status_counts,omitempty"`
|
||
}
|
||
|
||
type PipelineLatencyDiagnostics struct {
|
||
TotalSamples int64 `json:"total_samples"`
|
||
MaxP99MS float64 `json:"max_p99_ms"`
|
||
Items []PipelineLatencyItem `json:"items,omitempty"`
|
||
}
|
||
|
||
type PipelineLatencyItem struct {
|
||
Stage string `json:"stage"`
|
||
Service string `json:"service"`
|
||
Topic string `json:"topic,omitempty"`
|
||
Subject string `json:"subject,omitempty"`
|
||
P99MS float64 `json:"p99_ms"`
|
||
Samples int64 `json:"samples"`
|
||
ThresholdMS float64 `json:"threshold_ms,omitempty"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
type DailyMileageDiagnostics struct {
|
||
URL string `json:"url,omitempty"`
|
||
VehicleTotal int64 `json:"vehicle_total"`
|
||
ActionableIssueTotal int64 `json:"actionable_issue_total"`
|
||
PipelineIssueTotal int64 `json:"pipeline_issue_total"`
|
||
SourceDataIssueTotal int64 `json:"source_data_issue_total"`
|
||
Items []DailyMileageDiagnosisReasonItem `json:"items,omitempty"`
|
||
Samples []DailyMileageDiagnosisSample `json:"samples,omitempty"`
|
||
FieldStatus *DailyMileageFieldStatusDiagnostics `json:"field_status,omitempty"`
|
||
}
|
||
|
||
type DailyMileageDiagnosisReasonItem struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
Diagnosis string `json:"diagnosis"`
|
||
Reason string `json:"reason"`
|
||
Count int64 `json:"count"`
|
||
Severity string `json:"severity,omitempty"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action,omitempty"`
|
||
}
|
||
|
||
type DailyMileageFieldStatusDiagnostics struct {
|
||
URL string `json:"url,omitempty"`
|
||
SummaryTotal int64 `json:"summary_total"`
|
||
VehicleTotal int64 `json:"vehicle_total"`
|
||
ActionableIssueTotal int64 `json:"actionable_issue_total"`
|
||
PipelineIssueTotal int64 `json:"pipeline_issue_total"`
|
||
SourceDataIssueTotal int64 `json:"source_data_issue_total"`
|
||
Items []DailyMileageFieldStatusSummaryItem `json:"items,omitempty"`
|
||
}
|
||
|
||
type DailyMileageFieldStatusSummaryItem struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
Diagnosis string `json:"diagnosis"`
|
||
Reason string `json:"reason"`
|
||
RealtimeMileageFieldStatus string `json:"realtime_mileage_field_status"`
|
||
Count int64 `json:"count"`
|
||
Severity string `json:"severity,omitempty"`
|
||
FieldStatusSeverity string `json:"field_status_severity,omitempty"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action,omitempty"`
|
||
FieldStatusAction string `json:"field_status_action,omitempty"`
|
||
}
|
||
|
||
type DailyMileageDiagnosisSample struct {
|
||
VIN string `json:"vin"`
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
Plate string `json:"plate,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
Peer string `json:"peer,omitempty"`
|
||
SnapshotEventTime string `json:"snapshot_event_time,omitempty"`
|
||
LocationEventTime string `json:"location_event_time,omitempty"`
|
||
SnapshotUpdatedAt string `json:"snapshot_updated_at,omitempty"`
|
||
LocationUpdatedAt string `json:"location_updated_at,omitempty"`
|
||
RealtimeTotalMileageKM *float64 `json:"realtime_total_mileage_km,omitempty"`
|
||
RealtimeTotalMileageAt string `json:"realtime_total_mileage_event_time,omitempty"`
|
||
DailyMileageKM *float64 `json:"daily_mileage_km,omitempty"`
|
||
DailyLatestTotalMileageKM *float64 `json:"daily_latest_total_mileage_km,omitempty"`
|
||
SourceSampleCount int64 `json:"source_sample_count"`
|
||
OKSourceCount int64 `json:"ok_source_count"`
|
||
SelectableSourceCount int64 `json:"selectable_source_count"`
|
||
RealtimeFieldCount int64 `json:"realtime_field_count,omitempty"`
|
||
RealtimeSampleFields []string `json:"realtime_sample_fields,omitempty"`
|
||
RealtimeMileageFieldStatus string `json:"realtime_mileage_field_status,omitempty"`
|
||
RealtimeMileageEvidence string `json:"realtime_mileage_evidence,omitempty"`
|
||
LatestStatEventTime string `json:"latest_stat_event_time,omitempty"`
|
||
QualityStatuses []string `json:"quality_statuses,omitempty"`
|
||
RawFrameQueryPath string `json:"raw_frame_query_path,omitempty"`
|
||
SourceSampleQueryPath string `json:"source_sample_query_path,omitempty"`
|
||
Diagnosis string `json:"diagnosis"`
|
||
Reason string `json:"reason"`
|
||
Severity string `json:"severity,omitempty"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action,omitempty"`
|
||
}
|
||
|
||
type DailyMileageSourceQualityDiagnostics struct {
|
||
URL string `json:"url,omitempty"`
|
||
SummaryTotal int64 `json:"summary_total"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
OKSourceCount int64 `json:"ok_source_count"`
|
||
InvalidDeltaSourceCount int64 `json:"invalid_delta_source_count"`
|
||
NoBaselineSourceCount int64 `json:"no_baseline_source_count"`
|
||
FallbackAfterInvalidBaselineSourceCount int64 `json:"fallback_after_invalid_baseline_source_count"`
|
||
RealtimeLocationFallbackSourceCount int64 `json:"realtime_location_fallback_source_count"`
|
||
Items []DailyMileageSourceQualityItem `json:"items,omitempty"`
|
||
ItemsTruncated bool `json:"items_truncated,omitempty"`
|
||
}
|
||
|
||
type DailyMileageSourceQualityItem struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
QualityStatus string `json:"quality_status"`
|
||
QualityReason string `json:"quality_reason,omitempty"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SelectedCount int64 `json:"selected_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
}
|
||
|
||
type DailyMileageSourceSelectionDiagnostics struct {
|
||
URL string `json:"url,omitempty"`
|
||
SummaryTotal int64 `json:"summary_total"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
SelectedSourceCount int64 `json:"selected_source_count"`
|
||
NotSelectedSourceCount int64 `json:"not_selected_source_count"`
|
||
NotSelectedMileageKM float64 `json:"not_selected_mileage_km"`
|
||
ExcludedSourceCount int64 `json:"excluded_source_count"`
|
||
UnmanagedSourceCount int64 `json:"unmanaged_source_count"`
|
||
UnknownKindSourceCount int64 `json:"unknown_kind_source_count"`
|
||
Protocols []DailyMileageSourceSelectionProtocol `json:"protocols,omitempty"`
|
||
Items []DailyMileageSourceSelectionItem `json:"items,omitempty"`
|
||
Samples []DailyMileageSourceSelectionSample `json:"samples,omitempty"`
|
||
ItemsTruncated bool `json:"items_truncated,omitempty"`
|
||
}
|
||
|
||
type DailyMileageSourceSelectionProtocol struct {
|
||
Protocol string `json:"protocol"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
SelectedSourceCount int64 `json:"selected_source_count"`
|
||
NotSelectedSourceCount int64 `json:"not_selected_source_count"`
|
||
NotSelectedMileageKM float64 `json:"not_selected_mileage_km"`
|
||
ExcludedSourceCount int64 `json:"excluded_source_count"`
|
||
UnmanagedSourceCount int64 `json:"unmanaged_source_count"`
|
||
UnknownKindSourceCount int64 `json:"unknown_kind_source_count"`
|
||
}
|
||
|
||
type DailyMileageSourceSelectionItem struct {
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
SelectionStatus string `json:"selection_status"`
|
||
SelectionReason string `json:"selection_reason,omitempty"`
|
||
SelectionAction string `json:"selection_action,omitempty"`
|
||
SourceCount int64 `json:"source_count"`
|
||
VehicleCount int64 `json:"vehicle_count"`
|
||
SelectedCount int64 `json:"selected_count"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
}
|
||
|
||
type DailyMileageSourceSelectionSample struct {
|
||
VIN string `json:"vin"`
|
||
StatDate string `json:"stat_date"`
|
||
Protocol string `json:"protocol"`
|
||
SourceKey string `json:"source_key"`
|
||
SourceIP string `json:"source_ip,omitempty"`
|
||
SourceEndpoint string `json:"source_endpoint,omitempty"`
|
||
Phone string `json:"phone,omitempty"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
SourceID *int64 `json:"source_id,omitempty"`
|
||
SourceCode string `json:"source_code,omitempty"`
|
||
SourceKind string `json:"source_kind,omitempty"`
|
||
SourceEnabled *bool `json:"source_enabled,omitempty"`
|
||
TrustPriority *int `json:"trust_priority,omitempty"`
|
||
LatestTotalMileageKM *float64 `json:"latest_total_mileage_km,omitempty"`
|
||
DailyMileageKM float64 `json:"daily_mileage_km"`
|
||
SampleCount int64 `json:"sample_count"`
|
||
QualityStatus string `json:"quality_status,omitempty"`
|
||
QualityReason string `json:"quality_reason,omitempty"`
|
||
SelectionStatus string `json:"selection_status,omitempty"`
|
||
SelectionReason string `json:"selection_reason,omitempty"`
|
||
SelectionAction string `json:"selection_action,omitempty"`
|
||
SelectedSource *DailyMileageSourceSelectionSample `json:"selected_source,omitempty"`
|
||
SelectedSourceQueryPath string `json:"selected_source_query_path,omitempty"`
|
||
UpdatedAt string `json:"updated_at,omitempty"`
|
||
}
|
||
|
||
type DataSourceDiagnostics struct {
|
||
URL string `json:"url,omitempty"`
|
||
MissingSourceCodeTotal int64 `json:"missing_source_code_total"`
|
||
RecentMissingSourceCodeTotal int64 `json:"recent_missing_source_code_total"`
|
||
UnknownSourceKindTotal int64 `json:"unknown_source_kind_total,omitempty"`
|
||
RecentUnknownSourceKindTotal int64 `json:"recent_unknown_source_kind_total,omitempty"`
|
||
PlatformKindCandidateTotal int64 `json:"platform_kind_candidate_total,omitempty"`
|
||
RecentPlatformKindCandidateTotal int64 `json:"recent_platform_kind_candidate_total,omitempty"`
|
||
RecentAgeThresholdSeconds int64 `json:"recent_age_threshold_seconds,omitempty"`
|
||
ActionableCandidateTotal int64 `json:"actionable_candidate_total"`
|
||
MappingIssueTotal int64 `json:"mapping_issue_total,omitempty"`
|
||
RecentMappingIssueTotal int64 `json:"recent_mapping_issue_total,omitempty"`
|
||
ReasonCounts map[string]int64 `json:"reason_counts,omitempty"`
|
||
MappingIssueReasonCounts map[string]int64 `json:"mapping_issue_reason_counts,omitempty"`
|
||
ReasonCountsTruncated bool `json:"reason_counts_truncated,omitempty"`
|
||
MappingIssueReasonCountsTruncated bool `json:"mapping_issue_reason_counts_truncated,omitempty"`
|
||
Samples []DataSourceDiagnosticSample `json:"samples,omitempty"`
|
||
MappingIssueSamples []DataSourceDiagnosticSample `json:"mapping_issue_samples,omitempty"`
|
||
}
|
||
|
||
type DataSourceDiagnosticSample struct {
|
||
ID int64 `json:"id"`
|
||
Protocol string `json:"protocol"`
|
||
SourceIP string `json:"source_ip"`
|
||
LatestSourceEndpoint string `json:"latest_source_endpoint,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
SourceCode string `json:"source_code,omitempty"`
|
||
SourceKind string `json:"source_kind"`
|
||
FirstSeenAt string `json:"first_seen_at,omitempty"`
|
||
LatestSeenAt string `json:"latest_seen_at,omitempty"`
|
||
ActiveSpanSeconds int64 `json:"active_span_seconds"`
|
||
LatestSeenAgeSeconds int64 `json:"latest_seen_age_seconds"`
|
||
RegistrationRows int64 `json:"registration_rows"`
|
||
PhoneCount int64 `json:"phone_count"`
|
||
UnknownVINRows int64 `json:"unknown_vin_rows"`
|
||
IdentifierMatchedPhones int64 `json:"identifier_matched_phones"`
|
||
UnmappedPhoneCount int64 `json:"unmapped_phone_count"`
|
||
IdentifierMatchRatio float64 `json:"identifier_match_ratio"`
|
||
ConfiguredSourceCodeMatchedPhones int64 `json:"configured_source_code_matched_phones"`
|
||
ConfiguredSourceCodePlatformName string `json:"configured_source_code_platform_name,omitempty"`
|
||
ConfiguredSourceCodeConflict bool `json:"configured_source_code_conflict"`
|
||
SourcePlatformNameMismatch bool `json:"source_platform_name_mismatch"`
|
||
MatchedSourceCodeCount int64 `json:"matched_source_code_count"`
|
||
CandidateSourceCode string `json:"candidate_source_code,omitempty"`
|
||
CandidatePlatformName string `json:"candidate_platform_name,omitempty"`
|
||
MatchedSourceCodes []string `json:"matched_source_codes,omitempty"`
|
||
MatchedPlatformNames []string `json:"matched_platform_names,omitempty"`
|
||
SamplePhones []string `json:"sample_phones,omitempty"`
|
||
Reason string `json:"reason"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action"`
|
||
SuggestedSourceKind string `json:"suggested_source_kind"`
|
||
SuggestionConfidence string `json:"suggestion_confidence"`
|
||
SuggestionReason string `json:"suggestion_reason"`
|
||
}
|
||
|
||
type JT808IdentityGapDiagnostics struct {
|
||
URL string `json:"url,omitempty"`
|
||
Total int64 `json:"total"`
|
||
RecentSeconds int64 `json:"recent_seconds"`
|
||
Samples []JT808IdentityGapSample `json:"samples,omitempty"`
|
||
}
|
||
|
||
type JT808IdentityGapSample struct {
|
||
Phone string `json:"phone"`
|
||
DeviceID string `json:"device_id,omitempty"`
|
||
Plate string `json:"plate,omitempty"`
|
||
VIN string `json:"vin,omitempty"`
|
||
SourceIP string `json:"source_ip,omitempty"`
|
||
SourceEndpoint string `json:"source_endpoint,omitempty"`
|
||
SourceCode string `json:"source_code,omitempty"`
|
||
PlatformName string `json:"platform_name,omitempty"`
|
||
SourceKind string `json:"source_kind,omitempty"`
|
||
FirstRegisteredAt string `json:"first_registered_at,omitempty"`
|
||
LatestRegisteredAt string `json:"latest_registered_at,omitempty"`
|
||
LatestAuthenticatedAt string `json:"latest_authenticated_at,omitempty"`
|
||
LatestSeenAt string `json:"latest_seen_at,omitempty"`
|
||
LatestSeenAgeSeconds int64 `json:"latest_seen_age_seconds"`
|
||
Reason string `json:"reason"`
|
||
RecommendedOperatorAction string `json:"recommended_operator_action"`
|
||
RawFrameQueryPath string `json:"raw_frame_query_path,omitempty"`
|
||
DataSourceQueryPath string `json:"data_source_query_path,omitempty"`
|
||
}
|
||
|
||
const (
|
||
DailyMileageSeverityOK = "ok"
|
||
DailyMileageSeverityPipeline = "pipeline"
|
||
DailyMileageSeveritySourceData = "source_data"
|
||
)
|
||
|
||
func AnnotateDailyMileageDiagnostics(items []DailyMileageDiagnosisReasonItem) (annotated []DailyMileageDiagnosisReasonItem, pipelineIssues int64, sourceDataIssues int64) {
|
||
annotated = make([]DailyMileageDiagnosisReasonItem, 0, len(items))
|
||
for _, item := range items {
|
||
severity := strings.ToLower(strings.TrimSpace(item.Severity))
|
||
if severity != DailyMileageSeverityOK && severity != DailyMileageSeverityPipeline && severity != DailyMileageSeveritySourceData {
|
||
severity = DailyMileageIssueSeverity(item)
|
||
}
|
||
item.Severity = severity
|
||
if strings.TrimSpace(item.RecommendedOperatorAction) == "" {
|
||
item.RecommendedOperatorAction = DailyMileageRecommendedOperatorAction(item.Diagnosis, item.Reason)
|
||
}
|
||
switch item.Severity {
|
||
case DailyMileageSeverityPipeline:
|
||
pipelineIssues += item.Count
|
||
case DailyMileageSeveritySourceData:
|
||
sourceDataIssues += item.Count
|
||
}
|
||
annotated = append(annotated, item)
|
||
}
|
||
return annotated, pipelineIssues, sourceDataIssues
|
||
}
|
||
|
||
func DailyMileageIssueSeverity(item DailyMileageDiagnosisReasonItem) string {
|
||
diagnosis := strings.ToUpper(strings.TrimSpace(item.Diagnosis))
|
||
reason := strings.ToLower(strings.TrimSpace(item.Reason))
|
||
switch diagnosis {
|
||
case "", "OK":
|
||
return DailyMileageSeverityOK
|
||
case "MISSING_DAILY":
|
||
if reason == "source_samples_all_invalid" {
|
||
return DailyMileageSeveritySourceData
|
||
}
|
||
return DailyMileageSeverityPipeline
|
||
case "NO_SOURCE_SAMPLE":
|
||
return DailyMileageSeverityPipeline
|
||
case "NO_TOTAL_MILEAGE":
|
||
switch reason {
|
||
case "realtime_total_mileage_time_missing":
|
||
return DailyMileageSeverityPipeline
|
||
default:
|
||
return DailyMileageSeveritySourceData
|
||
}
|
||
default:
|
||
return DailyMileageSeverityPipeline
|
||
}
|
||
}
|
||
|
||
func DailyMileageRecommendedOperatorAction(diagnosis, reason string) string {
|
||
diagnosis = strings.ToUpper(strings.TrimSpace(diagnosis))
|
||
reason = strings.ToLower(strings.TrimSpace(reason))
|
||
switch diagnosis {
|
||
case "", "OK":
|
||
return "无需处理,车辆当日里程已生成"
|
||
case "MISSING_DAILY":
|
||
return "统计源样本已存在但最终日表缺失,优先排查 stat-writer 投影任务、MySQL 写入和 source 选举"
|
||
case "NO_SOURCE_SAMPLE":
|
||
return "实时位置已有有效总里程但统计字段样本缺失,优先排查字段流 topic、stat-writer 消费和协议字段映射"
|
||
case "NO_TOTAL_MILEAGE":
|
||
switch reason {
|
||
case "realtime_total_mileage_time_missing":
|
||
return "总里程值存在但事件时间缺失,优先排查解析器和 history/realtime 写入的时间字段"
|
||
case "realtime_total_mileage_missing":
|
||
return "当日实时数据缺少总里程字段,核对平台是否上报该字段以及协议字段映射是否覆盖"
|
||
case "realtime_total_mileage_non_positive":
|
||
return "终端上报总里程小于等于 0,不参与差值统计,需核对设备或平台里程字段来源"
|
||
case "realtime_total_mileage_not_reported_on_stat_date":
|
||
return "最新总里程不是统计日期内上报,等待当日含总里程数据或检查平台是否只上报部分字段"
|
||
default:
|
||
return "车辆当日在线但没有可用于里程统计的总里程,优先核对源平台报文字段和解析映射"
|
||
}
|
||
default:
|
||
return "诊断状态未知,检查统计诊断 SQL 和上游字段流"
|
||
}
|
||
}
|
||
|
||
type Thresholds struct {
|
||
GatewayFrameP99MS float64
|
||
GatewayIdentityP99MS float64
|
||
GatewayResponseRecentP99MS float64
|
||
AsyncSinkP99MS float64
|
||
AsyncSinkQueueWaitRecentP99MS float64
|
||
BridgeBatchP99MS float64
|
||
BridgeKafkaWriteP99MS float64
|
||
BridgeKafkaE2EP99MS float64
|
||
BridgeKafkaRecentE2EP99MS float64
|
||
BridgeFetchWaitMaxMS float64
|
||
BridgeWorkersMin float64
|
||
BridgeRouteErrorMax float64
|
||
BridgeKafkaWriteErrorMax float64
|
||
BridgeMessageMinReceived float64
|
||
BridgeMessageMaxUnackedRatio float64
|
||
FieldsProjectorWorkersPerProtocolMin float64
|
||
FieldsProjectorInvalidJSONMax float64
|
||
FieldsProjectorWriteErrorMax float64
|
||
FieldsProjectorBatchPendingMax float64
|
||
FastWriterStageP99MS float64
|
||
FastWriterRedisE2EP99MS float64
|
||
FastWriterRedisRecentE2EP99MS float64
|
||
FastWriterFetchWaitMaxMS float64
|
||
FastWriterWorkersMin float64
|
||
FastWriterInvalidJSONMax float64
|
||
FastWriterFallbackErrorMax float64
|
||
FastWriterDecoupledUpdateErrorMax float64
|
||
FastWriterTDengineEnabledMax float64
|
||
FastWriterMessageMinReceived float64
|
||
FastWriterMessageMaxUnackedRatio float64
|
||
HistoryFlushP99MS float64
|
||
HistoryWriteE2EP99MS float64
|
||
HistoryWriteRecentE2EP99MS float64
|
||
HistoryWorkersMin float64
|
||
HistoryInvalidJSONMax float64
|
||
HistoryLocationErrorMax float64
|
||
HistoryLocationErrorRecentSec float64
|
||
HistoryLocationAccountingMaxRatio float64
|
||
HistoryFallbackErrorMax float64
|
||
HistoryRetryMax float64
|
||
HistoryRetryExhaustedMax float64
|
||
RealtimeBatchP99MS float64
|
||
RealtimeStoreP99MS float64
|
||
RealtimeWorkersMin float64
|
||
RealtimeStoreErrorMax float64
|
||
RealtimeInvalidJSONMax float64
|
||
StatWriteP99MS float64
|
||
StatWriteE2EP99MS float64
|
||
StatWriteRecentE2EP99MS float64
|
||
StatWorkersMin float64
|
||
IdentityWorkersMin float64
|
||
StatInvalidJSONMax float64
|
||
StatProtocolTopicMismatchMax float64
|
||
MessageContractMismatchMax float64
|
||
StatRetryMax float64
|
||
StatRetryExhaustedMax float64
|
||
ProcessUptimeMinSec float64
|
||
ProcessHeapAllocMaxBytes float64
|
||
ProcessHeapSysMaxBytes float64
|
||
ProcessGoroutinesMax float64
|
||
KafkaLagMax float64
|
||
KafkaConsumerCommitErrorMax float64
|
||
KafkaConsumerMessageMinReceived float64
|
||
KafkaConsumerMaxUncommittedRatio float64
|
||
DurableSpoolBacklogMax float64
|
||
DurableSpoolOldestAgeMaxSec float64
|
||
DurableSpoolErrorMax float64
|
||
DurableSpoolReplayErrorMax float64
|
||
DurableOutboxInflightMax float64
|
||
DurableOutboxErrorMax float64
|
||
AsyncSinkEnqueueErrorMax float64
|
||
AsyncSinkPublishErrorMax float64
|
||
AsyncSinkQueueDepthMax float64
|
||
AsyncSinkQueueMaxUsageRatio float64
|
||
FastWriterBatchPendingMax float64
|
||
HistoryBatchPendingMax float64
|
||
HistoryRowsPendingMax float64
|
||
RealtimeBatchPendingMax float64
|
||
RealtimeAsyncQueueDepthMax float64
|
||
RealtimeAsyncQueueMaxUsageRatio float64
|
||
RealtimeAsyncQueueDroppedMax float64
|
||
HistoryParsedFieldMinFrames float64
|
||
HistoryParsedFieldMaxMissingRatio float64
|
||
MinHistogramSamples float64
|
||
StatSampleMinWrites float64
|
||
StatSampleMaxActionableSkipRatio float64
|
||
StatSampleFutureAdjustmentMax float64
|
||
StatSourceMinSamples float64
|
||
StatSourceMaxMissingRatio float64
|
||
StatProjectionMinSampleWrites float64
|
||
StatBatchPendingMax float64
|
||
ConsumerRetryPendingMax float64
|
||
StatCacheEntriesMax float64
|
||
RealtimeThrottleMinSamples float64
|
||
RealtimeThrottleCacheEntriesMax float64
|
||
RealtimePlateCacheEntriesMax float64
|
||
FastWriterRedisEnvelopeMinSeen float64
|
||
FastWriterRedisEnvelopeMaxBadRatio float64
|
||
FastWriterRedisFieldMinSeen float64
|
||
FastWriterRedisFieldMaxStaleRatio float64
|
||
GatewayFieldMinRaw float64
|
||
GatewayFieldMaxMissingRatio float64
|
||
GatewayFieldMinCount float64
|
||
GatewayBridgeMinPublish float64
|
||
GatewayFastWriterMinRawPublish float64
|
||
GatewayParseMinFrames float64
|
||
GatewayParseMaxBadRatio float64
|
||
GatewayResponseMinSamples float64
|
||
GatewayResponseMaxErrorRatio float64
|
||
GatewayIdentityMinSamples float64
|
||
GatewayIdentityMaxUnresolvedRatio float64
|
||
GatewayIdentityCacheEntriesMax float64
|
||
GatewayIdentitySnapshotRequired float64
|
||
GatewayIdentitySnapshotMaxAgeSec float64
|
||
GatewayIdentityLocationTouchFailureMax float64
|
||
GatewayIdentityRegistrationWriteQueueDepthMax float64
|
||
GatewayIdentityRegistrationWriteQueueMaxUsageRatio float64
|
||
GatewayIdentityRegistrationWriteErrorMax float64
|
||
GatewayIdentityStaleMinSamples float64
|
||
GatewayIdentityMaxStaleCacheRatio float64
|
||
StatTopicMinBridge float64
|
||
RawFanoutMinBridge float64
|
||
LastActivityStaleSec float64
|
||
RequiredConsumerTopics map[string][]string
|
||
}
|
||
|
||
func DefaultThresholds() Thresholds {
|
||
return Thresholds{
|
||
GatewayFrameP99MS: 250,
|
||
GatewayIdentityP99MS: 100,
|
||
GatewayResponseRecentP99MS: 100,
|
||
AsyncSinkP99MS: 100,
|
||
AsyncSinkQueueWaitRecentP99MS: 100,
|
||
BridgeBatchP99MS: 5000,
|
||
BridgeKafkaWriteP99MS: 500,
|
||
BridgeKafkaE2EP99MS: 0,
|
||
BridgeKafkaRecentE2EP99MS: 300,
|
||
BridgeFetchWaitMaxMS: 50,
|
||
BridgeWorkersMin: 2,
|
||
BridgeRouteErrorMax: 0,
|
||
BridgeKafkaWriteErrorMax: 0,
|
||
BridgeMessageMinReceived: 5000,
|
||
BridgeMessageMaxUnackedRatio: 0.20,
|
||
FieldsProjectorWorkersPerProtocolMin: 1,
|
||
FieldsProjectorInvalidJSONMax: 0,
|
||
FieldsProjectorWriteErrorMax: 0,
|
||
FieldsProjectorBatchPendingMax: 1000,
|
||
FastWriterStageP99MS: 100,
|
||
FastWriterRedisE2EP99MS: 0,
|
||
FastWriterRedisRecentE2EP99MS: 100,
|
||
FastWriterFetchWaitMaxMS: 50,
|
||
FastWriterWorkersMin: 4,
|
||
FastWriterInvalidJSONMax: 0,
|
||
FastWriterFallbackErrorMax: 0,
|
||
FastWriterDecoupledUpdateErrorMax: 0,
|
||
FastWriterTDengineEnabledMax: 0,
|
||
FastWriterMessageMinReceived: 5000,
|
||
FastWriterMessageMaxUnackedRatio: 0.20,
|
||
HistoryFlushP99MS: 1000,
|
||
HistoryWriteE2EP99MS: 0,
|
||
HistoryWriteRecentE2EP99MS: 2000,
|
||
HistoryWorkersMin: 3,
|
||
HistoryInvalidJSONMax: 0,
|
||
HistoryLocationErrorMax: 0,
|
||
HistoryLocationErrorRecentSec: 300,
|
||
HistoryLocationAccountingMaxRatio: 0.02,
|
||
HistoryFallbackErrorMax: 0,
|
||
HistoryRetryMax: 100,
|
||
HistoryRetryExhaustedMax: 0,
|
||
RealtimeBatchP99MS: 500,
|
||
RealtimeStoreP99MS: 100,
|
||
RealtimeWorkersMin: 3,
|
||
RealtimeStoreErrorMax: 0,
|
||
RealtimeInvalidJSONMax: 0,
|
||
StatWriteP99MS: 100,
|
||
StatWriteE2EP99MS: 0,
|
||
StatWriteRecentE2EP99MS: 1000,
|
||
StatWorkersMin: 3,
|
||
IdentityWorkersMin: 3,
|
||
StatInvalidJSONMax: 0,
|
||
StatProtocolTopicMismatchMax: 0,
|
||
MessageContractMismatchMax: 0,
|
||
StatRetryMax: 100,
|
||
StatRetryExhaustedMax: 0,
|
||
ProcessUptimeMinSec: 0,
|
||
ProcessHeapAllocMaxBytes: 0,
|
||
ProcessHeapSysMaxBytes: 0,
|
||
ProcessGoroutinesMax: 0,
|
||
KafkaLagMax: 1000,
|
||
KafkaConsumerCommitErrorMax: 0,
|
||
KafkaConsumerMessageMinReceived: 100,
|
||
KafkaConsumerMaxUncommittedRatio: 0.20,
|
||
DurableSpoolBacklogMax: 10000,
|
||
DurableSpoolOldestAgeMaxSec: 300,
|
||
DurableSpoolErrorMax: 0,
|
||
DurableSpoolReplayErrorMax: 0,
|
||
DurableOutboxInflightMax: 10000,
|
||
DurableOutboxErrorMax: 0,
|
||
AsyncSinkEnqueueErrorMax: 0,
|
||
AsyncSinkPublishErrorMax: 0,
|
||
AsyncSinkQueueDepthMax: 10000,
|
||
AsyncSinkQueueMaxUsageRatio: 0.80,
|
||
FastWriterBatchPendingMax: 1000,
|
||
HistoryBatchPendingMax: 1000,
|
||
HistoryRowsPendingMax: 5000,
|
||
RealtimeBatchPendingMax: 1000,
|
||
RealtimeAsyncQueueDepthMax: 10000,
|
||
RealtimeAsyncQueueMaxUsageRatio: 0.80,
|
||
RealtimeAsyncQueueDroppedMax: 0,
|
||
HistoryParsedFieldMinFrames: 100,
|
||
HistoryParsedFieldMaxMissingRatio: 0.05,
|
||
MinHistogramSamples: 100,
|
||
StatSampleMinWrites: 100,
|
||
StatSampleMaxActionableSkipRatio: 0.60,
|
||
StatSampleFutureAdjustmentMax: 0,
|
||
StatSourceMinSamples: 100,
|
||
StatSourceMaxMissingRatio: 0.60,
|
||
StatProjectionMinSampleWrites: 100,
|
||
StatBatchPendingMax: 1000,
|
||
ConsumerRetryPendingMax: 0,
|
||
StatCacheEntriesMax: 1000000,
|
||
RealtimeThrottleMinSamples: 100,
|
||
RealtimeThrottleCacheEntriesMax: 1000000,
|
||
RealtimePlateCacheEntriesMax: 200000,
|
||
FastWriterRedisEnvelopeMinSeen: 100,
|
||
FastWriterRedisEnvelopeMaxBadRatio: 0.50,
|
||
FastWriterRedisFieldMinSeen: 1000,
|
||
FastWriterRedisFieldMaxStaleRatio: 0.20,
|
||
GatewayFieldMinRaw: 100,
|
||
GatewayFieldMaxMissingRatio: 0.20,
|
||
GatewayFieldMinCount: 1,
|
||
GatewayBridgeMinPublish: 1000,
|
||
GatewayFastWriterMinRawPublish: 1000,
|
||
GatewayParseMinFrames: 100,
|
||
GatewayParseMaxBadRatio: 0.05,
|
||
GatewayResponseMinSamples: 100,
|
||
GatewayResponseMaxErrorRatio: 0.05,
|
||
GatewayIdentityMinSamples: 100,
|
||
GatewayIdentityMaxUnresolvedRatio: 0.20,
|
||
GatewayIdentityCacheEntriesMax: 300000,
|
||
GatewayIdentitySnapshotRequired: 1,
|
||
GatewayIdentitySnapshotMaxAgeSec: 180,
|
||
GatewayIdentityLocationTouchFailureMax: 0,
|
||
GatewayIdentityRegistrationWriteQueueDepthMax: 10000,
|
||
GatewayIdentityRegistrationWriteQueueMaxUsageRatio: 0.80,
|
||
GatewayIdentityRegistrationWriteErrorMax: 0,
|
||
GatewayIdentityStaleMinSamples: 100,
|
||
GatewayIdentityMaxStaleCacheRatio: 0.05,
|
||
StatTopicMinBridge: 1000,
|
||
RawFanoutMinBridge: 1000,
|
||
LastActivityStaleSec: 300,
|
||
}
|
||
}
|
||
|
||
func DefaultRequiredConsumerTopics() map[string][]string {
|
||
return map[string][]string{
|
||
"fields-projector": {
|
||
topics.RawGB32960,
|
||
topics.RawJT808,
|
||
topics.RawYutongMQTT,
|
||
},
|
||
"history": {
|
||
topics.RawGB32960,
|
||
topics.RawJT808,
|
||
topics.RawYutongMQTT,
|
||
},
|
||
"realtime": {
|
||
topics.RawGB32960,
|
||
topics.RawJT808,
|
||
topics.RawYutongMQTT,
|
||
},
|
||
"stat": {
|
||
topics.FieldsGB32960,
|
||
topics.FieldsJT808,
|
||
topics.FieldsYutongMQTT,
|
||
},
|
||
"identity": {
|
||
topics.RawJT808,
|
||
},
|
||
}
|
||
}
|
||
|
||
func Evaluate(metricsByService map[string]string) Report {
|
||
return EvaluateWithThresholds(metricsByService, DefaultThresholds())
|
||
}
|
||
|
||
func EvaluateWithThresholds(metricsByService map[string]string, thresholds Thresholds) Report {
|
||
report := Report{
|
||
Status: StatusOK,
|
||
CheckedAt: time.Now().UTC(),
|
||
Services: make([]ServiceCheck, 0, len(metricsByService)),
|
||
}
|
||
parsedByService := make(map[string]parsedMetricSet, len(metricsByService))
|
||
var names []string
|
||
for name := range metricsByService {
|
||
names = append(names, name)
|
||
}
|
||
sort.Strings(names)
|
||
for _, name := range names {
|
||
parsed := parseMetricSet(metricsByService[name])
|
||
parsedByService[name] = parsed
|
||
metrics := parsed.totals
|
||
addLastActivityAges(metrics, report.CheckedAt)
|
||
report.Services = append(report.Services, ServiceCheck{Name: name, Metrics: metrics})
|
||
report.Totals.ActiveConnections += int64(metrics["vehicle_gateway_active_connections"])
|
||
report.Totals.KafkaLag += metrics["vehicle_history_kafka_lag"]
|
||
report.Totals.KafkaLag += metrics["vehicle_stat_kafka_lag"]
|
||
report.Totals.KafkaLag += metrics["vehicle_realtime_kafka_lag"]
|
||
report.Totals.KafkaLag += metrics["vehicle_identity_writer_kafka_lag"]
|
||
report.Totals.BridgeConsumerPending += metrics["vehicle_bridge_nats_consumer_pending"]
|
||
report.Totals.BridgeAckPending += metrics["vehicle_bridge_nats_consumer_ack_pending"]
|
||
report.Totals.BridgeBatchPendingMessages += metrics["vehicle_bridge_batch_pending_messages"]
|
||
report.Totals.FieldsProjectorKafkaLag += metrics["vehicle_fields_projector_kafka_lag"]
|
||
report.Totals.FieldsProjectorBatchPending += metrics["vehicle_fields_projector_batch_pending_messages"]
|
||
report.Totals.FieldsProjectorRetryPending += metrics["vehicle_fields_projector_retry_pending_messages"]
|
||
report.Totals.FastWriterConsumerPending += metrics["vehicle_fast_writer_nats_consumer_pending"]
|
||
report.Totals.FastWriterAckPending += metrics["vehicle_fast_writer_nats_consumer_ack_pending"]
|
||
report.Totals.FastWriterBatchPending += metrics["vehicle_fast_writer_batch_pending_messages"]
|
||
report.Totals.HistoryBatchPending += metrics["vehicle_history_batch_pending_messages"]
|
||
report.Totals.HistoryRowsPending += metrics["vehicle_history_batch_pending_rows"]
|
||
report.Totals.HistoryRetryPending += metrics["vehicle_history_retry_pending_messages"]
|
||
report.Totals.RealtimeBatchPending += metrics["vehicle_realtime_batch_pending_messages"]
|
||
report.Totals.RealtimeAsyncQueuePending += metrics["vehicle_realtime_async_queue_depth"]
|
||
report.Totals.RealtimeRetryPending += metrics["vehicle_realtime_retry_pending_messages"]
|
||
report.Totals.StatRetryPending += metrics["vehicle_stat_retry_pending_messages"]
|
||
report.Totals.IdentityBatchPending += metrics["vehicle_identity_writer_batch_pending_messages"]
|
||
report.Totals.IdentityFactsPending += metrics["vehicle_identity_writer_batch_pending_facts"]
|
||
report.Totals.IdentityRetryPending += metrics["vehicle_identity_writer_retry_pending_messages"]
|
||
report.Totals.JT808RegistrationWriteQueuePending += gatewayIdentityCacheMetric(parsed, "registration_write_queue", "vehicle_gateway_identity_cache_entries")
|
||
report.Totals.JT808RegistrationWriteErrors += jt808RegistrationWriteErrors(parsed)
|
||
report.Findings = append(report.Findings, findingsForService(name, metrics)...)
|
||
report.Findings = append(report.Findings, findingsForMetrics(name, metrics, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRuntimeConfig(name, parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForAsyncSinkQueueUsage(name, parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForAsyncSinkErrors(name, parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForDurableSpool(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForDurableOutbox(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForHistograms(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForKafkaConsumerCompletion(name, parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForMessageContractMismatches(name, parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForBridgeRouteErrors(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForBridgeKafkaWriteErrors(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForBridgeMessageCompletion(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFieldsProjector(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForHistoryInvalidJSON(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForHistoryLocationErrors(parsed, thresholds, report.CheckedAt)...)
|
||
report.Findings = append(report.Findings, findingsForHistoryLocationAccounting(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForHistoryFallbackErrors(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForWriteRetries(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForHistoryParsedFields(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForStatInvalidJSON(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForStatProtocolTopicMismatches(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForStatSamples(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForStatSources(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForStatProjections(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForStatCache(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFastWriterRedisEnvelopes(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFastWriterRedisFields(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFastWriterInvalidJSON(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFastWriterFallbackErrors(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFastWriterDecoupledUpdateErrors(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForFastWriterMessageCompletion(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRealtimeInvalidJSON(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRealtimeStoreErrors(parsed, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRealtimeAsyncQueue(parsed, thresholds)...)
|
||
}
|
||
report.Findings = append(report.Findings, findingsForGatewayIdentityQuality(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayIdentityCache(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayIdentitySnapshot(parsedByService["gateway"], thresholds, report.CheckedAt)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayIdentityLocationTouchFailures(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayRegistrationWriteQueue(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayRegistrationWriteErrors(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayIdentityStaleCache(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForIdentityWriterOwnership(parsedByService["gateway"], parsedByService["identity"])...)
|
||
report.GatewayFields = buildGatewayFieldsDiagnostics(parsedByService["gateway"], thresholds)
|
||
report.IdentityResolution = buildIdentityResolutionDiagnostics(parsedByService["gateway"])
|
||
report.ProtocolFlow = buildProtocolFlowDiagnostics(parsedByService, thresholds)
|
||
report.PipelineLatency = buildPipelineLatencyDiagnostics(parsedByService, thresholds)
|
||
report.Findings = append(report.Findings, findingsForGatewayParseQuality(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForGatewayResponseQuality(parsedByService["gateway"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRealtimeThrottle(parsedByService["realtime"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRealtimeThrottleCache(parsedByService["realtime"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRealtimePlateCache(parsedByService["realtime"], thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForRequiredConsumerTopics(parsedByService, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForTopicFlow(parsedByService, thresholds)...)
|
||
report.Findings = append(report.Findings, findingsForLastActivity(parsedByService, thresholds, report.CheckedAt)...)
|
||
if len(report.Findings) > 0 {
|
||
report.Status = StatusDegraded
|
||
}
|
||
return report
|
||
}
|
||
|
||
func findingsForService(name string, metrics map[string]float64) []string {
|
||
if metrics["vehicle_service_info"] <= 0 {
|
||
return []string{fmt.Sprintf("%s service info metric missing", name)}
|
||
}
|
||
if requiresKafkaConsumerInfo(name) && metrics["vehicle_kafka_consumer_info"] <= 0 {
|
||
return []string{fmt.Sprintf("%s kafka consumer info metric missing", name)}
|
||
}
|
||
return nil
|
||
}
|
||
|
||
func requiresKafkaConsumerInfo(name string) bool {
|
||
switch name {
|
||
case "history", "stat", "realtime", "fields-projector":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func findingsForMetrics(service string, metrics map[string]float64, thresholds Thresholds) []string {
|
||
var findings []string
|
||
if thresholds.ProcessUptimeMinSec > 0 {
|
||
value, ok := metrics["vehicle_process_uptime_seconds"]
|
||
if !ok {
|
||
findings = append(findings, fmt.Sprintf("%s process uptime metric missing", service))
|
||
} else if value < thresholds.ProcessUptimeMinSec {
|
||
findings = append(findings, fmt.Sprintf("%s process uptime %.0fs below %.0fs", service, value, thresholds.ProcessUptimeMinSec))
|
||
}
|
||
}
|
||
if thresholds.ProcessHeapAllocMaxBytes > 0 {
|
||
value, ok := metrics["vehicle_process_heap_alloc_bytes"]
|
||
if !ok {
|
||
findings = append(findings, fmt.Sprintf("%s process heap alloc metric missing", service))
|
||
} else if value > thresholds.ProcessHeapAllocMaxBytes {
|
||
findings = append(findings, fmt.Sprintf("%s process heap alloc %.0f bytes exceeds %.0f", service, value, thresholds.ProcessHeapAllocMaxBytes))
|
||
}
|
||
}
|
||
if thresholds.ProcessHeapSysMaxBytes > 0 {
|
||
value, ok := metrics["vehicle_process_heap_sys_bytes"]
|
||
if !ok {
|
||
findings = append(findings, fmt.Sprintf("%s process heap sys metric missing", service))
|
||
} else if value > thresholds.ProcessHeapSysMaxBytes {
|
||
findings = append(findings, fmt.Sprintf("%s process heap sys %.0f bytes exceeds %.0f", service, value, thresholds.ProcessHeapSysMaxBytes))
|
||
}
|
||
}
|
||
if thresholds.ProcessGoroutinesMax > 0 {
|
||
value, ok := metrics["vehicle_process_goroutines"]
|
||
if !ok {
|
||
findings = append(findings, fmt.Sprintf("%s process goroutines metric missing", service))
|
||
} else if value > thresholds.ProcessGoroutinesMax {
|
||
findings = append(findings, fmt.Sprintf("%s process goroutines %.0f exceeds %.0f", service, value, thresholds.ProcessGoroutinesMax))
|
||
}
|
||
}
|
||
if value := metrics["vehicle_gateway_connection_rejections_total"]; value > 0 {
|
||
findings = append(findings, fmt.Sprintf("gateway connection rejections %.0f", value))
|
||
}
|
||
if thresholds.AsyncSinkQueueDepthMax > 0 {
|
||
if value := metrics["vehicle_async_sink_queue_depth"]; value > thresholds.AsyncSinkQueueDepthMax {
|
||
findings = append(findings, fmt.Sprintf("async sink queue depth %.0f exceeds %.0f", value, thresholds.AsyncSinkQueueDepthMax))
|
||
}
|
||
}
|
||
if value := metrics["vehicle_bridge_nats_consumer_ack_pending"]; value > 100 {
|
||
findings = append(findings, fmt.Sprintf("bridge ack pending %.0f exceeds 100", value))
|
||
}
|
||
if value := metrics["vehicle_bridge_nats_consumer_pending"]; value > 10_000 {
|
||
findings = append(findings, fmt.Sprintf("bridge consumer pending %.0f exceeds 10000", value))
|
||
}
|
||
if value := metrics["vehicle_bridge_batch_pending_messages"]; value > 1000 {
|
||
findings = append(findings, fmt.Sprintf("bridge batch pending %.0f exceeds 1000", value))
|
||
}
|
||
if value := metrics["vehicle_fast_writer_nats_consumer_ack_pending"]; value > 100 {
|
||
findings = append(findings, fmt.Sprintf("fast writer ack pending %.0f exceeds 100", value))
|
||
}
|
||
if value := metrics["vehicle_fast_writer_nats_consumer_pending"]; value > 10_000 {
|
||
findings = append(findings, fmt.Sprintf("fast writer consumer pending %.0f exceeds 10000", value))
|
||
}
|
||
if value := metrics["vehicle_fast_writer_batch_pending_messages"]; thresholds.FastWriterBatchPendingMax > 0 && value > thresholds.FastWriterBatchPendingMax {
|
||
findings = append(findings, fmt.Sprintf("fast writer batch pending %.0f exceeds %.0f", value, thresholds.FastWriterBatchPendingMax))
|
||
}
|
||
if value := metrics["vehicle_fast_writer_tdengine_enabled"]; thresholds.FastWriterTDengineEnabledMax >= 0 && value > thresholds.FastWriterTDengineEnabledMax {
|
||
findings = append(findings, fmt.Sprintf("fast writer tdengine enabled %.0f exceeds %.0f", value, thresholds.FastWriterTDengineEnabledMax))
|
||
}
|
||
if value := metrics["vehicle_realtime_redis_projector_enabled"]; value > 0 {
|
||
findings = append(findings, fmt.Sprintf("realtime redis projector enabled %.0f", value))
|
||
}
|
||
if value := metrics["vehicle_history_batch_pending_messages"]; thresholds.HistoryBatchPendingMax > 0 && value > thresholds.HistoryBatchPendingMax {
|
||
findings = append(findings, fmt.Sprintf("history batch pending %.0f exceeds %.0f", value, thresholds.HistoryBatchPendingMax))
|
||
}
|
||
if value := metrics["vehicle_history_batch_pending_rows"]; thresholds.HistoryRowsPendingMax > 0 && value > thresholds.HistoryRowsPendingMax {
|
||
findings = append(findings, fmt.Sprintf("history rows pending %.0f exceeds %.0f", value, thresholds.HistoryRowsPendingMax))
|
||
}
|
||
if value := metrics["vehicle_stat_batch_pending_messages"]; thresholds.StatBatchPendingMax > 0 && value > thresholds.StatBatchPendingMax {
|
||
findings = append(findings, fmt.Sprintf("stat batch pending %.0f exceeds %.0f", value, thresholds.StatBatchPendingMax))
|
||
}
|
||
if value := metrics["vehicle_realtime_batch_pending_messages"]; thresholds.RealtimeBatchPendingMax > 0 && value > thresholds.RealtimeBatchPendingMax {
|
||
findings = append(findings, fmt.Sprintf("realtime batch pending %.0f exceeds %.0f", value, thresholds.RealtimeBatchPendingMax))
|
||
}
|
||
if value := metrics["vehicle_realtime_async_queue_depth"]; thresholds.RealtimeAsyncQueueDepthMax > 0 && value > thresholds.RealtimeAsyncQueueDepthMax {
|
||
findings = append(findings, fmt.Sprintf("realtime async queue depth %.0f exceeds %.0f", value, thresholds.RealtimeAsyncQueueDepthMax))
|
||
}
|
||
for _, retry := range []struct {
|
||
name string
|
||
metric string
|
||
}{
|
||
{name: "history", metric: "vehicle_history_retry_pending_messages"},
|
||
{name: "realtime", metric: "vehicle_realtime_retry_pending_messages"},
|
||
{name: "stat", metric: "vehicle_stat_retry_pending_messages"},
|
||
{name: "identity", metric: "vehicle_identity_writer_retry_pending_messages"},
|
||
{name: "fields projector", metric: "vehicle_fields_projector_retry_pending_messages"},
|
||
} {
|
||
if value := metrics[retry.metric]; thresholds.ConsumerRetryPendingMax >= 0 && value > thresholds.ConsumerRetryPendingMax {
|
||
findings = append(findings, fmt.Sprintf("%s retry pending %.0f exceeds %.0f", retry.name, value, thresholds.ConsumerRetryPendingMax))
|
||
}
|
||
}
|
||
if value := metrics["vehicle_history_kafka_lag"] + metrics["vehicle_stat_kafka_lag"] + metrics["vehicle_realtime_kafka_lag"] + metrics["vehicle_identity_writer_kafka_lag"] + metrics["vehicle_fields_projector_kafka_lag"]; thresholds.KafkaLagMax > 0 && value > thresholds.KafkaLagMax {
|
||
findings = append(findings, fmt.Sprintf("kafka lag %.0f exceeds %.0f", value, thresholds.KafkaLagMax))
|
||
}
|
||
if value := metrics["vehicle_identity_writer_batch_pending_messages"]; thresholds.RealtimeBatchPendingMax > 0 && value > thresholds.RealtimeBatchPendingMax {
|
||
findings = append(findings, fmt.Sprintf("identity batch pending %.0f exceeds %.0f", value, thresholds.RealtimeBatchPendingMax))
|
||
}
|
||
if value := metrics["vehicle_fields_projector_batch_pending_messages"]; thresholds.FieldsProjectorBatchPendingMax > 0 && value > thresholds.FieldsProjectorBatchPendingMax {
|
||
findings = append(findings, fmt.Sprintf("fields projector batch pending %.0f exceeds %.0f", value, thresholds.FieldsProjectorBatchPendingMax))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForFieldsProjector(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var invalidJSON float64
|
||
var writeErrors float64
|
||
for _, sample := range parsed.samples {
|
||
switch {
|
||
case sample.name == "vehicle_fields_projector_kafka_messages_total" && sample.labels["status"] == "invalid_json":
|
||
invalidJSON += sample.value
|
||
case sample.name == "vehicle_fields_projector_kafka_writes_total" && sample.labels["status"] == "error":
|
||
writeErrors += sample.value
|
||
}
|
||
}
|
||
var findings []string
|
||
if thresholds.FieldsProjectorInvalidJSONMax >= 0 && invalidJSON > thresholds.FieldsProjectorInvalidJSONMax {
|
||
findings = append(findings, fmt.Sprintf("fields projector invalid json %.0f exceeds %.0f", invalidJSON, thresholds.FieldsProjectorInvalidJSONMax))
|
||
}
|
||
if thresholds.FieldsProjectorWriteErrorMax >= 0 && writeErrors > thresholds.FieldsProjectorWriteErrorMax {
|
||
findings = append(findings, fmt.Sprintf("fields projector kafka write errors %.0f exceeds %.0f", writeErrors, thresholds.FieldsProjectorWriteErrorMax))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForRuntimeConfig(service string, parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
if service == "bridge" || hasServiceInfo(parsed, "nats-kafka-bridge") {
|
||
findings = append(findings, configMaxFinding(parsed, "vehicle_bridge_config", "fetch_wait_ms", "bridge fetch wait", "ms", thresholds.BridgeFetchWaitMaxMS)...)
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_bridge_config", "workers", "bridge workers", "", thresholds.BridgeWorkersMin)...)
|
||
}
|
||
if service == "fields-projector" || hasServiceInfo(parsed, "vehicle-fields-projector") {
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_fields_projector_config", "workers_per_protocol", "fields projector workers per protocol", "", thresholds.FieldsProjectorWorkersPerProtocolMin)...)
|
||
}
|
||
if service == "fast-writer" || hasServiceInfo(parsed, "nats-fast-writer") {
|
||
findings = append(findings, configMaxFinding(parsed, "vehicle_fast_writer_config", "fetch_wait_ms", "fast writer fetch wait", "ms", thresholds.FastWriterFetchWaitMaxMS)...)
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_fast_writer_config", "workers", "fast writer workers", "", thresholds.FastWriterWorkersMin)...)
|
||
}
|
||
if service == "history" || hasServiceInfo(parsed, "vehicle-history-writer") {
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_history_config", "workers", "history workers", "", thresholds.HistoryWorkersMin)...)
|
||
}
|
||
if service == "realtime" || hasServiceInfo(parsed, "vehicle-realtime-writer") {
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_realtime_config", "workers", "realtime workers", "", thresholds.RealtimeWorkersMin)...)
|
||
}
|
||
if service == "stat" || hasServiceInfo(parsed, "vehicle-stat-writer") {
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_stat_config", "workers", "stat workers", "", thresholds.StatWorkersMin)...)
|
||
}
|
||
if service == "identity" || hasServiceInfo(parsed, "vehicle-identity-writer") {
|
||
findings = append(findings, configMinFinding(parsed, "vehicle_identity_writer_config", "workers", "identity workers", "", thresholds.IdentityWorkersMin)...)
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func configMaxFinding(parsed parsedMetricSet, metricName string, setting string, label string, unit string, threshold float64) []string {
|
||
if threshold <= 0 {
|
||
return nil
|
||
}
|
||
value, ok := maxConfigGaugeValue(parsed, metricName, setting)
|
||
if !ok {
|
||
return []string{fmt.Sprintf("%s config metric missing (%s setting=%s)", label, metricName, setting)}
|
||
}
|
||
if value <= threshold {
|
||
return nil
|
||
}
|
||
return []string{fmt.Sprintf("%s %.0f%s exceeds %.0f%s", label, value, unit, threshold, unit)}
|
||
}
|
||
|
||
func configMinFinding(parsed parsedMetricSet, metricName string, setting string, label string, unit string, threshold float64) []string {
|
||
if threshold <= 0 {
|
||
return nil
|
||
}
|
||
value, ok := minConfigGaugeValue(parsed, metricName, setting)
|
||
if !ok {
|
||
return []string{fmt.Sprintf("%s config metric missing (%s setting=%s)", label, metricName, setting)}
|
||
}
|
||
if value >= threshold {
|
||
return nil
|
||
}
|
||
return []string{fmt.Sprintf("%s %.0f%s below %.0f%s", label, value, unit, threshold, unit)}
|
||
}
|
||
|
||
func hasServiceInfo(parsed parsedMetricSet, service string) bool {
|
||
service = strings.TrimSpace(service)
|
||
if service == "" {
|
||
return false
|
||
}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name == "vehicle_service_info" && sample.labels["service"] == service && sample.value > 0 {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func maxConfigGaugeValue(parsed parsedMetricSet, metricName string, setting string) (float64, bool) {
|
||
var out float64
|
||
found := false
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != metricName || sample.labels["setting"] != setting {
|
||
continue
|
||
}
|
||
if !found || sample.value > out {
|
||
out = sample.value
|
||
found = true
|
||
}
|
||
}
|
||
return out, found
|
||
}
|
||
|
||
func minConfigGaugeValue(parsed parsedMetricSet, metricName string, setting string) (float64, bool) {
|
||
var out float64
|
||
found := false
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != metricName || sample.labels["setting"] != setting {
|
||
continue
|
||
}
|
||
if !found || sample.value < out {
|
||
out = sample.value
|
||
found = true
|
||
}
|
||
}
|
||
return out, found
|
||
}
|
||
|
||
func findingsForAsyncSinkQueueUsage(service string, parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.AsyncSinkQueueMaxUsageRatio <= 0 {
|
||
return nil
|
||
}
|
||
type queueSample struct {
|
||
labels map[string]string
|
||
depth float64
|
||
capacity float64
|
||
}
|
||
queues := map[string]*queueSample{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_async_sink_queue_depth":
|
||
key := metricLabelsKey(sample.labels)
|
||
item := queues[key]
|
||
if item == nil {
|
||
item = &queueSample{labels: sample.labels}
|
||
queues[key] = item
|
||
}
|
||
item.depth += sample.value
|
||
case "vehicle_async_sink_queue_capacity":
|
||
key := metricLabelsKey(sample.labels)
|
||
item := queues[key]
|
||
if item == nil {
|
||
item = &queueSample{labels: sample.labels}
|
||
queues[key] = item
|
||
}
|
||
item.capacity += sample.value
|
||
}
|
||
}
|
||
var keys []string
|
||
for key := range queues {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
var findings []string
|
||
for _, key := range keys {
|
||
item := queues[key]
|
||
if item.capacity <= 0 || item.depth <= 0 {
|
||
continue
|
||
}
|
||
ratio := item.depth / item.capacity
|
||
if ratio <= thresholds.AsyncSinkQueueMaxUsageRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s async sink queue usage %.2f exceeds %.2f (%s, depth %.0f, capacity %.0f)",
|
||
service,
|
||
ratio,
|
||
thresholds.AsyncSinkQueueMaxUsageRatio,
|
||
key,
|
||
item.depth,
|
||
item.capacity,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForAsyncSinkErrors(service string, parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
if thresholds.AsyncSinkEnqueueErrorMax >= 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_async_sink_enqueue_total" {
|
||
continue
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
if status == "" || status == "queued" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.AsyncSinkEnqueueErrorMax {
|
||
continue
|
||
}
|
||
sink := strings.TrimSpace(sample.labels["sink"])
|
||
if sink == "" {
|
||
sink = "unknown"
|
||
}
|
||
kind := strings.TrimSpace(sample.labels["kind"])
|
||
if kind == "" {
|
||
kind = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s async sink enqueue %s %.0f exceeds %.0f for sink %s kind %s",
|
||
service,
|
||
status,
|
||
sample.value,
|
||
thresholds.AsyncSinkEnqueueErrorMax,
|
||
sink,
|
||
kind,
|
||
))
|
||
}
|
||
}
|
||
if thresholds.AsyncSinkPublishErrorMax >= 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_async_sink_publish_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.AsyncSinkPublishErrorMax {
|
||
continue
|
||
}
|
||
sink := strings.TrimSpace(sample.labels["sink"])
|
||
if sink == "" {
|
||
sink = "unknown"
|
||
}
|
||
kind := strings.TrimSpace(sample.labels["kind"])
|
||
if kind == "" {
|
||
kind = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s async sink publish errors %.0f exceeds %.0f for sink %s kind %s",
|
||
service,
|
||
sample.value,
|
||
thresholds.AsyncSinkPublishErrorMax,
|
||
sink,
|
||
kind,
|
||
))
|
||
}
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForDurableSpool(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
if thresholds.DurableSpoolBacklogMax > 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_durable_spool_backlog_files" || sample.value <= thresholds.DurableSpoolBacklogMax {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(sample.labels["name"])
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"durable spool backlog %.0f exceeds %.0f for name %s",
|
||
sample.value,
|
||
thresholds.DurableSpoolBacklogMax,
|
||
name,
|
||
))
|
||
}
|
||
}
|
||
if thresholds.DurableSpoolOldestAgeMaxSec > 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_durable_spool_oldest_age_seconds" || sample.value <= thresholds.DurableSpoolOldestAgeMaxSec {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(sample.labels["name"])
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"durable spool oldest age %.0fs exceeds %.0fs for name %s",
|
||
sample.value,
|
||
thresholds.DurableSpoolOldestAgeMaxSec,
|
||
name,
|
||
))
|
||
}
|
||
}
|
||
if thresholds.DurableSpoolErrorMax >= 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_durable_spool_records_total" || sample.labels["status"] != "error" || sample.value <= thresholds.DurableSpoolErrorMax {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(sample.labels["name"])
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
kind := strings.TrimSpace(sample.labels["kind"])
|
||
if kind == "" {
|
||
kind = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"durable spool record errors %.0f exceeds %.0f for name %s kind %s",
|
||
sample.value,
|
||
thresholds.DurableSpoolErrorMax,
|
||
name,
|
||
kind,
|
||
))
|
||
}
|
||
}
|
||
if thresholds.DurableSpoolReplayErrorMax >= 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_durable_spool_replay_total" || !strings.HasSuffix(sample.labels["status"], "_error") || sample.value <= thresholds.DurableSpoolReplayErrorMax {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(sample.labels["name"])
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
if status == "" {
|
||
status = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"durable spool replay %s %.0f exceeds %.0f for name %s",
|
||
status,
|
||
sample.value,
|
||
thresholds.DurableSpoolReplayErrorMax,
|
||
name,
|
||
))
|
||
}
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForDurableOutbox(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
if thresholds.DurableOutboxInflightMax > 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_durable_outbox_inflight" || sample.value <= thresholds.DurableOutboxInflightMax {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(sample.labels["name"])
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"durable outbox inflight %.0f exceeds %.0f for name %s",
|
||
sample.value,
|
||
thresholds.DurableOutboxInflightMax,
|
||
name,
|
||
))
|
||
}
|
||
}
|
||
if thresholds.DurableOutboxErrorMax >= 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_durable_outbox_publish_total" || !durableOutboxErrorStatus(sample.labels["status"]) || sample.value <= thresholds.DurableOutboxErrorMax {
|
||
continue
|
||
}
|
||
name := strings.TrimSpace(sample.labels["name"])
|
||
if name == "" {
|
||
name = "unknown"
|
||
}
|
||
kind := strings.TrimSpace(sample.labels["kind"])
|
||
if kind == "" {
|
||
kind = "unknown"
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
findings = append(findings, fmt.Sprintf(
|
||
"durable outbox %s %.0f exceeds %.0f for name %s kind %s",
|
||
status,
|
||
sample.value,
|
||
thresholds.DurableOutboxErrorMax,
|
||
name,
|
||
kind,
|
||
))
|
||
}
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func durableOutboxErrorStatus(status string) bool {
|
||
switch strings.TrimSpace(status) {
|
||
case "validation_error", "submit_error", "ack_error", "remove_error", "close_timeout", "quarantined":
|
||
return true
|
||
default:
|
||
return false
|
||
}
|
||
}
|
||
|
||
func findingsForHistograms(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_gateway_frame_duration_ms_histogram", "gateway frame", thresholds.GatewayFrameP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_gateway_identity_duration_ms_histogram", "gateway identity", thresholds.GatewayIdentityP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, recentP99GaugeFindings(parsed, "vehicle_gateway_response_e2e_recent_p99_ms", "vehicle_gateway_response_e2e_recent_samples", "gateway response e2e", thresholds.GatewayResponseRecentP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_async_sink_publish_duration_ms_histogram", "async sink publish", thresholds.AsyncSinkP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, recentP99GaugeFindings(parsed, "vehicle_async_sink_queue_wait_recent_p99_ms", "vehicle_async_sink_queue_wait_recent_samples", "async sink queue wait", thresholds.AsyncSinkQueueWaitRecentP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_bridge_batch_duration_ms_histogram", "bridge batch", thresholds.BridgeBatchP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_bridge_kafka_write_duration_ms_histogram", "bridge kafka write", thresholds.BridgeKafkaWriteP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_bridge_kafka_e2e_duration_ms_histogram", "bridge kafka e2e", thresholds.BridgeKafkaE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, recentP99GaugeFindings(parsed, "vehicle_bridge_kafka_e2e_recent_p99_ms", "vehicle_bridge_kafka_e2e_recent_samples", "bridge kafka e2e", thresholds.BridgeKafkaRecentE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_fast_writer_stage_duration_ms_histogram", "fast writer stage", thresholds.FastWriterStageP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_fast_writer_redis_e2e_duration_ms_histogram", "fast writer redis e2e", thresholds.FastWriterRedisE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, recentP99GaugeFindings(parsed, "vehicle_fast_writer_redis_e2e_recent_p99_ms", "vehicle_fast_writer_redis_e2e_recent_samples", "fast writer redis e2e", thresholds.FastWriterRedisRecentE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_history_batch_flush_duration_ms_histogram", "history flush", thresholds.HistoryFlushP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_history_write_e2e_duration_ms_histogram", "history write e2e", thresholds.HistoryWriteE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, recentP99GaugeFindings(parsed, "vehicle_history_write_e2e_recent_p99_ms", "vehicle_history_write_e2e_recent_samples", "history write e2e", thresholds.HistoryWriteRecentE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_realtime_batch_flush_duration_ms_histogram", "realtime batch flush", thresholds.RealtimeBatchP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_realtime_store_update_duration_ms_histogram", "realtime store update", thresholds.RealtimeStoreP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_stat_write_duration_ms_histogram", "stat write", thresholds.StatWriteP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, histogramP99Findings(parsed, "vehicle_stat_write_e2e_duration_ms_histogram", "stat write e2e", thresholds.StatWriteE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
findings = append(findings, recentP99GaugeFindings(parsed, "vehicle_stat_write_e2e_recent_p99_ms", "vehicle_stat_write_e2e_recent_samples", "stat write e2e", thresholds.StatWriteRecentE2EP99MS, thresholds.MinHistogramSamples)...)
|
||
return findings
|
||
}
|
||
|
||
type gatewayPublishCounters struct {
|
||
rawOK float64
|
||
fieldsOK float64
|
||
fieldsPublished float64
|
||
fieldsDelegated float64
|
||
fieldsSkippedNonRealtime float64
|
||
fieldsSkippedMissing float64
|
||
fieldsPublishError float64
|
||
}
|
||
|
||
type gatewayFieldFlowGroup struct {
|
||
gatewayPublishCounters
|
||
latestCount float64
|
||
hasLatestCount bool
|
||
}
|
||
|
||
type gatewayIdentityCounters struct {
|
||
total float64
|
||
resolved float64
|
||
unresolved float64
|
||
}
|
||
|
||
type identityResolutionGroup struct {
|
||
protocol string
|
||
total int64
|
||
skipped int64
|
||
resolved int64
|
||
unresolved int64
|
||
timeout int64
|
||
errorCount int64
|
||
otherNonResolved int64
|
||
staleCache int64
|
||
cacheStatusCounts map[string]int64
|
||
}
|
||
|
||
type pipelineLatencySpec struct {
|
||
service string
|
||
stage string
|
||
metricName string
|
||
samplesMetricName string
|
||
keyLabel string
|
||
thresholdMS float64
|
||
}
|
||
|
||
type gatewayParseCounters struct {
|
||
total float64
|
||
ok float64
|
||
partial float64
|
||
badFrame float64
|
||
other float64
|
||
}
|
||
|
||
type gatewayResponseCounters struct {
|
||
total float64
|
||
ok float64
|
||
buildError float64
|
||
writeError float64
|
||
}
|
||
|
||
type historyParsedFieldCounters struct {
|
||
present float64
|
||
missing float64
|
||
}
|
||
|
||
type kafkaConsumerCompletionCounters struct {
|
||
received float64
|
||
committedOK float64
|
||
commitError float64
|
||
}
|
||
|
||
type kafkaConsumerCompletionSpec struct {
|
||
serviceName string
|
||
messageMetric string
|
||
commitMetric string
|
||
}
|
||
|
||
func findingsForKafkaConsumerCompletion(service string, parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.KafkaConsumerMessageMinReceived <= 0 || thresholds.KafkaConsumerMaxUncommittedRatio <= 0 {
|
||
return nil
|
||
}
|
||
spec, ok := kafkaConsumerCompletionSpecForService(service)
|
||
if !ok {
|
||
return nil
|
||
}
|
||
groups := map[string]*kafkaConsumerCompletionCounters{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case spec.messageMetric, spec.commitMetric:
|
||
default:
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" || topic == "mixed" || topic == "unknown" {
|
||
continue
|
||
}
|
||
group := groups[topic]
|
||
if group == nil {
|
||
group = &kafkaConsumerCompletionCounters{}
|
||
groups[topic] = group
|
||
}
|
||
switch {
|
||
case sample.name == spec.messageMetric && sample.labels["status"] == "received":
|
||
group.received += sample.value
|
||
case sample.name == spec.commitMetric && sample.labels["status"] == "ok":
|
||
group.committedOK += sample.value
|
||
case sample.name == spec.commitMetric && sample.labels["status"] == "error":
|
||
group.commitError += sample.value
|
||
}
|
||
}
|
||
var topics []string
|
||
for topic := range groups {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
group := groups[topic]
|
||
if thresholds.KafkaConsumerCommitErrorMax >= 0 && group.commitError > thresholds.KafkaConsumerCommitErrorMax {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s kafka consumer commit errors %.0f exceeds %.0f for topic %s",
|
||
spec.serviceName,
|
||
group.commitError,
|
||
thresholds.KafkaConsumerCommitErrorMax,
|
||
topic,
|
||
))
|
||
}
|
||
if group.received < thresholds.KafkaConsumerMessageMinReceived {
|
||
continue
|
||
}
|
||
uncommitted := group.received - group.committedOK
|
||
if uncommitted < 0 {
|
||
uncommitted = 0
|
||
}
|
||
ratio := uncommitted / group.received
|
||
if ratio <= thresholds.KafkaConsumerMaxUncommittedRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s kafka consumer uncommitted message ratio %.2f exceeds %.2f for topic %s: received %.0f, commit ok %.0f, commit errors %.0f, uncommitted %.0f",
|
||
spec.serviceName,
|
||
ratio,
|
||
thresholds.KafkaConsumerMaxUncommittedRatio,
|
||
topic,
|
||
group.received,
|
||
group.committedOK,
|
||
group.commitError,
|
||
uncommitted,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func kafkaConsumerCompletionSpecForService(service string) (kafkaConsumerCompletionSpec, bool) {
|
||
switch service {
|
||
case "history":
|
||
return kafkaConsumerCompletionSpec{
|
||
serviceName: "history",
|
||
messageMetric: "vehicle_history_kafka_messages_total",
|
||
commitMetric: "vehicle_history_kafka_commits_total",
|
||
}, true
|
||
case "realtime":
|
||
return kafkaConsumerCompletionSpec{
|
||
serviceName: "realtime",
|
||
messageMetric: "vehicle_realtime_kafka_messages_total",
|
||
commitMetric: "vehicle_realtime_kafka_commits_total",
|
||
}, true
|
||
case "stat":
|
||
return kafkaConsumerCompletionSpec{
|
||
serviceName: "stat",
|
||
messageMetric: "vehicle_stat_kafka_messages_total",
|
||
commitMetric: "vehicle_stat_kafka_commits_total",
|
||
}, true
|
||
case "identity":
|
||
return kafkaConsumerCompletionSpec{
|
||
serviceName: "identity",
|
||
messageMetric: "vehicle_identity_writer_kafka_messages_total",
|
||
commitMetric: "vehicle_identity_writer_kafka_commits_total",
|
||
}, true
|
||
default:
|
||
return kafkaConsumerCompletionSpec{}, false
|
||
}
|
||
}
|
||
|
||
func findingsForMessageContractMismatches(service string, parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.MessageContractMismatchMax < 0 {
|
||
return nil
|
||
}
|
||
specs := messageContractMismatchSpecs(service)
|
||
if len(specs) == 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
if status == "" {
|
||
continue
|
||
}
|
||
for _, spec := range specs {
|
||
if sample.name != spec.metric || status != spec.status {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.MessageContractMismatchMax {
|
||
continue
|
||
}
|
||
key := strings.TrimSpace(sample.labels[spec.keyLabel])
|
||
if key == "" {
|
||
key = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s message contract %s %.0f exceeds %.0f for %s %s",
|
||
spec.serviceLabel,
|
||
status,
|
||
sample.value,
|
||
thresholds.MessageContractMismatchMax,
|
||
spec.keyLabel,
|
||
key,
|
||
))
|
||
}
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
type messageContractMismatchSpec struct {
|
||
serviceLabel string
|
||
metric string
|
||
status string
|
||
keyLabel string
|
||
}
|
||
|
||
func messageContractMismatchSpecs(service string) []messageContractMismatchSpec {
|
||
switch service {
|
||
case "history":
|
||
return []messageContractMismatchSpec{
|
||
{serviceLabel: "history", metric: "vehicle_history_kafka_messages_total", status: "event_kind_mismatch", keyLabel: "topic"},
|
||
{serviceLabel: "history", metric: "vehicle_history_kafka_messages_total", status: "protocol_topic_mismatch", keyLabel: "topic"},
|
||
}
|
||
case "realtime":
|
||
return []messageContractMismatchSpec{
|
||
{serviceLabel: "realtime", metric: "vehicle_realtime_kafka_messages_total", status: "event_kind_mismatch", keyLabel: "topic"},
|
||
{serviceLabel: "realtime", metric: "vehicle_realtime_kafka_messages_total", status: "protocol_topic_mismatch", keyLabel: "topic"},
|
||
}
|
||
case "stat":
|
||
return []messageContractMismatchSpec{
|
||
{serviceLabel: "stat", metric: "vehicle_stat_kafka_messages_total", status: "event_kind_mismatch", keyLabel: "topic"},
|
||
}
|
||
case "identity":
|
||
return []messageContractMismatchSpec{
|
||
{serviceLabel: "identity", metric: "vehicle_identity_writer_kafka_messages_total", status: "event_kind_mismatch", keyLabel: "topic"},
|
||
{serviceLabel: "identity", metric: "vehicle_identity_writer_kafka_messages_total", status: "protocol_topic_mismatch", keyLabel: "topic"},
|
||
}
|
||
case "fast-writer":
|
||
return []messageContractMismatchSpec{
|
||
{serviceLabel: "fast writer", metric: "vehicle_fast_writer_messages_total", status: "event_kind_mismatch", keyLabel: "subject"},
|
||
{serviceLabel: "fast writer", metric: "vehicle_fast_writer_messages_total", status: "protocol_topic_mismatch", keyLabel: "subject"},
|
||
}
|
||
default:
|
||
return nil
|
||
}
|
||
}
|
||
|
||
func findingsForIdentityWriterOwnership(gateway parsedMetricSet, identityWriter parsedMetricSet) []string {
|
||
if len(identityWriter.samples) == 0 {
|
||
return nil
|
||
}
|
||
for _, sample := range gateway.samples {
|
||
if sample.name != "vehicle_gateway_jt808_registration_gateway_writes_enabled" {
|
||
continue
|
||
}
|
||
if sample.value > 0 {
|
||
return []string{"jt808 registration has duplicate writers: gateway writes remain enabled while identity writer is active"}
|
||
}
|
||
return nil
|
||
}
|
||
return []string{"gateway jt808 registration ownership metric missing while identity writer is active"}
|
||
}
|
||
|
||
func findingsForHistoryParsedFields(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.HistoryParsedFieldMinFrames <= 0 || thresholds.HistoryParsedFieldMaxMissingRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*historyParsedFieldCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_history_parsed_fields_total" {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "UNKNOWN"
|
||
}
|
||
key := topic + "\x00" + protocol
|
||
group := groups[key]
|
||
if group == nil {
|
||
group = &historyParsedFieldCounters{}
|
||
groups[key] = group
|
||
}
|
||
switch sample.labels["status"] {
|
||
case "present":
|
||
group.present += sample.value
|
||
case "missing":
|
||
group.missing += sample.value
|
||
}
|
||
}
|
||
var keys []string
|
||
for key := range groups {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
var findings []string
|
||
for _, key := range keys {
|
||
group := groups[key]
|
||
total := group.present + group.missing
|
||
if total < thresholds.HistoryParsedFieldMinFrames || total <= 0 {
|
||
continue
|
||
}
|
||
ratio := group.missing / total
|
||
if ratio <= thresholds.HistoryParsedFieldMaxMissingRatio {
|
||
continue
|
||
}
|
||
topic, protocol, _ := strings.Cut(key, "\x00")
|
||
findings = append(findings, fmt.Sprintf(
|
||
"history parsed fields missing ratio %.2f exceeds %.2f for topic %s protocol %s (total %.0f, present %.0f, missing %.0f)",
|
||
ratio,
|
||
thresholds.HistoryParsedFieldMaxMissingRatio,
|
||
topic,
|
||
protocol,
|
||
total,
|
||
group.present,
|
||
group.missing,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForHistoryInvalidJSON(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.HistoryInvalidJSONMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_history_kafka_messages_total" || sample.labels["status"] != "invalid_json" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.HistoryInvalidJSONMax {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"history invalid json %.0f exceeds %.0f for topic %s",
|
||
sample.value,
|
||
thresholds.HistoryInvalidJSONMax,
|
||
topic,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForRealtimeInvalidJSON(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.RealtimeInvalidJSONMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_realtime_kafka_messages_total" || sample.labels["status"] != "invalid_json" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.RealtimeInvalidJSONMax {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"realtime invalid json %.0f exceeds %.0f for topic %s",
|
||
sample.value,
|
||
thresholds.RealtimeInvalidJSONMax,
|
||
topic,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForRealtimeStoreErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.RealtimeStoreErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_realtime_store_updates_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.RealtimeStoreErrorMax {
|
||
continue
|
||
}
|
||
store := strings.TrimSpace(sample.labels["store"])
|
||
if store == "" {
|
||
store = "unknown"
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "UNKNOWN"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"realtime store update errors %.0f exceeds %.0f for store %s protocol %s",
|
||
sample.value,
|
||
thresholds.RealtimeStoreErrorMax,
|
||
store,
|
||
protocol,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForRealtimeAsyncQueue(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
if thresholds.RealtimeAsyncQueueDroppedMax >= 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_realtime_async_queue_total" {
|
||
continue
|
||
}
|
||
status := sample.labels["status"]
|
||
if status != "dropped" && status != "closed" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.RealtimeAsyncQueueDroppedMax {
|
||
continue
|
||
}
|
||
store := strings.TrimSpace(sample.labels["store"])
|
||
if store == "" {
|
||
store = "unknown"
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "UNKNOWN"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"realtime async queue %s %.0f exceeds %.0f for store %s protocol %s",
|
||
status,
|
||
sample.value,
|
||
thresholds.RealtimeAsyncQueueDroppedMax,
|
||
store,
|
||
protocol,
|
||
))
|
||
}
|
||
}
|
||
if thresholds.RealtimeAsyncQueueMaxUsageRatio <= 0 {
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
type asyncQueueSample struct {
|
||
labels map[string]string
|
||
depth float64
|
||
}
|
||
queues := map[string]*asyncQueueSample{}
|
||
capacityByKey := map[string]float64{}
|
||
capacityByStore := map[string]float64{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_realtime_async_queue_depth":
|
||
key := metricLabelsKey(sample.labels)
|
||
item := queues[key]
|
||
if item == nil {
|
||
item = &asyncQueueSample{labels: sample.labels}
|
||
queues[key] = item
|
||
}
|
||
item.depth += sample.value
|
||
case "vehicle_realtime_async_queue_capacity":
|
||
key := metricLabelsKey(sample.labels)
|
||
capacityByKey[key] += sample.value
|
||
store := strings.TrimSpace(sample.labels["store"])
|
||
if store != "" {
|
||
capacityByStore[store] += sample.value
|
||
}
|
||
}
|
||
}
|
||
var keys []string
|
||
for key := range queues {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, key := range keys {
|
||
item := queues[key]
|
||
capacity := capacityByKey[key]
|
||
if capacity <= 0 {
|
||
capacity = capacityByStore[strings.TrimSpace(item.labels["store"])]
|
||
}
|
||
if capacity <= 0 || item.depth <= 0 {
|
||
continue
|
||
}
|
||
ratio := item.depth / capacity
|
||
if ratio <= thresholds.RealtimeAsyncQueueMaxUsageRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"realtime async queue usage %.2f exceeds %.2f (%s, depth %.0f, capacity %.0f)",
|
||
ratio,
|
||
thresholds.RealtimeAsyncQueueMaxUsageRatio,
|
||
key,
|
||
item.depth,
|
||
capacity,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayResponseQuality(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayResponseMinSamples <= 0 || thresholds.GatewayResponseMaxErrorRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*gatewayResponseCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_response_total" {
|
||
continue
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
group := groups[protocol]
|
||
if group == nil {
|
||
group = &gatewayResponseCounters{}
|
||
groups[protocol] = group
|
||
}
|
||
switch sample.labels["status"] {
|
||
case "ok":
|
||
group.ok += sample.value
|
||
group.total += sample.value
|
||
case "build_error":
|
||
group.buildError += sample.value
|
||
group.total += sample.value
|
||
case "write_error":
|
||
group.writeError += sample.value
|
||
group.total += sample.value
|
||
}
|
||
}
|
||
var protocols []string
|
||
for protocol := range groups {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
var findings []string
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
if group.total < thresholds.GatewayResponseMinSamples || group.total <= 0 {
|
||
continue
|
||
}
|
||
errors := group.buildError + group.writeError
|
||
ratio := errors / group.total
|
||
if ratio <= thresholds.GatewayResponseMaxErrorRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway response error ratio %.2f exceeds %.2f for protocol %s (total %.0f, ok %.0f, errors %.0f, build_error %.0f, write_error %.0f)",
|
||
ratio,
|
||
thresholds.GatewayResponseMaxErrorRatio,
|
||
protocol,
|
||
group.total,
|
||
group.ok,
|
||
errors,
|
||
group.buildError,
|
||
group.writeError,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayParseQuality(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayParseMinFrames <= 0 || thresholds.GatewayParseMaxBadRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*gatewayParseCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_frames_total" {
|
||
continue
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
group := groups[protocol]
|
||
if group == nil {
|
||
group = &gatewayParseCounters{}
|
||
groups[protocol] = group
|
||
}
|
||
group.total += sample.value
|
||
switch envelope.ParseStatus(sample.labels["status"]) {
|
||
case envelope.ParseOK:
|
||
group.ok += sample.value
|
||
case envelope.ParsePartial:
|
||
group.partial += sample.value
|
||
case envelope.ParseBadFrame:
|
||
group.badFrame += sample.value
|
||
default:
|
||
group.other += sample.value
|
||
}
|
||
}
|
||
var protocols []string
|
||
for protocol := range groups {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
var findings []string
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
if group.total < thresholds.GatewayParseMinFrames || group.total <= 0 {
|
||
continue
|
||
}
|
||
bad := group.partial + group.badFrame + group.other
|
||
ratio := bad / group.total
|
||
if ratio <= thresholds.GatewayParseMaxBadRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway parse bad ratio %.2f exceeds %.2f for protocol %s (total %.0f, ok %.0f, bad %.0f, partial %.0f, bad_frame %.0f, other %.0f)",
|
||
ratio,
|
||
thresholds.GatewayParseMaxBadRatio,
|
||
protocol,
|
||
group.total,
|
||
group.ok,
|
||
bad,
|
||
group.partial,
|
||
group.badFrame,
|
||
group.other,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayIdentityQuality(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayIdentityMinSamples <= 0 || thresholds.GatewayIdentityMaxUnresolvedRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*gatewayIdentityCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_identity_total" {
|
||
continue
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
group := groups[protocol]
|
||
if group == nil {
|
||
group = &gatewayIdentityCounters{}
|
||
groups[protocol] = group
|
||
}
|
||
group.total += sample.value
|
||
if sample.labels["status"] == "resolved" {
|
||
group.resolved += sample.value
|
||
} else {
|
||
group.unresolved += sample.value
|
||
}
|
||
}
|
||
var protocols []string
|
||
for protocol := range groups {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
var findings []string
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
if group.total < thresholds.GatewayIdentityMinSamples || group.total <= 0 {
|
||
continue
|
||
}
|
||
ratio := group.unresolved / group.total
|
||
if ratio <= thresholds.GatewayIdentityMaxUnresolvedRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway identity unresolved ratio %.2f exceeds %.2f for protocol %s (total %.0f, unresolved %.0f, resolved %.0f)",
|
||
ratio,
|
||
thresholds.GatewayIdentityMaxUnresolvedRatio,
|
||
protocol,
|
||
group.total,
|
||
group.unresolved,
|
||
group.resolved,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func buildIdentityResolutionDiagnostics(parsed parsedMetricSet) *IdentityResolutionDiagnostics {
|
||
groups := map[string]*identityResolutionGroup{}
|
||
cacheEntries := map[string]int64{}
|
||
issues := map[string]IdentityIssueCount{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_gateway_identity_total":
|
||
protocol := normalizedMetricLabel(sample.labels, "protocol")
|
||
status := normalizedMetricLabel(sample.labels, "status")
|
||
group := identityResolutionGroupForProtocol(groups, protocol)
|
||
value := metricCount(sample.value)
|
||
group.total += value
|
||
switch status {
|
||
case "resolved":
|
||
group.resolved += value
|
||
case "unresolved":
|
||
group.unresolved += value
|
||
case "timeout":
|
||
group.timeout += value
|
||
case "error":
|
||
group.errorCount += value
|
||
default:
|
||
group.otherNonResolved += value
|
||
}
|
||
case "vehicle_gateway_identity_skips_total":
|
||
protocol := normalizedMetricLabel(sample.labels, "protocol")
|
||
group := identityResolutionGroupForProtocol(groups, protocol)
|
||
group.skipped += metricCount(sample.value)
|
||
case "vehicle_gateway_identity_issues_total":
|
||
issue := IdentityIssueCount{
|
||
Protocol: normalizedMetricLabel(sample.labels, "protocol"),
|
||
Status: normalizedMetricLabel(sample.labels, "status"),
|
||
MessageID: normalizedMetricLabel(sample.labels, "message_id"),
|
||
Reason: normalizedMetricLabel(sample.labels, "reason"),
|
||
Count: metricCount(sample.value),
|
||
}
|
||
if issue.Count <= 0 {
|
||
continue
|
||
}
|
||
key := strings.Join([]string{issue.Protocol, issue.Status, issue.MessageID, issue.Reason}, "\x00")
|
||
existing := issues[key]
|
||
if existing.Count == 0 {
|
||
existing = issue
|
||
} else {
|
||
existing.Count += issue.Count
|
||
}
|
||
issues[key] = existing
|
||
case "vehicle_gateway_identity_cache_total":
|
||
protocol := normalizedMetricLabel(sample.labels, "protocol")
|
||
cacheStatus := normalizedMetricLabel(sample.labels, "cache_status")
|
||
group := identityResolutionGroupForProtocol(groups, protocol)
|
||
value := metricCount(sample.value)
|
||
if group.cacheStatusCounts == nil {
|
||
group.cacheStatusCounts = map[string]int64{}
|
||
}
|
||
group.cacheStatusCounts[cacheStatus] += value
|
||
if cacheStatus == "stale" {
|
||
group.staleCache += value
|
||
}
|
||
case "vehicle_gateway_identity_cache_entries":
|
||
cacheName := normalizedMetricLabel(sample.labels, "cache")
|
||
cacheEntries[cacheName] += metricCount(sample.value)
|
||
}
|
||
}
|
||
if len(groups) == 0 && len(cacheEntries) == 0 && len(issues) == 0 {
|
||
return nil
|
||
}
|
||
diagnostics := &IdentityResolutionDiagnostics{}
|
||
if len(cacheEntries) > 0 {
|
||
diagnostics.CacheEntries = cacheEntries
|
||
}
|
||
if len(issues) > 0 {
|
||
diagnostics.Issues = sortedIdentityIssues(issues)
|
||
}
|
||
var protocols []string
|
||
for protocol := range groups {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
row := identityProtocolResolution(group)
|
||
diagnostics.Total += row.Total
|
||
diagnostics.SkippedCount += row.SkippedCount
|
||
diagnostics.ResolvedCount += row.ResolvedCount
|
||
diagnostics.NonResolvedCount += row.NonResolvedCount
|
||
diagnostics.UnresolvedCount += row.UnresolvedCount
|
||
diagnostics.TimeoutCount += row.TimeoutCount
|
||
diagnostics.ErrorCount += row.ErrorCount
|
||
diagnostics.OtherNonResolvedCount += row.OtherNonResolvedCount
|
||
diagnostics.StaleCacheCount += row.StaleCacheCount
|
||
diagnostics.Protocols = append(diagnostics.Protocols, row)
|
||
}
|
||
diagnostics.NonResolvedRatio = ratioInt64(diagnostics.NonResolvedCount, diagnostics.Total)
|
||
diagnostics.StaleCacheRatio = ratioInt64(diagnostics.StaleCacheCount, diagnostics.Total)
|
||
return diagnostics
|
||
}
|
||
|
||
func sortedIdentityIssues(issues map[string]IdentityIssueCount) []IdentityIssueCount {
|
||
rows := make([]IdentityIssueCount, 0, len(issues))
|
||
for _, row := range issues {
|
||
rows = append(rows, row)
|
||
}
|
||
sort.Slice(rows, func(i, j int) bool {
|
||
if rows[i].Protocol != rows[j].Protocol {
|
||
return rows[i].Protocol < rows[j].Protocol
|
||
}
|
||
if rows[i].Status != rows[j].Status {
|
||
return rows[i].Status < rows[j].Status
|
||
}
|
||
if rows[i].MessageID != rows[j].MessageID {
|
||
return rows[i].MessageID < rows[j].MessageID
|
||
}
|
||
return rows[i].Reason < rows[j].Reason
|
||
})
|
||
return rows
|
||
}
|
||
|
||
func identityProtocolResolution(group *identityResolutionGroup) IdentityProtocolResolution {
|
||
if group == nil {
|
||
return IdentityProtocolResolution{Protocol: "unknown"}
|
||
}
|
||
nonResolved := group.unresolved + group.timeout + group.errorCount + group.otherNonResolved
|
||
row := IdentityProtocolResolution{
|
||
Protocol: group.protocol,
|
||
Total: group.total,
|
||
SkippedCount: group.skipped,
|
||
ResolvedCount: group.resolved,
|
||
NonResolvedCount: nonResolved,
|
||
UnresolvedCount: group.unresolved,
|
||
TimeoutCount: group.timeout,
|
||
ErrorCount: group.errorCount,
|
||
OtherNonResolvedCount: group.otherNonResolved,
|
||
NonResolvedRatio: ratioInt64(nonResolved, group.total),
|
||
StaleCacheCount: group.staleCache,
|
||
StaleCacheRatio: ratioInt64(group.staleCache, group.total),
|
||
CacheStatusCounts: group.cacheStatusCounts,
|
||
}
|
||
return row
|
||
}
|
||
|
||
func identityResolutionGroupForProtocol(groups map[string]*identityResolutionGroup, protocol string) *identityResolutionGroup {
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
group := groups[protocol]
|
||
if group == nil {
|
||
group = &identityResolutionGroup{protocol: protocol}
|
||
groups[protocol] = group
|
||
}
|
||
return group
|
||
}
|
||
|
||
func normalizedMetricLabel(labels map[string]string, key string) string {
|
||
value := strings.TrimSpace(labels[key])
|
||
if value == "" {
|
||
return "unknown"
|
||
}
|
||
return value
|
||
}
|
||
|
||
func metricCount(value float64) int64 {
|
||
if value <= 0 {
|
||
return 0
|
||
}
|
||
return int64(math.Round(value))
|
||
}
|
||
|
||
func ratioInt64(numerator int64, denominator int64) float64 {
|
||
if denominator <= 0 {
|
||
return 0
|
||
}
|
||
return float64(numerator) / float64(denominator)
|
||
}
|
||
|
||
func buildPipelineLatencyDiagnostics(parsedByService map[string]parsedMetricSet, thresholds Thresholds) *PipelineLatencyDiagnostics {
|
||
specs := []pipelineLatencySpec{
|
||
{
|
||
service: "gateway",
|
||
stage: "gateway_protocol_response_e2e",
|
||
metricName: "vehicle_gateway_response_e2e_recent_p99_ms",
|
||
samplesMetricName: "vehicle_gateway_response_e2e_recent_samples",
|
||
keyLabel: "protocol",
|
||
thresholdMS: thresholds.GatewayResponseRecentP99MS,
|
||
},
|
||
{
|
||
service: "bridge",
|
||
stage: "bridge_kafka_e2e",
|
||
metricName: "vehicle_bridge_kafka_e2e_recent_p99_ms",
|
||
samplesMetricName: "vehicle_bridge_kafka_e2e_recent_samples",
|
||
keyLabel: "topic",
|
||
thresholdMS: thresholds.BridgeKafkaRecentE2EP99MS,
|
||
},
|
||
{
|
||
service: "fast-writer",
|
||
stage: "fast_writer_redis_e2e",
|
||
metricName: "vehicle_fast_writer_redis_e2e_recent_p99_ms",
|
||
samplesMetricName: "vehicle_fast_writer_redis_e2e_recent_samples",
|
||
keyLabel: "subject",
|
||
thresholdMS: thresholds.FastWriterRedisRecentE2EP99MS,
|
||
},
|
||
{
|
||
service: "history",
|
||
stage: "history_tdengine_write_e2e",
|
||
metricName: "vehicle_history_write_e2e_recent_p99_ms",
|
||
samplesMetricName: "vehicle_history_write_e2e_recent_samples",
|
||
keyLabel: "topic",
|
||
thresholdMS: thresholds.HistoryWriteRecentE2EP99MS,
|
||
},
|
||
{
|
||
service: "stat",
|
||
stage: "stat_mysql_write_e2e",
|
||
metricName: "vehicle_stat_write_e2e_recent_p99_ms",
|
||
samplesMetricName: "vehicle_stat_write_e2e_recent_samples",
|
||
keyLabel: "topic",
|
||
thresholdMS: thresholds.StatWriteRecentE2EP99MS,
|
||
},
|
||
}
|
||
var items []PipelineLatencyItem
|
||
for _, spec := range specs {
|
||
items = append(items, pipelineLatencyItemsForSpec(parsedByService[spec.service], spec, thresholds.MinHistogramSamples)...)
|
||
}
|
||
if len(items) == 0 {
|
||
return nil
|
||
}
|
||
sort.Slice(items, func(i, j int) bool {
|
||
if items[i].Stage != items[j].Stage {
|
||
return items[i].Stage < items[j].Stage
|
||
}
|
||
if items[i].Service != items[j].Service {
|
||
return items[i].Service < items[j].Service
|
||
}
|
||
if items[i].Topic != items[j].Topic {
|
||
return items[i].Topic < items[j].Topic
|
||
}
|
||
return items[i].Subject < items[j].Subject
|
||
})
|
||
diagnostics := &PipelineLatencyDiagnostics{Items: items}
|
||
for _, item := range items {
|
||
diagnostics.TotalSamples += item.Samples
|
||
if item.P99MS > diagnostics.MaxP99MS {
|
||
diagnostics.MaxP99MS = item.P99MS
|
||
}
|
||
}
|
||
return diagnostics
|
||
}
|
||
|
||
func pipelineLatencyItemsForSpec(parsed parsedMetricSet, spec pipelineLatencySpec, minSamples float64) []PipelineLatencyItem {
|
||
samplesByKey := map[string]float64{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != spec.samplesMetricName {
|
||
continue
|
||
}
|
||
samplesByKey[metricLabelsKey(sample.labels)] = sample.value
|
||
}
|
||
var items []PipelineLatencyItem
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != spec.metricName {
|
||
continue
|
||
}
|
||
key := metricLabelsKey(sample.labels)
|
||
row := PipelineLatencyItem{
|
||
Stage: spec.stage,
|
||
Service: spec.service,
|
||
P99MS: sample.value,
|
||
Samples: metricCount(samplesByKey[key]),
|
||
ThresholdMS: spec.thresholdMS,
|
||
}
|
||
switch spec.keyLabel {
|
||
case "subject":
|
||
row.Subject = normalizedMetricLabel(sample.labels, spec.keyLabel)
|
||
default:
|
||
row.Topic = normalizedMetricLabel(sample.labels, spec.keyLabel)
|
||
}
|
||
row.Status = pipelineLatencyStatus(row.P99MS, float64(row.Samples), spec.thresholdMS, minSamples)
|
||
items = append(items, row)
|
||
}
|
||
return items
|
||
}
|
||
|
||
func pipelineLatencyStatus(p99MS float64, samples float64, thresholdMS float64, minSamples float64) string {
|
||
if minSamples > 0 && samples < minSamples {
|
||
return "insufficient_samples"
|
||
}
|
||
if thresholdMS > 0 && p99MS > thresholdMS {
|
||
return "degraded"
|
||
}
|
||
return "ok"
|
||
}
|
||
|
||
func findingsForGatewayIdentityCache(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayIdentityCacheEntriesMax <= 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_identity_cache_entries" || sample.value <= thresholds.GatewayIdentityCacheEntriesMax {
|
||
continue
|
||
}
|
||
cacheName := strings.TrimSpace(sample.labels["cache"])
|
||
if cacheName == "" {
|
||
cacheName = "unknown"
|
||
}
|
||
if cacheName == "registration_write_queue" {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway identity cache entries %.0f exceeds %.0f for cache %s",
|
||
sample.value,
|
||
thresholds.GatewayIdentityCacheEntriesMax,
|
||
cacheName,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayIdentitySnapshot(parsed parsedMetricSet, thresholds Thresholds, checkedAt time.Time) []string {
|
||
if thresholds.GatewayIdentitySnapshotRequired <= 0 && thresholds.GatewayIdentitySnapshotMaxAgeSec <= 0 {
|
||
return nil
|
||
}
|
||
if !hasServiceInfo(parsed, "vehicle-gateway") {
|
||
return nil
|
||
}
|
||
ready, readyFound := parsed.totals["vehicle_gateway_identity_snapshot_ready"]
|
||
var findings []string
|
||
if thresholds.GatewayIdentitySnapshotRequired > 0 {
|
||
switch {
|
||
case !readyFound:
|
||
findings = append(findings, "gateway identity snapshot readiness metric missing")
|
||
case ready < thresholds.GatewayIdentitySnapshotRequired:
|
||
findings = append(findings, fmt.Sprintf("gateway identity snapshot ready %.0f below %.0f", ready, thresholds.GatewayIdentitySnapshotRequired))
|
||
}
|
||
}
|
||
if thresholds.GatewayIdentitySnapshotMaxAgeSec <= 0 || !readyFound || ready <= 0 {
|
||
return findings
|
||
}
|
||
lastSuccess, ok := parsed.totals["vehicle_gateway_identity_snapshot_last_success_unix_seconds"]
|
||
if !ok || lastSuccess <= 0 {
|
||
return append(findings, "gateway identity snapshot last-success metric missing")
|
||
}
|
||
age := checkedAt.Unix() - int64(lastSuccess)
|
||
if age < 0 {
|
||
age = 0
|
||
}
|
||
if float64(age) > thresholds.GatewayIdentitySnapshotMaxAgeSec {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway identity snapshot age %ds exceeds %.0fs",
|
||
age,
|
||
thresholds.GatewayIdentitySnapshotMaxAgeSec,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayRegistrationWriteQueue(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
depth := gatewayIdentityCacheMetric(parsed, "registration_write_queue", "vehicle_gateway_identity_cache_entries")
|
||
capacity := gatewayIdentityCacheMetric(parsed, "registration_write_queue", "vehicle_gateway_identity_cache_max_entries")
|
||
var findings []string
|
||
if thresholds.GatewayIdentityRegistrationWriteQueueDepthMax > 0 && depth > thresholds.GatewayIdentityRegistrationWriteQueueDepthMax {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway jt808 registration write queue depth %.0f exceeds %.0f",
|
||
depth,
|
||
thresholds.GatewayIdentityRegistrationWriteQueueDepthMax,
|
||
))
|
||
}
|
||
if thresholds.GatewayIdentityRegistrationWriteQueueMaxUsageRatio > 0 && depth > 0 && capacity > 0 {
|
||
ratio := depth / capacity
|
||
if ratio > thresholds.GatewayIdentityRegistrationWriteQueueMaxUsageRatio {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway jt808 registration write queue usage %.2f exceeds %.2f (depth %.0f, capacity %.0f)",
|
||
ratio,
|
||
thresholds.GatewayIdentityRegistrationWriteQueueMaxUsageRatio,
|
||
depth,
|
||
capacity,
|
||
))
|
||
}
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func gatewayIdentityCacheMetric(parsed parsedMetricSet, cacheName string, metricName string) float64 {
|
||
cacheName = strings.TrimSpace(cacheName)
|
||
if cacheName == "" || metricName == "" {
|
||
return 0
|
||
}
|
||
var total float64
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != metricName || strings.TrimSpace(sample.labels["cache"]) != cacheName {
|
||
continue
|
||
}
|
||
total += sample.value
|
||
}
|
||
return total
|
||
}
|
||
|
||
func findingsForGatewayRegistrationWriteErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayIdentityRegistrationWriteErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_jt808_registration_write_total" {
|
||
continue
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
if status == "" || status == "ok" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.GatewayIdentityRegistrationWriteErrorMax {
|
||
continue
|
||
}
|
||
mode := strings.TrimSpace(sample.labels["mode"])
|
||
if mode == "" {
|
||
mode = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway jt808 registration write %s count %.0f exceeds %.0f for mode %s",
|
||
status,
|
||
sample.value,
|
||
thresholds.GatewayIdentityRegistrationWriteErrorMax,
|
||
mode,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func jt808RegistrationWriteErrors(parsed parsedMetricSet) float64 {
|
||
var total float64
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_jt808_registration_write_total" {
|
||
continue
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
if status == "" || status == "ok" {
|
||
continue
|
||
}
|
||
total += sample.value
|
||
}
|
||
return total
|
||
}
|
||
|
||
func findingsForGatewayIdentityLocationTouchFailures(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayIdentityLocationTouchFailureMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_gateway_identity_cache_entries" || strings.TrimSpace(sample.labels["cache"]) != "location_touch_failure" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.GatewayIdentityLocationTouchFailureMax {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway identity location touch failure cache %.0f exceeds %.0f; jt808 registration writes are failing or in protective backoff",
|
||
sample.value,
|
||
thresholds.GatewayIdentityLocationTouchFailureMax,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayIdentityStaleCache(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayIdentityStaleMinSamples <= 0 || thresholds.GatewayIdentityMaxStaleCacheRatio <= 0 {
|
||
return nil
|
||
}
|
||
totalsByProtocol := map[string]float64{}
|
||
staleByProtocol := map[string]float64{}
|
||
for _, sample := range parsed.samples {
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
switch sample.name {
|
||
case "vehicle_gateway_identity_total":
|
||
totalsByProtocol[protocol] += sample.value
|
||
case "vehicle_gateway_identity_cache_total":
|
||
if sample.labels["cache_status"] == "stale" {
|
||
staleByProtocol[protocol] += sample.value
|
||
}
|
||
}
|
||
}
|
||
var protocols []string
|
||
for protocol := range totalsByProtocol {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
var findings []string
|
||
for _, protocol := range protocols {
|
||
total := totalsByProtocol[protocol]
|
||
if total < thresholds.GatewayIdentityStaleMinSamples || total <= 0 {
|
||
continue
|
||
}
|
||
stale := staleByProtocol[protocol]
|
||
ratio := stale / total
|
||
if ratio <= thresholds.GatewayIdentityMaxStaleCacheRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway identity stale cache ratio %.2f exceeds %.2f for protocol %s (total %.0f, stale %.0f)",
|
||
ratio,
|
||
thresholds.GatewayIdentityMaxStaleCacheRatio,
|
||
protocol,
|
||
total,
|
||
stale,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForBridgeRouteErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.BridgeRouteErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_bridge_messages_total" || sample.labels["status"] != "route_error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.BridgeRouteErrorMax {
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" {
|
||
subject = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"bridge route errors %.0f exceeds %.0f for subject %s",
|
||
sample.value,
|
||
thresholds.BridgeRouteErrorMax,
|
||
subject,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForBridgeKafkaWriteErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.BridgeKafkaWriteErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_bridge_kafka_writes_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.BridgeKafkaWriteErrorMax {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"bridge kafka write errors %.0f exceeds %.0f for topic %s",
|
||
sample.value,
|
||
thresholds.BridgeKafkaWriteErrorMax,
|
||
topic,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
type bridgeMessageCounters struct {
|
||
received float64
|
||
ackOK float64
|
||
droppedRouteError float64
|
||
}
|
||
|
||
func findingsForBridgeMessageCompletion(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.BridgeMessageMinReceived <= 0 || thresholds.BridgeMessageMaxUnackedRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*bridgeMessageCounters{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_bridge_messages_total", "vehicle_bridge_nats_acks_total":
|
||
default:
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" || subject == "mixed" || subject == "unknown" {
|
||
continue
|
||
}
|
||
group := groups[subject]
|
||
if group == nil {
|
||
group = &bridgeMessageCounters{}
|
||
groups[subject] = group
|
||
}
|
||
switch {
|
||
case sample.name == "vehicle_bridge_messages_total" && sample.labels["status"] == "received":
|
||
group.received += sample.value
|
||
case sample.name == "vehicle_bridge_nats_acks_total" && sample.labels["status"] == "ok":
|
||
group.ackOK += sample.value
|
||
case sample.name == "vehicle_bridge_nats_acks_total" && sample.labels["status"] == "dropped_route_error":
|
||
group.droppedRouteError += sample.value
|
||
}
|
||
}
|
||
var subjects []string
|
||
for subject := range groups {
|
||
subjects = append(subjects, subject)
|
||
}
|
||
sort.Strings(subjects)
|
||
var findings []string
|
||
for _, subject := range subjects {
|
||
group := groups[subject]
|
||
if group.received < thresholds.BridgeMessageMinReceived {
|
||
continue
|
||
}
|
||
completed := group.ackOK + group.droppedRouteError
|
||
unacked := group.received - completed
|
||
if unacked < 0 {
|
||
unacked = 0
|
||
}
|
||
ratio := unacked / group.received
|
||
if ratio <= thresholds.BridgeMessageMaxUnackedRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"bridge unacked message ratio %.2f exceeds %.2f for subject %s: received %.0f, ack ok %.0f, dropped route error %.0f, unacked %.0f",
|
||
ratio,
|
||
thresholds.BridgeMessageMaxUnackedRatio,
|
||
subject,
|
||
group.received,
|
||
group.ackOK,
|
||
group.droppedRouteError,
|
||
unacked,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForTopicFlow(parsedByService map[string]parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
findings = append(findings, findingsForGatewayFieldFlow(parsedByService["gateway"], thresholds)...)
|
||
findings = append(findings, findingsForGatewayFieldCounts(parsedByService["gateway"], thresholds)...)
|
||
findings = append(findings, findingsForGatewayToBridgeFlow(parsedByService["gateway"], parsedByService["bridge"], thresholds)...)
|
||
findings = append(findings, findingsForGatewayToFastWriterFlow(parsedByService["gateway"], parsedByService["fast-writer"], thresholds)...)
|
||
findings = append(findings, findingsForBridgeRawFanout(parsedByService["bridge"], parsedByService["history"], parsedByService["realtime"], thresholds)...)
|
||
findings = append(findings, findingsForBridgeToStatFieldFlow(parsedByService["bridge"], parsedByService["stat"], thresholds)...)
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayFieldFlow(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayFieldMinRaw <= 0 {
|
||
return nil
|
||
}
|
||
groups := gatewayFieldFlowGroups(parsed)
|
||
protocols := gatewayFieldFlowProtocols(groups)
|
||
var findings []string
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
realtimeCandidates := gatewayRealtimeCandidates(group.gatewayPublishCounters)
|
||
if realtimeCandidates < thresholds.GatewayFieldMinRaw {
|
||
continue
|
||
}
|
||
if group.fieldsOK <= 0 {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway fields missing for protocol %s: raw ok %.0f, realtime candidates %.0f, fields ok %.0f, skipped non-realtime %.0f, skipped missing fields %.0f, publish errors %.0f",
|
||
protocol,
|
||
group.rawOK,
|
||
realtimeCandidates,
|
||
group.fieldsOK,
|
||
group.fieldsSkippedNonRealtime,
|
||
group.fieldsSkippedMissing,
|
||
group.fieldsPublishError,
|
||
))
|
||
continue
|
||
}
|
||
missingRatio := (group.fieldsSkippedMissing + group.fieldsPublishError) / realtimeCandidates
|
||
if thresholds.GatewayFieldMaxMissingRatio > 0 && missingRatio > thresholds.GatewayFieldMaxMissingRatio {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway fields missing ratio %.2f exceeds %.2f for protocol %s: realtime candidates %.0f, fields ok %.0f, skipped missing fields %.0f, publish errors %.0f",
|
||
missingRatio,
|
||
thresholds.GatewayFieldMaxMissingRatio,
|
||
protocol,
|
||
realtimeCandidates,
|
||
group.fieldsOK,
|
||
group.fieldsSkippedMissing,
|
||
group.fieldsPublishError,
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForGatewayFieldCounts(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayFieldMinCount <= 0 {
|
||
return nil
|
||
}
|
||
minPublished := thresholds.GatewayFieldMinRaw
|
||
if minPublished <= 0 {
|
||
minPublished = 1
|
||
}
|
||
groups := gatewayFieldFlowGroups(parsed)
|
||
protocols := gatewayFieldFlowProtocols(groups)
|
||
var findings []string
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
fieldsAccepted := group.fieldsPublished + group.fieldsDelegated
|
||
if fieldsAccepted < minPublished {
|
||
continue
|
||
}
|
||
if !group.hasLatestCount {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway fields count metric missing for protocol %s after %.0f published fields events",
|
||
protocol,
|
||
fieldsAccepted,
|
||
))
|
||
continue
|
||
}
|
||
if group.latestCount < thresholds.GatewayFieldMinCount {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway fields count %.0f below %.0f for protocol %s after %.0f published fields events",
|
||
group.latestCount,
|
||
thresholds.GatewayFieldMinCount,
|
||
protocol,
|
||
fieldsAccepted,
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func buildGatewayFieldsDiagnostics(parsed parsedMetricSet, thresholds Thresholds) *GatewayFieldsDiagnostics {
|
||
groups := gatewayFieldFlowGroups(parsed)
|
||
protocols := gatewayFieldFlowProtocols(groups)
|
||
if len(protocols) == 0 {
|
||
return nil
|
||
}
|
||
out := &GatewayFieldsDiagnostics{Protocols: make([]GatewayProtocolFields, 0, len(protocols))}
|
||
for _, protocol := range protocols {
|
||
group := groups[protocol]
|
||
realtimeCandidates := gatewayRealtimeCandidates(group.gatewayPublishCounters)
|
||
missingRatio := 0.0
|
||
if realtimeCandidates > 0 {
|
||
missingRatio = (group.fieldsSkippedMissing + group.fieldsPublishError) / realtimeCandidates
|
||
}
|
||
status, reason := gatewayFieldDiagnosticsStatus(group, realtimeCandidates, thresholds)
|
||
var latestCount *float64
|
||
if group.hasLatestCount {
|
||
value := group.latestCount
|
||
latestCount = &value
|
||
}
|
||
out.Protocols = append(out.Protocols, GatewayProtocolFields{
|
||
Protocol: protocol,
|
||
RawOK: group.rawOK,
|
||
RealtimeCandidates: realtimeCandidates,
|
||
FieldsOK: group.fieldsOK,
|
||
FieldsPublished: group.fieldsPublished,
|
||
FieldsDelegated: group.fieldsDelegated,
|
||
FieldsSkippedNonRealtime: group.fieldsSkippedNonRealtime,
|
||
FieldsSkippedMissing: group.fieldsSkippedMissing,
|
||
FieldsPublishError: group.fieldsPublishError,
|
||
MissingRatio: missingRatio,
|
||
LatestFieldCount: latestCount,
|
||
Status: status,
|
||
Reason: reason,
|
||
})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func gatewayFieldDiagnosticsStatus(group *gatewayFieldFlowGroup, realtimeCandidates float64, thresholds Thresholds) (string, string) {
|
||
if thresholds.GatewayFieldMinRaw > 0 && realtimeCandidates >= thresholds.GatewayFieldMinRaw {
|
||
if group.fieldsOK <= 0 {
|
||
return "degraded", "missing_fields_publish"
|
||
}
|
||
missingRatio := 0.0
|
||
if realtimeCandidates > 0 {
|
||
missingRatio = (group.fieldsSkippedMissing + group.fieldsPublishError) / realtimeCandidates
|
||
}
|
||
if thresholds.GatewayFieldMaxMissingRatio > 0 && missingRatio > thresholds.GatewayFieldMaxMissingRatio {
|
||
return "degraded", "high_missing_ratio"
|
||
}
|
||
}
|
||
minPublished := thresholds.GatewayFieldMinRaw
|
||
if minPublished <= 0 {
|
||
minPublished = 1
|
||
}
|
||
if thresholds.GatewayFieldMinCount > 0 && group.fieldsPublished+group.fieldsDelegated >= minPublished {
|
||
if !group.hasLatestCount {
|
||
return "degraded", "missing_field_count_metric"
|
||
}
|
||
if group.latestCount < thresholds.GatewayFieldMinCount {
|
||
return "degraded", "low_field_count"
|
||
}
|
||
}
|
||
return "ok", ""
|
||
}
|
||
|
||
func gatewayFieldFlowGroups(parsed parsedMetricSet) map[string]*gatewayFieldFlowGroup {
|
||
groups := map[string]*gatewayFieldFlowGroup{}
|
||
groupForProtocol := func(protocol string) *gatewayFieldFlowGroup {
|
||
protocol = strings.TrimSpace(protocol)
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
group := groups[protocol]
|
||
if group == nil {
|
||
group = &gatewayFieldFlowGroup{}
|
||
groups[protocol] = group
|
||
}
|
||
return group
|
||
}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_gateway_publish_total":
|
||
if sample.labels["status"] != "ok" && sample.labels["status"] != "delegated" {
|
||
continue
|
||
}
|
||
group := groupForProtocol(sample.labels["protocol"])
|
||
switch sample.labels["kind"] {
|
||
case "raw":
|
||
group.rawOK += sample.value
|
||
case "fields":
|
||
group.fieldsOK += sample.value
|
||
}
|
||
case "vehicle_gateway_fields_total":
|
||
group := groupForProtocol(sample.labels["protocol"])
|
||
switch sample.labels["status"] {
|
||
case "published":
|
||
group.fieldsPublished += sample.value
|
||
case "delegated_to_bridge":
|
||
group.fieldsDelegated += sample.value
|
||
case "skipped_non_realtime":
|
||
group.fieldsSkippedNonRealtime += sample.value
|
||
case "skipped_missing_fields":
|
||
group.fieldsSkippedMissing += sample.value
|
||
case "publish_error":
|
||
group.fieldsPublishError += sample.value
|
||
}
|
||
case "vehicle_gateway_fields_count":
|
||
if sample.labels["status"] != "published" && sample.labels["status"] != "delegated_to_bridge" {
|
||
continue
|
||
}
|
||
group := groupForProtocol(sample.labels["protocol"])
|
||
group.latestCount = sample.value
|
||
group.hasLatestCount = true
|
||
}
|
||
}
|
||
return groups
|
||
}
|
||
|
||
func gatewayFieldFlowProtocols(groups map[string]*gatewayFieldFlowGroup) []string {
|
||
var protocols []string
|
||
for protocol := range groups {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
return protocols
|
||
}
|
||
|
||
func gatewayRealtimeCandidates(group gatewayPublishCounters) float64 {
|
||
realtimeCandidates := group.rawOK - group.fieldsSkippedNonRealtime
|
||
if realtimeCandidates < 0 {
|
||
return 0
|
||
}
|
||
return realtimeCandidates
|
||
}
|
||
|
||
func findingsForGatewayToFastWriterFlow(gateway parsedMetricSet, fastWriter parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayFastWriterMinRawPublish <= 0 {
|
||
return nil
|
||
}
|
||
gatewayPublishes := gatewayPublishGroups(gateway.samples)
|
||
fastWriterMessages := metricValuesBySubject(fastWriter.samples, "vehicle_fast_writer_messages_total", "received", "vehicle.raw.go.")
|
||
var findings []string
|
||
for _, group := range gatewayPublishes {
|
||
if group.kind != "raw" || group.count < thresholds.GatewayFastWriterMinRawPublish {
|
||
continue
|
||
}
|
||
subject, ok := expectedKafkaTopicForGatewayPublish(group.protocol, group.kind)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if fastWriterMessages[subject] <= 0 {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer received messages missing for gateway raw publish protocol %s: gateway ok %.0f, expected subject %s, fast writer received %.0f",
|
||
group.protocol,
|
||
group.count,
|
||
subject,
|
||
fastWriterMessages[subject],
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
type gatewayPublishGroup struct {
|
||
protocol string
|
||
kind string
|
||
count float64
|
||
}
|
||
|
||
func findingsForGatewayToBridgeFlow(gateway parsedMetricSet, bridge parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.GatewayBridgeMinPublish <= 0 {
|
||
return nil
|
||
}
|
||
gatewayPublishes := gatewayPublishGroups(gateway.samples)
|
||
bridgeWrites := metricValuesByTopic(bridge.samples, "vehicle_bridge_kafka_writes_total", "ok", "vehicle.")
|
||
var findings []string
|
||
for _, group := range gatewayPublishes {
|
||
if group.count < thresholds.GatewayBridgeMinPublish {
|
||
continue
|
||
}
|
||
topic, ok := expectedKafkaTopicForGatewayPublish(group.protocol, group.kind)
|
||
if !ok {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"gateway %s publish topic mapping missing for protocol %s: gateway ok %.0f",
|
||
group.kind,
|
||
group.protocol,
|
||
group.count,
|
||
))
|
||
continue
|
||
}
|
||
if bridgeWrites[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"bridge writes missing for gateway %s publish protocol %s: gateway ok %.0f, expected topic %s, bridge writes ok %.0f",
|
||
group.kind,
|
||
group.protocol,
|
||
group.count,
|
||
topic,
|
||
bridgeWrites[topic],
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func gatewayPublishGroups(samples []metricSample) []gatewayPublishGroup {
|
||
groups := map[string]*gatewayPublishGroup{}
|
||
for _, sample := range samples {
|
||
if sample.name != "vehicle_gateway_publish_total" || (sample.labels["status"] != "ok" && sample.labels["status"] != "delegated") {
|
||
continue
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
kind := strings.TrimSpace(sample.labels["kind"])
|
||
if kind == "" {
|
||
kind = "unknown"
|
||
}
|
||
key := protocol + "\x00" + kind
|
||
group := groups[key]
|
||
if group == nil {
|
||
group = &gatewayPublishGroup{protocol: protocol, kind: kind}
|
||
groups[key] = group
|
||
}
|
||
group.count += sample.value
|
||
}
|
||
var keys []string
|
||
for key := range groups {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
out := make([]gatewayPublishGroup, 0, len(keys))
|
||
for _, key := range keys {
|
||
out = append(out, *groups[key])
|
||
}
|
||
return out
|
||
}
|
||
|
||
func expectedKafkaTopicForGatewayPublish(protocol string, kind string) (string, bool) {
|
||
switch kind {
|
||
case "raw":
|
||
switch envelope.Protocol(protocol) {
|
||
case envelope.ProtocolGB32960:
|
||
return topics.RawGB32960, true
|
||
case envelope.ProtocolJT808:
|
||
return topics.RawJT808, true
|
||
case envelope.ProtocolYutongMQTT:
|
||
return topics.RawYutongMQTT, true
|
||
default:
|
||
return "", false
|
||
}
|
||
case "fields":
|
||
switch envelope.Protocol(protocol) {
|
||
case envelope.ProtocolGB32960:
|
||
return topics.FieldsGB32960, true
|
||
case envelope.ProtocolJT808:
|
||
return topics.FieldsJT808, true
|
||
case envelope.ProtocolYutongMQTT:
|
||
return topics.FieldsYutongMQTT, true
|
||
default:
|
||
return "", false
|
||
}
|
||
default:
|
||
return "", false
|
||
}
|
||
}
|
||
|
||
func findingsForBridgeRawFanout(bridge parsedMetricSet, history parsedMetricSet, realtime parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.RawFanoutMinBridge <= 0 {
|
||
return nil
|
||
}
|
||
bridgeWrites := metricValuesByTopic(bridge.samples, "vehicle_bridge_kafka_writes_total", "ok", "vehicle.raw.go.")
|
||
historyConsumers := metricValuesByTopic(history.samples, "vehicle_kafka_consumer_info", "", "vehicle.raw.go.")
|
||
historyReceived := metricValuesByTopic(history.samples, "vehicle_history_kafka_messages_total", "received", "vehicle.raw.go.")
|
||
historyWrites := metricValuesByTopic(history.samples, "vehicle_history_writes_total", "ok", "vehicle.raw.go.")
|
||
realtimeConsumers := metricValuesByTopic(realtime.samples, "vehicle_kafka_consumer_info", "", "vehicle.raw.go.")
|
||
realtimeReceived := metricValuesByTopic(realtime.samples, "vehicle_realtime_kafka_messages_total", "received", "vehicle.raw.go.")
|
||
realtimeUpdates := metricValuesByTopic(realtime.samples, "vehicle_realtime_updates_total", "ok", "vehicle.raw.go.")
|
||
var topics []string
|
||
for topic := range bridgeWrites {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
writes := bridgeWrites[topic]
|
||
if writes < thresholds.RawFanoutMinBridge {
|
||
continue
|
||
}
|
||
if historyConsumers[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("history consumer topic missing for %s: bridge raw writes ok %.0f", topic, writes))
|
||
} else if historyReceived[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("history messages missing for %s: bridge raw writes ok %.0f, history received %.0f", topic, writes, historyReceived[topic]))
|
||
} else if historyWrites[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("history writes missing for %s: bridge raw writes ok %.0f, history received %.0f, history writes ok %.0f", topic, writes, historyReceived[topic], historyWrites[topic]))
|
||
}
|
||
if realtimeConsumers[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("realtime consumer topic missing for %s: bridge raw writes ok %.0f", topic, writes))
|
||
} else if realtimeReceived[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("realtime messages missing for %s: bridge raw writes ok %.0f, realtime received %.0f", topic, writes, realtimeReceived[topic]))
|
||
} else if realtimeUpdates[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("realtime updates missing for %s: bridge raw writes ok %.0f, realtime received %.0f, realtime updates ok %.0f", topic, writes, realtimeReceived[topic], realtimeUpdates[topic]))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForBridgeToStatFieldFlow(bridge parsedMetricSet, stat parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatTopicMinBridge <= 0 {
|
||
return nil
|
||
}
|
||
bridgeWrites := metricValuesByTopic(bridge.samples, "vehicle_bridge_kafka_writes_total", "ok", "vehicle.fields.go.")
|
||
statReceived := metricValuesByTopic(stat.samples, "vehicle_stat_kafka_messages_total", "received", "vehicle.fields.go.")
|
||
statConsumers := metricValuesByTopic(stat.samples, "vehicle_kafka_consumer_info", "", "vehicle.fields.go.")
|
||
var topics []string
|
||
for topic := range bridgeWrites {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
writes := bridgeWrites[topic]
|
||
if writes < thresholds.StatTopicMinBridge {
|
||
continue
|
||
}
|
||
if statConsumers[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("stat consumer topic missing for %s: bridge writes ok %.0f", topic, writes))
|
||
continue
|
||
}
|
||
if statReceived[topic] <= 0 {
|
||
findings = append(findings, fmt.Sprintf("stat messages missing for %s: bridge writes ok %.0f, stat received %.0f", topic, writes, statReceived[topic]))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForRequiredConsumerTopics(parsedByService map[string]parsedMetricSet, thresholds Thresholds) []string {
|
||
if len(thresholds.RequiredConsumerTopics) == 0 {
|
||
return nil
|
||
}
|
||
var services []string
|
||
for service := range thresholds.RequiredConsumerTopics {
|
||
services = append(services, service)
|
||
}
|
||
sort.Strings(services)
|
||
var findings []string
|
||
for _, service := range services {
|
||
required := normalizeTopicSet(thresholds.RequiredConsumerTopics[service])
|
||
if len(required) == 0 {
|
||
continue
|
||
}
|
||
seen := kafkaConsumerTopicSet(parsedByService[service])
|
||
var topics []string
|
||
for topic := range required {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
for _, topic := range topics {
|
||
if seen[topic] > 0 {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf("%s required kafka consumer topic missing: %s", service, topic))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func normalizeTopicSet(values []string) map[string]struct{} {
|
||
out := map[string]struct{}{}
|
||
for _, value := range values {
|
||
value = strings.TrimSpace(value)
|
||
if value == "" {
|
||
continue
|
||
}
|
||
out[value] = struct{}{}
|
||
}
|
||
return out
|
||
}
|
||
|
||
func kafkaConsumerTopicSet(parsed parsedMetricSet) map[string]float64 {
|
||
out := map[string]float64{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_kafka_consumer_info" || sample.value <= 0 {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
continue
|
||
}
|
||
out[topic] += sample.value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func metricValuesByTopic(samples []metricSample, metricName string, status string, topicPrefix string) map[string]float64 {
|
||
out := map[string]float64{}
|
||
for _, sample := range samples {
|
||
if sample.name != metricName {
|
||
continue
|
||
}
|
||
if status != "" && sample.labels["status"] != status {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" || (topicPrefix != "" && !strings.HasPrefix(topic, topicPrefix)) {
|
||
continue
|
||
}
|
||
out[topic] += sample.value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func metricValuesBySubject(samples []metricSample, metricName string, status string, subjectPrefix string) map[string]float64 {
|
||
out := map[string]float64{}
|
||
for _, sample := range samples {
|
||
if sample.name != metricName {
|
||
continue
|
||
}
|
||
if status != "" && sample.labels["status"] != status {
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" || (subjectPrefix != "" && !strings.HasPrefix(subject, subjectPrefix)) {
|
||
continue
|
||
}
|
||
out[subject] += sample.value
|
||
}
|
||
return out
|
||
}
|
||
|
||
func findingsForLastActivity(parsedByService map[string]parsedMetricSet, thresholds Thresholds, checkedAt time.Time) []string {
|
||
if thresholds.LastActivityStaleSec <= 0 {
|
||
return nil
|
||
}
|
||
specs := []lastActivitySpec{
|
||
{service: "gateway", metric: "vehicle_gateway_last_frame_unix_seconds", label: "gateway last frame", status: "OK"},
|
||
{service: "gateway", metric: "vehicle_gateway_last_publish_unix_seconds", label: "gateway last publish", status: "ok"},
|
||
{service: "bridge", metric: "vehicle_bridge_last_message_unix_seconds", label: "bridge last message", status: "received"},
|
||
{service: "bridge", metric: "vehicle_bridge_last_kafka_write_unix_seconds", label: "bridge last kafka write", status: "ok"},
|
||
{service: "bridge", metric: "vehicle_bridge_last_ack_unix_seconds", label: "bridge last ack", status: "ok"},
|
||
{service: "fast-writer", metric: "vehicle_fast_writer_last_message_unix_seconds", label: "fast writer last message", status: "received"},
|
||
{service: "fast-writer", metric: "vehicle_fast_writer_last_stage_unix_seconds", label: "fast writer last stage", status: "ok"},
|
||
{service: "history", metric: "vehicle_history_last_message_unix_seconds", label: "history last message", status: "received"},
|
||
{service: "history", metric: "vehicle_history_last_write_unix_seconds", label: "history last write", status: "ok"},
|
||
{service: "history", metric: "vehicle_history_last_commit_unix_seconds", label: "history last commit", status: "ok"},
|
||
{service: "stat", metric: "vehicle_stat_last_message_unix_seconds", label: "stat last message", status: "received"},
|
||
{service: "stat", metric: "vehicle_stat_last_write_unix_seconds", label: "stat last write", status: "ok"},
|
||
{service: "stat", metric: "vehicle_stat_last_commit_unix_seconds", label: "stat last commit", status: "ok"},
|
||
{service: "realtime", metric: "vehicle_realtime_last_message_unix_seconds", label: "realtime last message", status: "received"},
|
||
{service: "realtime", metric: "vehicle_realtime_last_update_unix_seconds", label: "realtime last update", status: "ok"},
|
||
{service: "realtime", metric: "vehicle_realtime_last_store_update_unix_seconds", label: "realtime last store update", status: "ok"},
|
||
{service: "realtime", metric: "vehicle_realtime_last_commit_unix_seconds", label: "realtime last commit", status: "ok"},
|
||
{service: "identity", metric: "vehicle_identity_writer_last_message_unix_seconds", label: "identity last message", status: "received"},
|
||
{service: "identity", metric: "vehicle_identity_writer_last_write_unix_seconds", label: "identity last write", status: "ok"},
|
||
{service: "identity", metric: "vehicle_identity_writer_last_commit_unix_seconds", label: "identity last commit", status: "ok"},
|
||
}
|
||
var findings []string
|
||
for _, spec := range specs {
|
||
findings = append(findings, staleActivityFindings(parsedByService[spec.service], spec, thresholds.LastActivityStaleSec, checkedAt)...)
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
type lastActivitySpec struct {
|
||
service string
|
||
metric string
|
||
label string
|
||
status string
|
||
}
|
||
|
||
func staleActivityFindings(parsed parsedMetricSet, spec lastActivitySpec, thresholdSec float64, checkedAt time.Time) []string {
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != spec.metric {
|
||
continue
|
||
}
|
||
if spec.status != "" && sample.labels["status"] != spec.status {
|
||
continue
|
||
}
|
||
if sample.value <= 0 {
|
||
continue
|
||
}
|
||
if isRollupLastActivity(sample) {
|
||
continue
|
||
}
|
||
ageSec := checkedAt.Sub(time.Unix(int64(sample.value), 0).UTC()).Seconds()
|
||
if ageSec <= thresholdSec {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf("%s stale %.0fs exceeds %.0fs (%s)", spec.label, ageSec, thresholdSec, metricLabelsKey(sample.labels)))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func isRollupLastActivity(sample metricSample) bool {
|
||
for _, label := range []string{"subject", "topic"} {
|
||
switch strings.TrimSpace(sample.labels[label]) {
|
||
case "mixed", "unknown":
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
type statSampleCounters struct {
|
||
writesOK float64
|
||
found float64
|
||
written float64
|
||
skippedMissingFields float64
|
||
skippedMissingVIN float64
|
||
skippedMissingMileage float64
|
||
skippedNonMileageFrame float64
|
||
skippedNonPositiveMileage float64
|
||
skippedMissingTime float64
|
||
futureAdjusted float64
|
||
skippedSameMileage float64
|
||
skippedMissingSource float64
|
||
}
|
||
|
||
type statSourceCounters struct {
|
||
found float64
|
||
attempted float64
|
||
written float64
|
||
skippedThrottled float64
|
||
skippedMissingEndpoint float64
|
||
skippedUnmanaged float64
|
||
}
|
||
|
||
type statProjectionCounters struct {
|
||
samplesWritten float64
|
||
attempted float64
|
||
written float64
|
||
skippedThrottled float64
|
||
}
|
||
|
||
type protocolFlowStatCounters struct {
|
||
samples statSampleCounters
|
||
sources statSourceCounters
|
||
projections statProjectionCounters
|
||
}
|
||
|
||
func buildProtocolFlowDiagnostics(parsedByService map[string]parsedMetricSet, thresholds Thresholds) *ProtocolFlowDiagnostics {
|
||
gatewayGroups := gatewayFieldFlowGroups(parsedByService["gateway"])
|
||
identityDiagnostics := buildIdentityResolutionDiagnostics(parsedByService["gateway"])
|
||
identityByProtocol := map[string]IdentityProtocolResolution{}
|
||
if identityDiagnostics != nil {
|
||
for _, row := range identityDiagnostics.Protocols {
|
||
identityByProtocol[row.Protocol] = row
|
||
}
|
||
}
|
||
statGroups := protocolFlowStatGroups(parsedByService["stat"])
|
||
|
||
protocolSet := map[string]struct{}{}
|
||
for protocol := range gatewayGroups {
|
||
protocolSet[protocol] = struct{}{}
|
||
}
|
||
for protocol := range identityByProtocol {
|
||
protocolSet[protocol] = struct{}{}
|
||
}
|
||
for protocol := range statGroups {
|
||
protocolSet[protocol] = struct{}{}
|
||
}
|
||
if len(protocolSet) == 0 {
|
||
return nil
|
||
}
|
||
protocols := make([]string, 0, len(protocolSet))
|
||
for protocol := range protocolSet {
|
||
protocols = append(protocols, protocol)
|
||
}
|
||
sort.Strings(protocols)
|
||
|
||
out := &ProtocolFlowDiagnostics{Protocols: make([]ProtocolFlowProtocol, 0, len(protocols))}
|
||
for _, protocol := range protocols {
|
||
gatewayGroup := gatewayGroups[protocol]
|
||
statGroup := statGroups[protocol]
|
||
identityRow := identityByProtocol[protocol]
|
||
row := ProtocolFlowProtocol{Protocol: protocol}
|
||
if gatewayGroup != nil {
|
||
row.RawOK = gatewayGroup.rawOK
|
||
row.RealtimeCandidates = gatewayRealtimeCandidates(gatewayGroup.gatewayPublishCounters)
|
||
row.FieldsPublished = gatewayGroup.fieldsPublished
|
||
if gatewayGroup.hasLatestCount {
|
||
value := gatewayGroup.latestCount
|
||
row.LatestFieldCount = &value
|
||
}
|
||
}
|
||
row.IdentityTotal = identityRow.Total
|
||
row.IdentitySkipped = identityRow.SkippedCount
|
||
row.IdentityResolved = identityRow.ResolvedCount
|
||
row.IdentityNonResolved = identityRow.NonResolvedCount
|
||
row.IdentityNonResolvedRatio = identityRow.NonResolvedRatio
|
||
if statGroup != nil {
|
||
row.StatWritesOK = statGroup.samples.writesOK
|
||
row.StatMileageCandidateWrites = statMileageCandidateWrites(statGroup.samples)
|
||
row.StatSamplesFound = statGroup.samples.found
|
||
row.StatSamplesWritten = statGroup.samples.written
|
||
row.StatSamplesActionableSkipped = statActionableSkips(statGroup.samples)
|
||
row.StatSkippedMissingFields = statGroup.samples.skippedMissingFields
|
||
row.StatSkippedMissingVIN = statGroup.samples.skippedMissingVIN
|
||
row.StatSkippedMissingMileage = statGroup.samples.skippedMissingMileage
|
||
row.StatSkippedNonMileageFrame = statGroup.samples.skippedNonMileageFrame
|
||
row.StatSkippedNonPositiveMileage = statGroup.samples.skippedNonPositiveMileage
|
||
row.StatSkippedMissingTime = statGroup.samples.skippedMissingTime
|
||
row.StatEventTimeFutureAdjusted = statGroup.samples.futureAdjusted
|
||
row.StatSkippedMissingSource = statGroup.samples.skippedMissingSource
|
||
row.StatSkippedSameMileage = statGroup.samples.skippedSameMileage
|
||
row.StatSourceWritten = statGroup.sources.written
|
||
row.StatSourceThrottled = statGroup.sources.skippedThrottled
|
||
row.StatSourceMissingEndpoint = statGroup.sources.skippedMissingEndpoint
|
||
row.StatSourceSkippedUnmanaged = statGroup.sources.skippedUnmanaged
|
||
row.StatProjectionAttempted = statGroup.projections.attempted
|
||
row.StatProjectionWritten = statGroup.projections.written
|
||
row.StatProjectionThrottled = statGroup.projections.skippedThrottled
|
||
}
|
||
row.Status, row.Reason = protocolFlowStatus(gatewayGroup, identityRow, statGroup, thresholds)
|
||
out.Protocols = append(out.Protocols, row)
|
||
}
|
||
return out
|
||
}
|
||
|
||
func protocolFlowStatGroups(parsed parsedMetricSet) map[string]*protocolFlowStatCounters {
|
||
groups := map[string]*protocolFlowStatCounters{}
|
||
groupForSample := func(sample metricSample) *protocolFlowStatCounters {
|
||
protocol := protocolForFieldsMetricSample(sample)
|
||
group := groups[protocol]
|
||
if group == nil {
|
||
group = &protocolFlowStatCounters{}
|
||
groups[protocol] = group
|
||
}
|
||
return group
|
||
}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_stat_writes_total":
|
||
if sample.labels["status"] == "ok" {
|
||
groupForSample(sample).samples.writesOK += sample.value
|
||
}
|
||
case "vehicle_stat_samples_total":
|
||
group := groupForSample(sample)
|
||
switch sample.labels["status"] {
|
||
case "found":
|
||
group.samples.found += sample.value
|
||
group.sources.found += sample.value
|
||
case "written":
|
||
group.samples.written += sample.value
|
||
group.projections.samplesWritten += sample.value
|
||
case "skipped_missing_fields":
|
||
group.samples.skippedMissingFields += sample.value
|
||
case "skipped_missing_vin":
|
||
group.samples.skippedMissingVIN += sample.value
|
||
case "skipped_missing_mileage":
|
||
group.samples.skippedMissingMileage += sample.value
|
||
case "skipped_non_mileage_frame":
|
||
group.samples.skippedNonMileageFrame += sample.value
|
||
case "skipped_non_positive_mileage":
|
||
group.samples.skippedNonPositiveMileage += sample.value
|
||
case "skipped_missing_time":
|
||
group.samples.skippedMissingTime += sample.value
|
||
case "event_time_future_adjusted":
|
||
group.samples.futureAdjusted += sample.value
|
||
case "skipped_same_mileage":
|
||
group.samples.skippedSameMileage += sample.value
|
||
case "skipped_missing_source":
|
||
group.samples.skippedMissingSource += sample.value
|
||
}
|
||
case "vehicle_stat_sources_total":
|
||
group := groupForSample(sample)
|
||
switch sample.labels["status"] {
|
||
case "attempted":
|
||
group.sources.attempted += sample.value
|
||
case "written":
|
||
group.sources.written += sample.value
|
||
case "skipped_throttled":
|
||
group.sources.skippedThrottled += sample.value
|
||
case "skipped_missing_endpoint":
|
||
group.sources.skippedMissingEndpoint += sample.value
|
||
case "skipped_unmanaged":
|
||
group.sources.skippedUnmanaged += sample.value
|
||
}
|
||
case "vehicle_stat_projections_total":
|
||
group := groupForSample(sample)
|
||
switch sample.labels["status"] {
|
||
case "attempted":
|
||
group.projections.attempted += sample.value
|
||
case "written":
|
||
group.projections.written += sample.value
|
||
case "skipped_throttled":
|
||
group.projections.skippedThrottled += sample.value
|
||
}
|
||
}
|
||
}
|
||
return groups
|
||
}
|
||
|
||
func protocolForFieldsMetricSample(sample metricSample) string {
|
||
if protocol := strings.TrimSpace(sample.labels["protocol"]); protocol != "" {
|
||
return protocol
|
||
}
|
||
if protocol, ok := topics.ProtocolForKnownFieldsTopic(sample.labels["topic"]); ok {
|
||
return protocol
|
||
}
|
||
return "unknown"
|
||
}
|
||
|
||
func statActionableSkips(group statSampleCounters) float64 {
|
||
return group.skippedMissingFields +
|
||
group.skippedMissingVIN +
|
||
group.skippedMissingMileage +
|
||
group.skippedNonPositiveMileage +
|
||
group.skippedMissingTime +
|
||
group.skippedMissingSource
|
||
}
|
||
|
||
func statOnlyMissingMileageSkips(group statSampleCounters) bool {
|
||
return group.skippedMissingMileage > 0 &&
|
||
group.skippedMissingFields == 0 &&
|
||
group.skippedMissingVIN == 0 &&
|
||
group.skippedNonPositiveMileage == 0 &&
|
||
group.skippedMissingTime == 0 &&
|
||
group.skippedMissingSource == 0
|
||
}
|
||
|
||
func statMileageCandidateWrites(group statSampleCounters) float64 {
|
||
candidates := group.writesOK - group.skippedNonMileageFrame
|
||
if candidates < 0 {
|
||
return 0
|
||
}
|
||
return candidates
|
||
}
|
||
|
||
func protocolFlowStatus(gatewayGroup *gatewayFieldFlowGroup, identityRow IdentityProtocolResolution, statGroup *protocolFlowStatCounters, thresholds Thresholds) (string, string) {
|
||
if gatewayGroup != nil {
|
||
realtimeCandidates := gatewayRealtimeCandidates(gatewayGroup.gatewayPublishCounters)
|
||
status, reason := gatewayFieldDiagnosticsStatus(gatewayGroup, realtimeCandidates, thresholds)
|
||
if status != "ok" {
|
||
return status, "gateway_fields_" + reason
|
||
}
|
||
}
|
||
if thresholds.GatewayIdentityMinSamples > 0 &&
|
||
thresholds.GatewayIdentityMaxUnresolvedRatio > 0 &&
|
||
float64(identityRow.Total) >= thresholds.GatewayIdentityMinSamples &&
|
||
identityRow.NonResolvedRatio > thresholds.GatewayIdentityMaxUnresolvedRatio {
|
||
return "degraded", "identity_unresolved_ratio"
|
||
}
|
||
if statGroup == nil {
|
||
return "ok", ""
|
||
}
|
||
if thresholds.StatSampleMinWrites > 0 && statMileageCandidateWrites(statGroup.samples) >= thresholds.StatSampleMinWrites {
|
||
if thresholds.StatSampleFutureAdjustmentMax >= 0 &&
|
||
statGroup.samples.futureAdjusted > thresholds.StatSampleFutureAdjustmentMax {
|
||
return "degraded", "stat_future_event_time_adjusted"
|
||
}
|
||
if statGroup.samples.found <= 0 {
|
||
if statActionableSkips(statGroup.samples) <= 0 && statGroup.samples.skippedNonMileageFrame > 0 {
|
||
return "ok", ""
|
||
}
|
||
if statOnlyMissingMileageSkips(statGroup.samples) {
|
||
return "ok", ""
|
||
}
|
||
return "degraded", "stat_samples_missing"
|
||
}
|
||
if thresholds.StatSampleMaxActionableSkipRatio > 0 {
|
||
ratio := statActionableSkips(statGroup.samples) / statMileageCandidateWrites(statGroup.samples)
|
||
if ratio > thresholds.StatSampleMaxActionableSkipRatio {
|
||
return "degraded", "stat_actionable_skip_ratio"
|
||
}
|
||
}
|
||
if statGroup.samples.written <= 0 && statGroup.samples.skippedMissingSource > 0 {
|
||
return "degraded", "stat_samples_missing_source"
|
||
}
|
||
}
|
||
if thresholds.StatSourceMinSamples > 0 && statGroup.sources.found >= thresholds.StatSourceMinSamples {
|
||
activeSourceTouches := statGroup.sources.written + statGroup.sources.skippedThrottled + statGroup.sources.skippedUnmanaged
|
||
if activeSourceTouches <= 0 && statGroup.sources.skippedMissingEndpoint <= 0 {
|
||
return "degraded", "stat_source_tracking_missing"
|
||
}
|
||
if thresholds.StatSourceMaxMissingRatio > 0 {
|
||
ratio := statGroup.sources.skippedMissingEndpoint / statGroup.sources.found
|
||
if ratio > thresholds.StatSourceMaxMissingRatio {
|
||
return "degraded", "stat_source_missing_endpoint_ratio"
|
||
}
|
||
}
|
||
}
|
||
if thresholds.StatProjectionMinSampleWrites > 0 &&
|
||
statGroup.projections.samplesWritten >= thresholds.StatProjectionMinSampleWrites &&
|
||
statGroup.projections.written <= 0 {
|
||
return "degraded", "stat_projection_missing"
|
||
}
|
||
return "ok", ""
|
||
}
|
||
|
||
func findingsForStatProjections(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatProjectionMinSampleWrites <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*statProjectionCounters{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_stat_samples_total":
|
||
if sample.labels["status"] == "written" {
|
||
statProjectionGroup(groups, sample.labels["topic"]).samplesWritten += sample.value
|
||
}
|
||
case "vehicle_stat_projections_total":
|
||
group := statProjectionGroup(groups, sample.labels["topic"])
|
||
switch sample.labels["status"] {
|
||
case "attempted":
|
||
group.attempted += sample.value
|
||
case "written":
|
||
group.written += sample.value
|
||
case "skipped_throttled":
|
||
group.skippedThrottled += sample.value
|
||
}
|
||
}
|
||
}
|
||
var topics []string
|
||
for topic := range groups {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
group := groups[topic]
|
||
if group.samplesWritten < thresholds.StatProjectionMinSampleWrites {
|
||
continue
|
||
}
|
||
if group.written > 0 {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat final projection missing for %s: samples written %.0f, projections attempted %.0f, written %.0f, skipped throttled %.0f",
|
||
topic,
|
||
group.samplesWritten,
|
||
group.attempted,
|
||
group.written,
|
||
group.skippedThrottled,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func statProjectionGroup(groups map[string]*statProjectionCounters, topic string) *statProjectionCounters {
|
||
topic = strings.TrimSpace(topic)
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
group := groups[topic]
|
||
if group == nil {
|
||
group = &statProjectionCounters{}
|
||
groups[topic] = group
|
||
}
|
||
return group
|
||
}
|
||
|
||
func findingsForStatCache(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatCacheEntriesMax <= 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_stat_cache_entries" || sample.value <= thresholds.StatCacheEntriesMax {
|
||
continue
|
||
}
|
||
cache := strings.TrimSpace(sample.labels["cache"])
|
||
if cache == "" {
|
||
cache = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat cache entries %.0f exceeds %.0f for cache %s",
|
||
sample.value,
|
||
thresholds.StatCacheEntriesMax,
|
||
cache,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
type realtimeThrottleCounters struct {
|
||
passed float64
|
||
skippedInterval float64
|
||
skippedNonRealtime float64
|
||
skippedMissingVIN float64
|
||
}
|
||
|
||
func findingsForRealtimeThrottle(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.RealtimeThrottleMinSamples <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*realtimeThrottleCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_realtime_throttle_total" {
|
||
continue
|
||
}
|
||
group := realtimeThrottleGroup(groups, sample.labels["store"], sample.labels["protocol"])
|
||
switch sample.labels["status"] {
|
||
case "passed":
|
||
group.passed += sample.value
|
||
case "skipped_interval":
|
||
group.skippedInterval += sample.value
|
||
case "skipped_non_realtime":
|
||
group.skippedNonRealtime += sample.value
|
||
case "skipped_missing_vin":
|
||
group.skippedMissingVIN += sample.value
|
||
}
|
||
}
|
||
var keys []string
|
||
for key := range groups {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
var findings []string
|
||
for _, key := range keys {
|
||
group := groups[key]
|
||
total := group.passed + group.skippedInterval + group.skippedNonRealtime + group.skippedMissingVIN
|
||
if total < thresholds.RealtimeThrottleMinSamples || group.skippedInterval <= 0 || group.passed > 0 {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"realtime throttle passing updates missing for %s: total %.0f, passed %.0f, skipped interval %.0f, skipped non-realtime %.0f, skipped missing vin %.0f",
|
||
key,
|
||
total,
|
||
group.passed,
|
||
group.skippedInterval,
|
||
group.skippedNonRealtime,
|
||
group.skippedMissingVIN,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForRealtimeThrottleCache(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.RealtimeThrottleCacheEntriesMax <= 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_realtime_throttle_cache_entries" || sample.value <= thresholds.RealtimeThrottleCacheEntriesMax {
|
||
continue
|
||
}
|
||
store := strings.TrimSpace(sample.labels["store"])
|
||
if store == "" {
|
||
store = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"realtime throttle cache entries %.0f exceeds %.0f for store %s",
|
||
sample.value,
|
||
thresholds.RealtimeThrottleCacheEntriesMax,
|
||
store,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForRealtimePlateCache(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.RealtimePlateCacheEntriesMax <= 0 {
|
||
return nil
|
||
}
|
||
value := parsed.totals["vehicle_realtime_plate_cache_entries"]
|
||
if value <= thresholds.RealtimePlateCacheEntriesMax {
|
||
return nil
|
||
}
|
||
return []string{fmt.Sprintf(
|
||
"realtime plate cache entries %.0f exceeds %.0f",
|
||
value,
|
||
thresholds.RealtimePlateCacheEntriesMax,
|
||
)}
|
||
}
|
||
|
||
func realtimeThrottleGroup(groups map[string]*realtimeThrottleCounters, store string, protocol string) *realtimeThrottleCounters {
|
||
store = strings.TrimSpace(store)
|
||
if store == "" {
|
||
store = "unknown"
|
||
}
|
||
protocol = strings.TrimSpace(protocol)
|
||
if protocol == "" {
|
||
protocol = "unknown"
|
||
}
|
||
key := "store=" + store + ",protocol=" + protocol
|
||
group := groups[key]
|
||
if group == nil {
|
||
group = &realtimeThrottleCounters{}
|
||
groups[key] = group
|
||
}
|
||
return group
|
||
}
|
||
|
||
type fastWriterRedisFieldCounters struct {
|
||
seen float64
|
||
written float64
|
||
skippedStale float64
|
||
}
|
||
|
||
type fastWriterRedisEnvelopeCounters struct {
|
||
seen float64
|
||
updated float64
|
||
skippedNonRealtime float64
|
||
skippedMissingVIN float64
|
||
skippedMissingVehicleKey float64
|
||
skippedMissingFields float64
|
||
}
|
||
|
||
type fastWriterMessageCounters struct {
|
||
received float64
|
||
ok float64
|
||
invalidJSON float64
|
||
}
|
||
|
||
func findingsForFastWriterMessageCompletion(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.FastWriterMessageMinReceived <= 0 || thresholds.FastWriterMessageMaxUnackedRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*fastWriterMessageCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_fast_writer_messages_total" {
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" || subject == "mixed" || subject == "unknown" {
|
||
continue
|
||
}
|
||
group := groups[subject]
|
||
if group == nil {
|
||
group = &fastWriterMessageCounters{}
|
||
groups[subject] = group
|
||
}
|
||
switch sample.labels["status"] {
|
||
case "received":
|
||
group.received += sample.value
|
||
case "ok":
|
||
group.ok += sample.value
|
||
case "invalid_json":
|
||
group.invalidJSON += sample.value
|
||
}
|
||
}
|
||
var subjects []string
|
||
for subject := range groups {
|
||
subjects = append(subjects, subject)
|
||
}
|
||
sort.Strings(subjects)
|
||
var findings []string
|
||
for _, subject := range subjects {
|
||
group := groups[subject]
|
||
if group.received < thresholds.FastWriterMessageMinReceived {
|
||
continue
|
||
}
|
||
completed := group.ok + group.invalidJSON
|
||
unacked := group.received - completed
|
||
if unacked < 0 {
|
||
unacked = 0
|
||
}
|
||
ratio := unacked / group.received
|
||
if ratio <= thresholds.FastWriterMessageMaxUnackedRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer unacked message ratio %.2f exceeds %.2f for subject %s: received %.0f, ok %.0f, invalid_json %.0f, unacked %.0f",
|
||
ratio,
|
||
thresholds.FastWriterMessageMaxUnackedRatio,
|
||
subject,
|
||
group.received,
|
||
group.ok,
|
||
group.invalidJSON,
|
||
unacked,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForFastWriterRedisEnvelopes(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.FastWriterRedisEnvelopeMinSeen <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*fastWriterRedisEnvelopeCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_fast_writer_redis_envelopes_total" {
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" || subject == "mixed" || subject == "unknown" {
|
||
continue
|
||
}
|
||
group := groups[subject]
|
||
if group == nil {
|
||
group = &fastWriterRedisEnvelopeCounters{}
|
||
groups[subject] = group
|
||
}
|
||
switch sample.labels["status"] {
|
||
case "seen":
|
||
group.seen += sample.value
|
||
case "updated":
|
||
group.updated += sample.value
|
||
case "skipped_non_realtime":
|
||
group.skippedNonRealtime += sample.value
|
||
case "skipped_missing_vin":
|
||
group.skippedMissingVIN += sample.value
|
||
case "skipped_missing_vehicle_key":
|
||
group.skippedMissingVehicleKey += sample.value
|
||
case "skipped_missing_fields":
|
||
group.skippedMissingFields += sample.value
|
||
}
|
||
}
|
||
var subjects []string
|
||
for subject := range groups {
|
||
subjects = append(subjects, subject)
|
||
}
|
||
sort.Strings(subjects)
|
||
var findings []string
|
||
for _, subject := range subjects {
|
||
group := groups[subject]
|
||
if group.seen < thresholds.FastWriterRedisEnvelopeMinSeen {
|
||
continue
|
||
}
|
||
if group.updated <= 0 {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer redis realtime updates missing for subject %s: envelopes seen %.0f, updated %.0f, skipped non-realtime %.0f, missing vin %.0f, missing vehicle key %.0f, missing parsed fields %.0f",
|
||
subject,
|
||
group.seen,
|
||
group.updated,
|
||
group.skippedNonRealtime,
|
||
group.skippedMissingVIN,
|
||
group.skippedMissingVehicleKey,
|
||
group.skippedMissingFields,
|
||
))
|
||
continue
|
||
}
|
||
if thresholds.FastWriterRedisEnvelopeMaxBadRatio <= 0 {
|
||
continue
|
||
}
|
||
actionableSkips := group.skippedMissingVIN + group.skippedMissingVehicleKey + group.skippedMissingFields
|
||
ratio := actionableSkips / group.seen
|
||
if ratio <= thresholds.FastWriterRedisEnvelopeMaxBadRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer redis envelope skip ratio %.2f exceeds %.2f for subject %s: envelopes seen %.0f, updated %.0f, missing vin %.0f, missing vehicle key %.0f, missing parsed fields %.0f",
|
||
ratio,
|
||
thresholds.FastWriterRedisEnvelopeMaxBadRatio,
|
||
subject,
|
||
group.seen,
|
||
group.updated,
|
||
group.skippedMissingVIN,
|
||
group.skippedMissingVehicleKey,
|
||
group.skippedMissingFields,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForFastWriterRedisFields(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.FastWriterRedisFieldMinSeen <= 0 || thresholds.FastWriterRedisFieldMaxStaleRatio <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*fastWriterRedisFieldCounters{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_fast_writer_redis_fields_total" {
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" || subject == "mixed" || subject == "unknown" {
|
||
continue
|
||
}
|
||
group := groups[subject]
|
||
if group == nil {
|
||
group = &fastWriterRedisFieldCounters{}
|
||
groups[subject] = group
|
||
}
|
||
switch sample.labels["status"] {
|
||
case "seen":
|
||
group.seen += sample.value
|
||
case "written":
|
||
group.written += sample.value
|
||
case "skipped_stale":
|
||
group.skippedStale += sample.value
|
||
}
|
||
}
|
||
var subjects []string
|
||
for subject := range groups {
|
||
subjects = append(subjects, subject)
|
||
}
|
||
sort.Strings(subjects)
|
||
var findings []string
|
||
for _, subject := range subjects {
|
||
group := groups[subject]
|
||
if group.seen < thresholds.FastWriterRedisFieldMinSeen || group.seen <= 0 {
|
||
continue
|
||
}
|
||
ratio := group.skippedStale / group.seen
|
||
if ratio <= thresholds.FastWriterRedisFieldMaxStaleRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer redis stale field ratio %.2f exceeds %.2f for subject %s (seen %.0f, written %.0f, skipped_stale %.0f)",
|
||
ratio,
|
||
thresholds.FastWriterRedisFieldMaxStaleRatio,
|
||
subject,
|
||
group.seen,
|
||
group.written,
|
||
group.skippedStale,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForHistoryLocationErrors(parsed parsedMetricSet, thresholds Thresholds, checkedAt time.Time) []string {
|
||
if thresholds.HistoryLocationErrorMax < 0 {
|
||
return nil
|
||
}
|
||
lastErrorByTopic := map[string]float64{}
|
||
if thresholds.HistoryLocationErrorRecentSec > 0 {
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_history_last_location_write_unix_seconds" || sample.labels["status"] != "error" || sample.value <= 0 {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if sample.value > lastErrorByTopic[topic] {
|
||
lastErrorByTopic[topic] = sample.value
|
||
}
|
||
}
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_history_location_writes_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.HistoryLocationErrorMax {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
recency := ""
|
||
if thresholds.HistoryLocationErrorRecentSec > 0 {
|
||
if lastError, ok := lastErrorByTopic[topic]; ok {
|
||
ageSec := checkedAt.Sub(time.Unix(int64(lastError), 0).UTC()).Seconds()
|
||
if ageSec < 0 {
|
||
ageSec = 0
|
||
}
|
||
if ageSec > thresholds.HistoryLocationErrorRecentSec {
|
||
continue
|
||
}
|
||
recency = fmt.Sprintf(", last error %.0fs ago", ageSec)
|
||
} else {
|
||
recency = ", last error activity unavailable"
|
||
}
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"history location writes error %.0f exceeds %.0f for topic %s%s",
|
||
sample.value,
|
||
thresholds.HistoryLocationErrorMax,
|
||
topic,
|
||
recency,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForHistoryLocationAccounting(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.HistoryLocationAccountingMaxRatio <= 0 {
|
||
return nil
|
||
}
|
||
writesOKByTopic := map[string]float64{}
|
||
locationStatusByTopic := map[string]float64{}
|
||
for _, sample := range parsed.samples {
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" || topic == "mixed" || topic == "unknown" {
|
||
continue
|
||
}
|
||
switch {
|
||
case sample.name == "vehicle_history_writes_total" && sample.labels["status"] == "ok":
|
||
writesOKByTopic[topic] += sample.value
|
||
case sample.name == "vehicle_history_location_writes_total":
|
||
locationStatusByTopic[topic] += sample.value
|
||
}
|
||
}
|
||
var topics []string
|
||
for topic := range writesOKByTopic {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
writesOK := writesOKByTopic[topic]
|
||
if writesOK < thresholds.KafkaConsumerMessageMinReceived {
|
||
continue
|
||
}
|
||
accounted := locationStatusByTopic[topic]
|
||
diff := math.Abs(writesOK - accounted)
|
||
ratio := diff / writesOK
|
||
if ratio <= thresholds.HistoryLocationAccountingMaxRatio {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"history location accounting mismatch ratio %.2f exceeds %.2f for topic %s: history writes ok %.0f, location statuses %.0f, diff %.0f",
|
||
ratio,
|
||
thresholds.HistoryLocationAccountingMaxRatio,
|
||
topic,
|
||
writesOK,
|
||
accounted,
|
||
diff,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForHistoryFallbackErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.HistoryFallbackErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_history_batch_fallback_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.HistoryFallbackErrorMax {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"history batch fallback errors %.0f exceeds %.0f",
|
||
sample.value,
|
||
thresholds.HistoryFallbackErrorMax,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForFastWriterFallbackErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.FastWriterFallbackErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_fast_writer_batch_fallback_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.FastWriterFallbackErrorMax {
|
||
continue
|
||
}
|
||
stage := strings.TrimSpace(sample.labels["stage"])
|
||
if stage == "" {
|
||
stage = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer batch fallback errors %.0f exceeds %.0f for stage %s",
|
||
sample.value,
|
||
thresholds.FastWriterFallbackErrorMax,
|
||
stage,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForFastWriterDecoupledUpdateErrors(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.FastWriterDecoupledUpdateErrorMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_fast_writer_decoupled_updates_total" || sample.labels["status"] != "error" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.FastWriterDecoupledUpdateErrorMax {
|
||
continue
|
||
}
|
||
reason := strings.TrimSpace(sample.labels["reason"])
|
||
if reason == "" {
|
||
reason = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer decoupled update errors %.0f exceeds %.0f for reason %s",
|
||
sample.value,
|
||
thresholds.FastWriterDecoupledUpdateErrorMax,
|
||
reason,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForWriteRetries(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
findings = append(findings, writeRetryFindings(parsed, "vehicle_history_write_retries_total", "history", thresholds.HistoryRetryMax, thresholds.HistoryRetryExhaustedMax)...)
|
||
findings = append(findings, writeRetryFindings(parsed, "vehicle_stat_write_retries_total", "stat", thresholds.StatRetryMax, thresholds.StatRetryExhaustedMax)...)
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func writeRetryFindings(parsed parsedMetricSet, metricName string, serviceLabel string, retryMax float64, exhaustedMax float64) []string {
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != metricName {
|
||
continue
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
operation := strings.TrimSpace(sample.labels["operation"])
|
||
if operation == "" {
|
||
operation = "unknown"
|
||
}
|
||
switch status {
|
||
case "retry":
|
||
if retryMax < 0 || sample.value <= retryMax {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s write retries %.0f exceeds %.0f for operation %s",
|
||
serviceLabel,
|
||
sample.value,
|
||
retryMax,
|
||
operation,
|
||
))
|
||
case "exhausted":
|
||
if exhaustedMax < 0 || sample.value <= exhaustedMax {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"%s write retry exhausted %.0f exceeds %.0f for operation %s",
|
||
serviceLabel,
|
||
sample.value,
|
||
exhaustedMax,
|
||
operation,
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForStatInvalidJSON(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatInvalidJSONMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_stat_kafka_messages_total" || sample.labels["status"] != "invalid_json" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.StatInvalidJSONMax {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat invalid json %.0f exceeds %.0f for topic %s",
|
||
sample.value,
|
||
thresholds.StatInvalidJSONMax,
|
||
topic,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForStatProtocolTopicMismatches(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatProtocolTopicMismatchMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_stat_kafka_messages_total" || sample.labels["status"] != "protocol_topic_mismatch" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.StatProtocolTopicMismatchMax {
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat protocol topic mismatches %.0f exceeds %.0f for topic %s",
|
||
sample.value,
|
||
thresholds.StatProtocolTopicMismatchMax,
|
||
topic,
|
||
))
|
||
}
|
||
findings = append(findings, findingsForStatMetricProtocolTopicMismatches(parsed, thresholds)...)
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForStatMetricProtocolTopicMismatches(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_stat_samples_total", "vehicle_stat_sources_total", "vehicle_stat_projections_total":
|
||
default:
|
||
continue
|
||
}
|
||
topic := strings.TrimSpace(sample.labels["topic"])
|
||
expectedProtocol, ok := topics.ProtocolForKnownFieldsTopic(topic)
|
||
if !ok {
|
||
continue
|
||
}
|
||
protocol := strings.TrimSpace(sample.labels["protocol"])
|
||
if sample.value <= thresholds.StatProtocolTopicMismatchMax {
|
||
continue
|
||
}
|
||
status := strings.TrimSpace(sample.labels["status"])
|
||
if status == "" {
|
||
status = "unknown"
|
||
}
|
||
if protocol == "" {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat metric protocol label missing %.0f exceeds %.0f for %s topic %s (status %s)",
|
||
sample.value,
|
||
thresholds.StatProtocolTopicMismatchMax,
|
||
sample.name,
|
||
topic,
|
||
status,
|
||
))
|
||
continue
|
||
}
|
||
if protocol == expectedProtocol {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat metric protocol topic mismatches %.0f exceeds %.0f for %s topic %s: expected %s, got %s (status %s)",
|
||
sample.value,
|
||
thresholds.StatProtocolTopicMismatchMax,
|
||
sample.name,
|
||
topic,
|
||
expectedProtocol,
|
||
protocol,
|
||
status,
|
||
))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForFastWriterInvalidJSON(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.FastWriterInvalidJSONMax < 0 {
|
||
return nil
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != "vehicle_fast_writer_messages_total" || sample.labels["status"] != "invalid_json" {
|
||
continue
|
||
}
|
||
if sample.value <= thresholds.FastWriterInvalidJSONMax {
|
||
continue
|
||
}
|
||
subject := strings.TrimSpace(sample.labels["subject"])
|
||
if subject == "" {
|
||
subject = "unknown"
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"fast writer invalid json %.0f exceeds %.0f for subject %s",
|
||
sample.value,
|
||
thresholds.FastWriterInvalidJSONMax,
|
||
subject,
|
||
))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func findingsForStatSamples(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatSampleMinWrites <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*statSampleCounters{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_stat_writes_total":
|
||
if sample.labels["status"] != "ok" {
|
||
continue
|
||
}
|
||
statSampleGroup(groups, sample.labels["topic"]).writesOK += sample.value
|
||
case "vehicle_stat_samples_total":
|
||
group := statSampleGroup(groups, sample.labels["topic"])
|
||
switch sample.labels["status"] {
|
||
case "found":
|
||
group.found += sample.value
|
||
case "written":
|
||
group.written += sample.value
|
||
case "skipped_missing_fields":
|
||
group.skippedMissingFields += sample.value
|
||
case "skipped_missing_vin":
|
||
group.skippedMissingVIN += sample.value
|
||
case "skipped_missing_mileage":
|
||
group.skippedMissingMileage += sample.value
|
||
case "skipped_non_mileage_frame":
|
||
group.skippedNonMileageFrame += sample.value
|
||
case "skipped_non_positive_mileage":
|
||
group.skippedNonPositiveMileage += sample.value
|
||
case "skipped_missing_time":
|
||
group.skippedMissingTime += sample.value
|
||
case "event_time_future_adjusted":
|
||
group.futureAdjusted += sample.value
|
||
case "skipped_same_mileage":
|
||
group.skippedSameMileage += sample.value
|
||
case "skipped_missing_source":
|
||
group.skippedMissingSource += sample.value
|
||
}
|
||
}
|
||
}
|
||
var topics []string
|
||
for topic := range groups {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
group := groups[topic]
|
||
mileageCandidates := statMileageCandidateWrites(*group)
|
||
if mileageCandidates < thresholds.StatSampleMinWrites {
|
||
continue
|
||
}
|
||
if thresholds.StatSampleFutureAdjustmentMax >= 0 &&
|
||
group.futureAdjusted > thresholds.StatSampleFutureAdjustmentMax {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat future event time adjustments %.0f exceeds %.0f for %s: writes ok %.0f, mileage candidates %.0f, found %.0f, written %.0f",
|
||
group.futureAdjusted,
|
||
thresholds.StatSampleFutureAdjustmentMax,
|
||
topic,
|
||
group.writesOK,
|
||
mileageCandidates,
|
||
group.found,
|
||
group.written,
|
||
))
|
||
}
|
||
actionableSkips := statActionableSkips(*group)
|
||
if group.found <= 0 {
|
||
if actionableSkips <= 0 && group.skippedNonMileageFrame > 0 {
|
||
continue
|
||
}
|
||
if statOnlyMissingMileageSkips(*group) {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat samples missing for %s: writes ok %.0f, mileage candidates %.0f, missing fields %.0f, skipped missing vin %.0f, missing mileage %.0f, non-mileage frames %.0f, non-positive mileage %.0f, missing time %.0f, missing source %.0f",
|
||
topic,
|
||
group.writesOK,
|
||
mileageCandidates,
|
||
group.skippedMissingFields,
|
||
group.skippedMissingVIN,
|
||
group.skippedMissingMileage,
|
||
group.skippedNonMileageFrame,
|
||
group.skippedNonPositiveMileage,
|
||
group.skippedMissingTime,
|
||
group.skippedMissingSource,
|
||
))
|
||
continue
|
||
}
|
||
if thresholds.StatSampleMaxActionableSkipRatio > 0 {
|
||
ratio := actionableSkips / mileageCandidates
|
||
if ratio > thresholds.StatSampleMaxActionableSkipRatio {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat actionable skip ratio %.2f exceeds %.2f for %s: writes ok %.0f, mileage candidates %.0f, found %.0f, written %.0f, missing fields %.0f, skipped missing vin %.0f, missing mileage %.0f, non-mileage frames %.0f, non-positive mileage %.0f, missing time %.0f, missing source %.0f, same mileage %.0f",
|
||
ratio,
|
||
thresholds.StatSampleMaxActionableSkipRatio,
|
||
topic,
|
||
group.writesOK,
|
||
mileageCandidates,
|
||
group.found,
|
||
group.written,
|
||
group.skippedMissingFields,
|
||
group.skippedMissingVIN,
|
||
group.skippedMissingMileage,
|
||
group.skippedNonMileageFrame,
|
||
group.skippedNonPositiveMileage,
|
||
group.skippedMissingTime,
|
||
group.skippedMissingSource,
|
||
group.skippedSameMileage,
|
||
))
|
||
}
|
||
}
|
||
if group.written <= 0 && group.skippedMissingSource > 0 {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat samples not written for %s: found %.0f, skipped missing source %.0f, same mileage %.0f",
|
||
topic,
|
||
group.found,
|
||
group.skippedMissingSource,
|
||
group.skippedSameMileage,
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func findingsForStatSources(parsed parsedMetricSet, thresholds Thresholds) []string {
|
||
if thresholds.StatSourceMinSamples <= 0 {
|
||
return nil
|
||
}
|
||
groups := map[string]*statSourceCounters{}
|
||
for _, sample := range parsed.samples {
|
||
switch sample.name {
|
||
case "vehicle_stat_samples_total":
|
||
if sample.labels["status"] == "found" {
|
||
statSourceGroup(groups, sample.labels["topic"]).found += sample.value
|
||
}
|
||
case "vehicle_stat_sources_total":
|
||
group := statSourceGroup(groups, sample.labels["topic"])
|
||
switch sample.labels["status"] {
|
||
case "attempted":
|
||
group.attempted += sample.value
|
||
case "written":
|
||
group.written += sample.value
|
||
case "skipped_throttled":
|
||
group.skippedThrottled += sample.value
|
||
case "skipped_missing_endpoint":
|
||
group.skippedMissingEndpoint += sample.value
|
||
case "skipped_unmanaged":
|
||
group.skippedUnmanaged += sample.value
|
||
}
|
||
}
|
||
}
|
||
var topics []string
|
||
for topic := range groups {
|
||
topics = append(topics, topic)
|
||
}
|
||
sort.Strings(topics)
|
||
var findings []string
|
||
for _, topic := range topics {
|
||
group := groups[topic]
|
||
if group.found < thresholds.StatSourceMinSamples || group.found <= 0 {
|
||
continue
|
||
}
|
||
activeSourceTouches := group.written + group.skippedThrottled + group.skippedUnmanaged
|
||
if activeSourceTouches <= 0 && group.skippedMissingEndpoint <= 0 {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat source tracking missing for %s: samples found %.0f, source written %.0f, source throttled %.0f, source unmanaged %.0f, missing endpoint %.0f",
|
||
topic,
|
||
group.found,
|
||
group.written,
|
||
group.skippedThrottled,
|
||
group.skippedUnmanaged,
|
||
group.skippedMissingEndpoint,
|
||
))
|
||
continue
|
||
}
|
||
if thresholds.StatSourceMaxMissingRatio <= 0 {
|
||
continue
|
||
}
|
||
ratio := group.skippedMissingEndpoint / group.found
|
||
if ratio > thresholds.StatSourceMaxMissingRatio {
|
||
findings = append(findings, fmt.Sprintf(
|
||
"stat source missing endpoint ratio %.2f exceeds %.2f for %s: samples found %.0f, source written %.0f, source throttled %.0f, source unmanaged %.0f, missing endpoint %.0f",
|
||
ratio,
|
||
thresholds.StatSourceMaxMissingRatio,
|
||
topic,
|
||
group.found,
|
||
group.written,
|
||
group.skippedThrottled,
|
||
group.skippedUnmanaged,
|
||
group.skippedMissingEndpoint,
|
||
))
|
||
}
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func statSampleGroup(groups map[string]*statSampleCounters, topic string) *statSampleCounters {
|
||
topic = strings.TrimSpace(topic)
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
group := groups[topic]
|
||
if group == nil {
|
||
group = &statSampleCounters{}
|
||
groups[topic] = group
|
||
}
|
||
return group
|
||
}
|
||
|
||
func statSourceGroup(groups map[string]*statSourceCounters, topic string) *statSourceCounters {
|
||
topic = strings.TrimSpace(topic)
|
||
if topic == "" {
|
||
topic = "unknown"
|
||
}
|
||
group := groups[topic]
|
||
if group == nil {
|
||
group = &statSourceCounters{}
|
||
groups[topic] = group
|
||
}
|
||
return group
|
||
}
|
||
|
||
func histogramP99Findings(parsed parsedMetricSet, histogramName string, label string, thresholdMS float64, minSamples float64) []string {
|
||
if thresholdMS <= 0 {
|
||
return nil
|
||
}
|
||
groups := histogramGroups(parsed.samples, histogramName)
|
||
var findings []string
|
||
var keys []string
|
||
for key := range groups {
|
||
keys = append(keys, key)
|
||
}
|
||
sort.Strings(keys)
|
||
for _, key := range keys {
|
||
estimate, count, ok := histogramQuantile(groups[key], 0.99)
|
||
if !ok || count < minSamples || estimate <= thresholdMS {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf("%s p99 %.0fms exceeds %.0fms (%s, samples %.0f)", label, estimate, thresholdMS, key, count))
|
||
}
|
||
return findings
|
||
}
|
||
|
||
func recentP99GaugeFindings(parsed parsedMetricSet, metricName string, samplesMetricName string, label string, thresholdMS float64, minSamples float64) []string {
|
||
if thresholdMS <= 0 {
|
||
return nil
|
||
}
|
||
samplesByKey := map[string]float64{}
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != samplesMetricName {
|
||
continue
|
||
}
|
||
samplesByKey[metricLabelsKey(sample.labels)] = sample.value
|
||
}
|
||
var findings []string
|
||
for _, sample := range parsed.samples {
|
||
if sample.name != metricName {
|
||
continue
|
||
}
|
||
key := metricLabelsKey(sample.labels)
|
||
samples := samplesByKey[key]
|
||
if samples < minSamples || sample.value <= thresholdMS {
|
||
continue
|
||
}
|
||
findings = append(findings, fmt.Sprintf("%s recent p99 %.0fms exceeds %.0fms (%s, samples %.0f)", label, sample.value, thresholdMS, key, samples))
|
||
}
|
||
sort.Strings(findings)
|
||
return findings
|
||
}
|
||
|
||
func parseMetrics(text string) map[string]float64 {
|
||
return parseMetricSet(text).totals
|
||
}
|
||
|
||
type parsedMetricSet struct {
|
||
totals map[string]float64
|
||
samples []metricSample
|
||
}
|
||
|
||
type metricSample struct {
|
||
name string
|
||
labels map[string]string
|
||
value float64
|
||
}
|
||
|
||
func parseMetricSet(text string) parsedMetricSet {
|
||
out := map[string]float64{}
|
||
var samples []metricSample
|
||
scanner := bufio.NewScanner(strings.NewReader(text))
|
||
for scanner.Scan() {
|
||
line := strings.TrimSpace(scanner.Text())
|
||
if line == "" || strings.HasPrefix(line, "#") {
|
||
continue
|
||
}
|
||
sample, ok := parseMetricSample(line)
|
||
if !ok {
|
||
continue
|
||
}
|
||
if aggregateMetricByMax(sample.name) {
|
||
if current, ok := out[sample.name]; !ok || sample.value > current {
|
||
out[sample.name] = sample.value
|
||
}
|
||
} else {
|
||
out[sample.name] += sample.value
|
||
}
|
||
samples = append(samples, sample)
|
||
}
|
||
return parsedMetricSet{totals: out, samples: samples}
|
||
}
|
||
|
||
func aggregateMetricByMax(name string) bool {
|
||
return isLastActivityUnixMetric(name) ||
|
||
strings.HasSuffix(name, "_p99_ms")
|
||
}
|
||
|
||
func isLastActivityUnixMetric(name string) bool {
|
||
return strings.Contains(name, "_last_") && strings.HasSuffix(name, "_unix_seconds")
|
||
}
|
||
|
||
func addLastActivityAges(metrics map[string]float64, checkedAt time.Time) {
|
||
if len(metrics) == 0 {
|
||
return
|
||
}
|
||
for name, value := range metrics {
|
||
if !isLastActivityUnixMetric(name) || value <= 0 {
|
||
continue
|
||
}
|
||
age := checkedAt.Sub(time.Unix(int64(value), 0).UTC()).Seconds()
|
||
if age < 0 {
|
||
age = 0
|
||
}
|
||
metrics[strings.TrimSuffix(name, "_unix_seconds")+"_age_seconds"] = age
|
||
}
|
||
}
|
||
|
||
func parseMetricLine(line string) (string, float64, bool) {
|
||
sample, ok := parseMetricSample(line)
|
||
return sample.name, sample.value, ok
|
||
}
|
||
|
||
func parseMetricSample(line string) (metricSample, bool) {
|
||
parts := strings.Fields(line)
|
||
if len(parts) < 2 {
|
||
return metricSample{}, false
|
||
}
|
||
name, labels := parseMetricNameAndLabels(parts[0])
|
||
value, err := strconv.ParseFloat(parts[1], 64)
|
||
if err != nil {
|
||
return metricSample{}, false
|
||
}
|
||
return metricSample{name: name, labels: labels, value: value}, true
|
||
}
|
||
|
||
func parseMetricNameAndLabels(value string) (string, map[string]string) {
|
||
index := strings.IndexByte(value, '{')
|
||
if index < 0 {
|
||
return value, nil
|
||
}
|
||
end := strings.LastIndexByte(value, '}')
|
||
if end < index {
|
||
return value[:index], nil
|
||
}
|
||
return value[:index], parseMetricLabels(value[index+1 : end])
|
||
}
|
||
|
||
func parseMetricLabels(value string) map[string]string {
|
||
labels := map[string]string{}
|
||
for len(value) > 0 {
|
||
value = strings.TrimLeft(value, " \t,")
|
||
if value == "" {
|
||
break
|
||
}
|
||
index := strings.IndexByte(value, '=')
|
||
if index <= 0 {
|
||
break
|
||
}
|
||
key := strings.TrimSpace(value[:index])
|
||
value = strings.TrimLeft(value[index+1:], " \t")
|
||
if key == "" || value == "" {
|
||
break
|
||
}
|
||
if value[0] != '"' {
|
||
next := strings.IndexByte(value, ',')
|
||
if next < 0 {
|
||
labels[key] = strings.TrimSpace(value)
|
||
break
|
||
}
|
||
labels[key] = strings.TrimSpace(value[:next])
|
||
value = value[next+1:]
|
||
continue
|
||
}
|
||
labelValue, rest, ok := readQuotedLabelValue(value)
|
||
if !ok {
|
||
break
|
||
}
|
||
labels[key] = labelValue
|
||
value = rest
|
||
}
|
||
if len(labels) == 0 {
|
||
return nil
|
||
}
|
||
return labels
|
||
}
|
||
|
||
func readQuotedLabelValue(value string) (string, string, bool) {
|
||
if value == "" || value[0] != '"' {
|
||
return "", value, false
|
||
}
|
||
var b strings.Builder
|
||
escaped := false
|
||
for index := 1; index < len(value); index++ {
|
||
ch := value[index]
|
||
if escaped {
|
||
switch ch {
|
||
case 'n':
|
||
b.WriteByte('\n')
|
||
default:
|
||
b.WriteByte(ch)
|
||
}
|
||
escaped = false
|
||
continue
|
||
}
|
||
if ch == '\\' {
|
||
escaped = true
|
||
continue
|
||
}
|
||
if ch == '"' {
|
||
return b.String(), value[index+1:], true
|
||
}
|
||
b.WriteByte(ch)
|
||
}
|
||
return "", value, false
|
||
}
|
||
|
||
type histogramBucket struct {
|
||
upperBound float64
|
||
count float64
|
||
}
|
||
|
||
func histogramGroups(samples []metricSample, histogramName string) map[string][]histogramBucket {
|
||
groups := map[string][]histogramBucket{}
|
||
for _, sample := range samples {
|
||
if sample.name != histogramName+"_bucket" {
|
||
continue
|
||
}
|
||
upperBound, ok := parseBucketUpperBound(sample.labels["le"])
|
||
if !ok {
|
||
continue
|
||
}
|
||
key := metricLabelsKey(sample.labels, "le")
|
||
groups[key] = append(groups[key], histogramBucket{upperBound: upperBound, count: sample.value})
|
||
}
|
||
return groups
|
||
}
|
||
|
||
func parseBucketUpperBound(value string) (float64, bool) {
|
||
if value == "+Inf" {
|
||
return math.Inf(1), true
|
||
}
|
||
parsed, err := strconv.ParseFloat(value, 64)
|
||
return parsed, err == nil
|
||
}
|
||
|
||
func histogramQuantile(buckets []histogramBucket, quantile float64) (float64, float64, bool) {
|
||
if len(buckets) == 0 || quantile <= 0 || quantile > 1 {
|
||
return 0, 0, false
|
||
}
|
||
sort.Slice(buckets, func(i, j int) bool {
|
||
return buckets[i].upperBound < buckets[j].upperBound
|
||
})
|
||
total := buckets[len(buckets)-1].count
|
||
if total <= 0 {
|
||
return 0, 0, false
|
||
}
|
||
target := total * quantile
|
||
for _, bucket := range buckets {
|
||
if bucket.count >= target {
|
||
return bucket.upperBound, total, true
|
||
}
|
||
}
|
||
return buckets[len(buckets)-1].upperBound, total, true
|
||
}
|
||
|
||
func metricLabelsKey(labels map[string]string, exclude ...string) string {
|
||
if len(labels) == 0 {
|
||
return "all"
|
||
}
|
||
excluded := map[string]struct{}{}
|
||
for _, key := range exclude {
|
||
excluded[key] = struct{}{}
|
||
}
|
||
keys := make([]string, 0, len(labels))
|
||
for key := range labels {
|
||
if _, skip := excluded[key]; skip {
|
||
continue
|
||
}
|
||
keys = append(keys, key)
|
||
}
|
||
if len(keys) == 0 {
|
||
return "all"
|
||
}
|
||
sort.Strings(keys)
|
||
parts := make([]string, 0, len(keys))
|
||
for _, key := range keys {
|
||
parts = append(parts, key+"="+labels[key])
|
||
}
|
||
return strings.Join(parts, ",")
|
||
}
|