feat: build vehicle data platform and production pipeline

This commit is contained in:
lingniu
2026-07-14 12:35:33 +08:00
parent b452be3b94
commit bb59303a4b
270 changed files with 88016 additions and 1975 deletions

View File

@@ -0,0 +1,42 @@
package gateway
import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime"
)
const (
gatewayFieldsPublished = "published"
gatewayFieldsDelegated = "delegated_to_bridge"
gatewayFieldsSkippedNonRealtime = "skipped_non_realtime"
gatewayFieldsSkippedMissing = "skipped_missing_fields"
gatewayFieldsPublishError = "publish_error"
)
func gatewayFieldsEnvelope(env envelope.FrameEnvelope) (envelope.FrameEnvelope, string, bool) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return envelope.FrameEnvelope{}, gatewayFieldsSkippedNonRealtime, false
}
fieldsEnv, ok := realtime.BuildFieldsEnvelope(env)
if !ok {
return envelope.FrameEnvelope{}, gatewayFieldsSkippedMissing, false
}
return fieldsEnv, gatewayFieldsPublished, true
}
func gatewayDelegatedFields(env envelope.FrameEnvelope) (int, string, bool) {
if !envelope.IsRealtimeTelemetryFrame(env) {
return 0, gatewayFieldsSkippedNonRealtime, false
}
if len(env.ParsedFields) == 0 {
return 0, gatewayFieldsSkippedMissing, false
}
return len(env.ParsedFields), gatewayFieldsDelegated, true
}
func canonicalRawEnvelope(env envelope.FrameEnvelope) envelope.FrameEnvelope {
env.EventKind = envelope.EventKindRaw
env.Parsed = nil
env.Fields = nil
return env
}

View File

@@ -0,0 +1,76 @@
package gateway
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
)
func TestCanonicalRawEnvelopeDropsBareStandardizedFields(t *testing.T) {
original := envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
EventKind: envelope.EventKindFields,
Parsed: map[string]any{
"location": map[string]any{"latitude": 30.5, "speed_kmh": 20},
},
Fields: map[string]any{
envelope.FieldLatitude: 30.5,
envelope.FieldSpeedKMH: 20,
},
ParsedFields: map[string]any{
"jt808.location.latitude": 30.5,
"jt808.location.speed_kmh": 20,
},
}
canonical := canonicalRawEnvelope(original)
if canonical.EventKind != envelope.EventKindRaw {
t.Fatalf("event kind = %q", canonical.EventKind)
}
if len(canonical.Fields) != 0 {
t.Fatalf("canonical raw leaked bare fields: %#v", canonical.Fields)
}
if len(canonical.Parsed) != 0 {
t.Fatalf("canonical raw leaked duplicate parsed tree: %#v", canonical.Parsed)
}
if len(canonical.ParsedFields) != 2 {
t.Fatalf("canonical raw lost protocol fields: %#v", canonical.ParsedFields)
}
if len(original.Fields) != 2 {
t.Fatalf("canonical copy mutated in-process parser fields: %#v", original.Fields)
}
if len(original.Parsed) != 1 {
t.Fatalf("canonical copy mutated in-process parsed tree: %#v", original.Parsed)
}
}
func TestCanonicalRawEnvelopeReducesDuplicateParsedPayload(t *testing.T) {
original := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x02",
VIN: "LTESTVIN000000001",
ParseStatus: envelope.ParseOK,
Parsed: map[string]any{
"data_units": []any{map[string]any{
"name": "vendor",
"value": strings.Repeat("x", 4096),
}},
},
ParsedFields: map[string]any{
"gb32960.vendor.value": strings.Repeat("x", 4096),
},
}
before, err := original.MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
after, err := canonicalRawEnvelope(original).MarshalJSONBytes()
if err != nil {
t.Fatal(err)
}
if len(after)*100 >= len(before)*65 {
t.Fatalf("canonical raw should remove the duplicate parsed tree: before=%d after=%d", len(before), len(after))
}
t.Logf("canonical raw bytes before=%d after=%d reduction=%.1f%%", len(before), len(after), 100*(1-float64(len(after))/float64(len(before))))
}

View File

@@ -0,0 +1,104 @@
package gateway
import (
"context"
"errors"
"strings"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
var gatewayIdentityDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayFieldsCountBuckets = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, 5000}
func identityErrorStatus(err error) string {
if errors.Is(err, context.DeadlineExceeded) {
return "timeout"
}
return "error"
}
func recordGatewayIdentityDuration(registry *metrics.Registry, protocol envelope.Protocol, status string, elapsed time.Duration) {
if registry == nil {
return
}
elapsedMS := float64(elapsed.Nanoseconds()) / float64(time.Millisecond)
labels := metrics.Labels{
"protocol": string(protocol),
"status": status,
}
registry.SetGauge("vehicle_gateway_identity_duration_ms", labels, elapsedMS)
registry.ObserveHistogram("vehicle_gateway_identity_duration_ms_histogram", labels, gatewayIdentityDurationBucketsMS, elapsedMS)
}
func recordGatewayIdentityCacheStatus(registry *metrics.Registry, protocol envelope.Protocol, env envelope.FrameEnvelope) {
if registry == nil {
return
}
status := identityCacheStatus(env)
if status == "" {
return
}
registry.IncCounter("vehicle_gateway_identity_cache_total", metrics.Labels{
"protocol": string(protocol),
"cache_status": status,
})
}
func identityCacheStatus(env envelope.FrameEnvelope) string {
identity, ok := env.Parsed["identity"].(map[string]any)
if !ok {
return ""
}
status, ok := identity["cache_status"].(string)
if !ok {
return ""
}
return strings.TrimSpace(status)
}
func annotateIdentityError(env *envelope.FrameEnvelope, err error) {
if env == nil || err == nil {
return
}
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
identity, _ := env.Parsed["identity"].(map[string]any)
if identity == nil {
identity = map[string]any{}
}
if _, ok := identity["resolved"]; !ok {
identity["resolved"] = strings.TrimSpace(env.VIN) != ""
}
identity["error"] = err.Error()
env.Parsed["identity"] = identity
}
func recordGatewayFieldsMetric(registry *metrics.Registry, protocol envelope.Protocol, status string) {
if registry == nil {
return
}
registry.IncCounter("vehicle_gateway_fields_total", metrics.Labels{
"protocol": string(protocol),
"status": status,
})
}
func recordGatewayFieldsCount(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) {
if registry == nil {
return
}
if fieldCount < 0 {
fieldCount = 0
}
labels := metrics.Labels{
"protocol": string(protocol),
"status": status,
}
value := float64(fieldCount)
registry.SetGauge("vehicle_gateway_fields_count", labels, value)
registry.ObserveHistogram("vehicle_gateway_fields_count_histogram", labels, gatewayFieldsCountBuckets, value)
}

View File

@@ -0,0 +1,27 @@
package gateway
import (
"strings"
"testing"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestRecordGatewayIdentityCacheStatus(t *testing.T) {
registry := metrics.NewRegistry()
recordGatewayIdentityCacheStatus(registry, envelope.ProtocolJT808, envelope.FrameEnvelope{
Parsed: map[string]any{
"identity": map[string]any{
"resolved": true,
"cache_status": "stale",
},
},
})
got := registry.Render()
want := `vehicle_gateway_identity_cache_total{cache_status="stale",protocol="JT808"} 1`
if !strings.Contains(got, want) {
t.Fatalf("metrics missing %q:\n%s", want, got)
}
}

View File

@@ -41,6 +41,7 @@ type MQTTClientConfig struct {
Logger *slog.Logger
Metrics *metrics.Registry
PublishUnified bool
DelegateFields bool
}
type MQTTClient struct {
@@ -204,28 +205,42 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
}
env.EventID = env.StableEventID()
c.recordParseErrorMetric(err)
} else {
} else if envelope.RequiresVehicleIdentity(env) {
resolveStarted := time.Now()
resolved, resolveErr := c.cfg.Resolver.Resolve(messageCtx, env)
if resolveErr != nil {
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
if env.Parsed == nil {
env.Parsed = map[string]any{}
if resolved.Protocol != "" || strings.TrimSpace(resolved.VIN) != "" || strings.TrimSpace(resolved.Phone) != "" {
env = resolved
}
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
identityStatus := identityErrorStatus(resolveErr)
c.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
c.cfg.Logger.Warn("mqtt identity resolve failed", "topic", topic, "event_id", env.StableEventID(), "error", resolveErr)
annotateIdentityError(&env, resolveErr)
env.ParseStatus = envelope.ParsePartial
c.recordIdentityMetric("error")
c.recordIdentityMetric(identityStatus)
c.recordIdentityIssueMetric(identityStatus, env, identityIssueReason(identityStatus, resolveErr))
} else {
env = resolved
annotateIdentityUnresolved(&env)
c.recordIdentityMetric(identityStatus(env))
identityStatus := identityStatus(env)
c.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
c.recordIdentityMetric(identityStatus)
if identityStatus != "resolved" {
c.recordIdentityIssueMetric(identityStatus, env, "no_binding")
}
recordGatewayIdentityCacheStatus(c.cfg.Metrics, envelope.ProtocolYutongMQTT, env)
}
} else {
c.recordIdentitySkipMetric("non_vehicle_frame")
}
frameStatus = env.ParseStatus
env.EventKind = envelope.EventKindRaw
c.recordFrameMetric(env.ParseStatus)
if env.ParseStatus != envelope.ParseBadFrame {
realtime.EnsureParsedFields(&env)
}
if err := c.cfg.Sink.PublishRaw(messageCtx, env); err != nil {
canonicalRaw := canonicalRawEnvelope(env)
if err := c.cfg.Sink.PublishRaw(messageCtx, canonicalRaw); err != nil {
c.recordPublishMetric("raw", "error")
c.cfg.Logger.Error("publish mqtt raw failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
@@ -234,21 +249,37 @@ func (c *MQTTClient) handleMessage(ctx context.Context, topic string, payload []
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
c.recordPublishMetric("fields", "error")
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
if c.cfg.DelegateFields {
fieldCount, fieldsStatus, ok := gatewayDelegatedFields(env)
c.recordFieldsMetric(fieldsStatus)
if ok {
c.recordFieldsCount(fieldsStatus, fieldCount)
c.recordPublishMetric("fields", "delegated")
}
} else {
fieldsEnv, fieldsStatus, ok := gatewayFieldsEnvelope(env)
if !ok {
c.recordFieldsMetric(fieldsStatus)
} else {
if err := c.cfg.Sink.PublishFields(messageCtx, fieldsEnv); err != nil {
c.recordFieldsMetric(gatewayFieldsPublishError)
c.recordFieldsCount(gatewayFieldsPublishError, len(fieldsEnv.Fields))
c.recordPublishMetric("fields", "error")
c.cfg.Logger.Error("publish mqtt fields failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
} else {
c.recordFieldsMetric(fieldsStatus)
c.recordFieldsCount(fieldsStatus, len(fieldsEnv.Fields))
c.recordPublishMetric("fields", "ok")
}
}
c.recordPublishMetric("fields", "ok")
}
if c.cfg.PublishUnified {
if err := c.cfg.Sink.PublishUnified(messageCtx, env); err != nil {
if err := c.cfg.Sink.PublishUnified(messageCtx, canonicalRaw); err != nil {
c.recordPublishMetric("unified", "error")
c.cfg.Logger.Error("publish mqtt unified failed", "topic", topic, "event_id", env.StableEventID(), "error", err)
return
} else {
c.recordPublishMetric("unified", "ok")
}
c.recordPublishMetric("unified", "ok")
}
}
@@ -256,10 +287,12 @@ func (c *MQTTClient) recordFrameMetric(status envelope.ParseStatus) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"status": string(status),
})
}
c.cfg.Metrics.IncCounter("vehicle_gateway_frames_total", labels)
metrics.RecordLastActivity(c.cfg.Metrics, "vehicle_gateway_last_frame_unix_seconds", labels)
}
func (c *MQTTClient) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
@@ -282,11 +315,21 @@ func (c *MQTTClient) recordPublishMetric(kind string, status string) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"kind": kind,
"status": status,
})
}
c.cfg.Metrics.IncCounter("vehicle_gateway_publish_total", labels)
metrics.RecordLastActivity(c.cfg.Metrics, "vehicle_gateway_last_publish_unix_seconds", labels)
}
func (c *MQTTClient) recordFieldsMetric(status string) {
recordGatewayFieldsMetric(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status)
}
func (c *MQTTClient) recordFieldsCount(status string, fieldCount int) {
recordGatewayFieldsCount(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status, fieldCount)
}
func (c *MQTTClient) recordParseErrorMetric(err error) {
@@ -308,3 +351,37 @@ func (c *MQTTClient) recordIdentityMetric(status string) {
"status": status,
})
}
func (c *MQTTClient) recordIdentitySkipMetric(reason string) {
if c.cfg.Metrics == nil {
return
}
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_skips_total", metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"reason": reason,
})
}
func (c *MQTTClient) recordIdentityIssueMetric(status string, env envelope.FrameEnvelope, reason string) {
if c.cfg.Metrics == nil {
return
}
messageID := strings.TrimSpace(env.MessageID)
if messageID == "" {
messageID = "unknown"
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "unknown"
}
c.cfg.Metrics.IncCounter("vehicle_gateway_identity_issues_total", metrics.Labels{
"protocol": string(envelope.ProtocolYutongMQTT),
"status": status,
"message_id": messageID,
"reason": reason,
})
}
func (c *MQTTClient) recordIdentityDuration(status string, elapsed time.Duration) {
recordGatewayIdentityDuration(c.cfg.Metrics, envelope.ProtocolYutongMQTT, status, elapsed)
}

View File

@@ -7,6 +7,7 @@ import (
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"errors"
"log/slog"
"math/big"
"os"
@@ -19,7 +20,7 @@ import (
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
)
func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
func TestMQTTClientHandleMessagePublishesRawAndFieldsByDefault(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
@@ -39,18 +40,80 @@ func TestMQTTClientHandleMessagePublishesOnlyRawByDefault(t *testing.T) {
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified))
}
if sink.raw[0].Protocol != envelope.ProtocolYutongMQTT || sink.raw[0].VIN != "LTEST000000000001" {
t.Fatalf("unexpected raw envelope: %#v", sink.raw[0])
}
if sink.raw[0].EventKind != envelope.EventKindRaw {
t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw)
}
if sink.raw[0].RawText == "" {
t.Fatal("mqtt raw envelope should keep text payload")
}
if sink.raw[0].RawHex != "" {
t.Fatalf("mqtt raw envelope should not duplicate text payload as hex: %q", sink.raw[0].RawHex)
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
if got, want := sink.fields[0].Fields["yutong_mqtt.data.meter_speed"], sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != want {
t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want)
}
if sink.fields[0].EventKind != envelope.EventKindFields {
t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields)
}
if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want {
t.Fatalf("fields source event id = %#v, want %s", got, want)
}
if sink.fields[0].FieldMapping == "" {
t.Fatal("fields event should expose field mapping version")
}
if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 {
t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields)
}
}
func TestMQTTClientDelegatesFieldsProjectionWhenConfigured(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
DelegateFields: true,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.fields) != 0 {
t.Fatalf("raw=%d fields=%d, want canonical raw only", len(sink.raw), len(sink.fields))
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("delegated canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="delegated_to_bridge"} 1`,
`vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="delegated_to_bridge"} `,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="delegated"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("delegated fields metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
@@ -77,8 +140,18 @@ func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="OK"} 1`,
`vehicle_gateway_last_frame_unix_seconds{protocol="YUTONG_MQTT",status="OK"} `,
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="YUTONG_MQTT",status="resolved"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="resolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_sum{protocol="YUTONG_MQTT",status="resolved"}`,
`vehicle_gateway_publish_total{kind="raw",protocol="YUTONG_MQTT",status="ok"} 1`,
`vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="YUTONG_MQTT",status="ok"} `,
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`,
`vehicle_gateway_fields_count{protocol="YUTONG_MQTT",status="published"} `,
`vehicle_gateway_fields_count_histogram_count{protocol="YUTONG_MQTT",status="published"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="ok"} 1`,
`vehicle_gateway_frame_duration_ms{protocol="YUTONG_MQTT",status="OK"}`,
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="YUTONG_MQTT",status="OK"} 1`,
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="YUTONG_MQTT",status="OK"} 1`,
@@ -93,6 +166,36 @@ func TestMQTTClientRecordsMessageMetrics(t *testing.T) {
}
}
func TestMQTTClientRecordsNonRealtimeFieldsSkipMetric(t *testing.T) {
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: &recordingSink{},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{}
}`))
text := registry.Render()
if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="YUTONG_MQTT",reason="non_vehicle_frame"} 1`) {
t.Fatalf("identity skip metric missing:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestMQTTClientHandleBadPayloadPublishesOnlyRaw(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
@@ -167,6 +270,66 @@ func TestMQTTClientUsesUncancelledMessageContextForReceivedMessage(t *testing.T)
}
}
func TestMQTTClientPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) {
sink := &recordingSink{}
registry := metrics.NewRegistry()
wantErr := errors.New("registration upsert failed")
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
Sink: sink,
Resolver: resolvedErrorResolver{vin: "LRESOLVED00000001", err: wantErr},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LRESOLVED00000001" {
t.Fatalf("raw vin = %q, want resolver vin", sink.raw[0].VIN)
}
if sink.raw[0].ParseStatus != envelope.ParsePartial {
t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus)
}
if len(sink.raw[0].Parsed) != 0 {
t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed)
}
if got := sink.raw[0].ParsedFields["yutong_mqtt.data.meter_speed"]; got != "52.3" {
t.Fatalf("protocol field lost after identity side-effect failure: %#v", got)
}
for field := range sink.raw[0].ParsedFields {
if strings.HasPrefix(field, "yutong_mqtt.identity.") {
t.Fatalf("derived identity annotation leaked into protocol fields: %s", field)
}
}
if len(sink.fields) != 1 || sink.fields[0].VIN != "LRESOLVED00000001" {
t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="YUTONG_MQTT",status="error"} 1`,
`vehicle_gateway_identity_issues_total{message_id="MQTT",protocol="YUTONG_MQTT",reason="resolver_error",status="error"} 1`,
`vehicle_gateway_frames_total{protocol="YUTONG_MQTT",status="PARTIAL"} 1`,
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="published"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
sink := &recordingSink{}
client, err := NewMQTTClient(MQTTClientConfig{
@@ -193,6 +356,44 @@ func TestMQTTClientPublishesUnifiedWhenExplicitlyEnabled(t *testing.T) {
}
}
func TestMQTTClientPublishesUnifiedWhenFieldsPublishFails(t *testing.T) {
sink := &recordingSink{fieldsErr: errors.New("fields queue full")}
registry := metrics.NewRegistry()
client, err := NewMQTTClient(MQTTClientConfig{
EndpointName: "endpoint-a",
Broker: "tcp://127.0.0.1:1883",
ClientID: "test-client",
Topics: []string{"/ytforward/shln/+"},
PublishUnified: true,
Sink: sink,
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewMQTTClient() error = %v", err)
}
client.handleMessage(context.Background(), "/ytforward/shln/dev1", []byte(`{
"device":"LTEST000000000001",
"time":"20260413100000",
"data":{"METER_SPEED":52.3,"TOTAL_MILEAGE":123456.7}
}`))
if len(sink.raw) != 1 || len(sink.fields) != 1 || len(sink.unified) != 1 {
t.Fatalf("raw=%d fields=%d unified=%d", len(sink.raw), len(sink.fields), len(sink.unified))
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="YUTONG_MQTT",status="publish_error"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="YUTONG_MQTT",status="error"} 1`,
`vehicle_gateway_publish_total{kind="unified",protocol="YUTONG_MQTT",status="ok"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestMQTTClientBuildOptionsLoadsTLSCertificates(t *testing.T) {
dir := t.TempDir()
caPath, certPath, keyPath := writeTestTLSMaterial(t, dir)

View File

@@ -10,8 +10,10 @@ import (
"net"
"strings"
"sync"
"syscall"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/eventbus"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/identity"
@@ -26,11 +28,12 @@ type FrameParser func(raw []byte, receivedAtMS int64, sourceEndpoint string) (en
type FrameResponder func(raw []byte, env envelope.FrameEnvelope) (response []byte, ok bool, err error)
type TCPProtocol struct {
Protocol envelope.Protocol
Addr string
Extract FrameExtractor
Parse FrameParser
Respond FrameResponder
Protocol envelope.Protocol
Addr string
Extract FrameExtractor
Parse FrameParser
Authenticate authentication.Authenticator
Respond FrameResponder
}
type connectionState struct {
@@ -47,6 +50,9 @@ type TCPServer struct {
idleTimeout time.Duration
maxConnections int
publishUnified bool
delegateFields bool
activeMu sync.Mutex
activeConns map[net.Conn]struct{}
}
type TCPServerConfig struct {
@@ -59,11 +65,14 @@ type TCPServerConfig struct {
IdleTimeout time.Duration
MaxConnections int
PublishUnified bool
DelegateFields bool
}
const frameOperationTimeout = 30 * time.Second
var gatewayFrameDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayResponseDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000}
var gatewayResponseE2ERecent = metrics.NewRecentLatencyByKey(512)
func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
if cfg.Protocol.Protocol == "" {
@@ -106,6 +115,8 @@ func NewTCPServer(cfg TCPServerConfig) (*TCPServer, error) {
idleTimeout: cfg.IdleTimeout,
maxConnections: cfg.MaxConnections,
publishUnified: cfg.PublishUnified,
delegateFields: cfg.DelegateFields,
activeConns: map[net.Conn]struct{}{},
}, nil
}
@@ -120,6 +131,7 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
go func() {
<-ctx.Done()
_ = listener.Close()
s.closeActiveConnections()
}()
s.logger.Info("tcp listener started", "protocol", s.protocol.Protocol, "addr", listener.Addr().String())
@@ -155,12 +167,15 @@ func (s *TCPServer) ListenAndServe(ctx context.Context) error {
func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
defer conn.Close()
s.trackConnection(conn)
defer s.untrackConnection(conn)
source := conn.RemoteAddr().String()
log := s.logger.With("protocol", s.protocol.Protocol, "remote", source)
s.recordConnectionMetric(1)
defer s.recordConnectionMetric(-1)
log.Info("tcp connection opened")
defer log.Info("tcp connection closed")
log.Debug("tcp connection opened")
defer log.Debug("tcp connection closed")
readBuffer := make([]byte, s.readBufferSize)
var pending []byte
@@ -182,16 +197,21 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
}
}
if err != nil {
if errors.Is(err, io.EOF) {
s.recordConnectionClose("eof")
if ctx.Err() != nil {
s.recordConnectionClose("context_cancelled")
return
}
var netErr net.Error
if errors.As(err, &netErr) && netErr.Timeout() {
log.Warn("tcp connection idle timeout")
log.Debug("tcp connection idle timeout")
s.recordConnectionClose("read_timeout")
return
}
if isRoutineTCPReadClose(err) {
log.Debug("tcp connection closed by peer", "error", err)
s.recordConnectionClose("remote_closed")
return
}
log.Warn("tcp read failed", "error", err)
s.recordConnectionClose("read_error")
return
@@ -203,6 +223,55 @@ func (s *TCPServer) handleConnection(ctx context.Context, conn net.Conn) {
}
}
func isRoutineTCPReadClose(err error) bool {
if err == nil {
return false
}
if errors.Is(err, io.EOF) ||
errors.Is(err, net.ErrClosed) ||
errors.Is(err, syscall.ECONNRESET) ||
errors.Is(err, syscall.EPIPE) {
return true
}
text := strings.ToLower(err.Error())
return strings.Contains(text, "connection reset by peer") ||
strings.Contains(text, "broken pipe") ||
strings.Contains(text, "use of closed network connection")
}
func (s *TCPServer) trackConnection(conn net.Conn) {
if conn == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
if s.activeConns == nil {
s.activeConns = map[net.Conn]struct{}{}
}
s.activeConns[conn] = struct{}{}
}
func (s *TCPServer) untrackConnection(conn net.Conn) {
if conn == nil {
return
}
s.activeMu.Lock()
defer s.activeMu.Unlock()
delete(s.activeConns, conn)
}
func (s *TCPServer) closeActiveConnections() {
s.activeMu.Lock()
conns := make([]net.Conn, 0, len(s.activeConns))
for conn := range s.activeConns {
conns = append(conns, conn)
}
s.activeMu.Unlock()
for _, conn := range conns {
_ = conn.Close()
}
}
func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte, source string, state *connectionState) {
started := time.Now()
frameStatus := envelope.ParseBadFrame
@@ -228,29 +297,53 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
env.EventID = env.StableEventID()
s.recordParseErrorMetric(err)
} else {
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
if resolveErr != nil {
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
if env.Parsed == nil {
env.Parsed = map[string]any{}
if s.protocol.Authenticate != nil {
result := s.protocol.Authenticate.Authenticate(env)
authentication.Apply(&env, result)
if result.Applicable {
s.recordAuthenticationMetric(result)
}
env.Parsed["identity"] = map[string]any{"resolved": false, "error": resolveErr.Error()}
env.ParseStatus = envelope.ParsePartial
s.recordIdentityMetric("error")
} else {
env = resolved
annotateIdentityUnresolved(&env)
s.recordIdentityMetric(identityStatus(env))
}
enrichConnectionPlatform(&env, state)
authentication.RedactParsedCredentials(&env)
if envelope.RequiresVehicleIdentity(env) {
resolveStarted := time.Now()
resolved, resolveErr := s.resolver.Resolve(frameCtx, env)
if resolveErr != nil {
if resolved.Protocol != "" || strings.TrimSpace(resolved.VIN) != "" || strings.TrimSpace(resolved.Phone) != "" {
env = resolved
}
identityStatus := identityErrorStatus(resolveErr)
s.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
s.logger.Warn("identity resolve failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", resolveErr)
annotateIdentityError(&env, resolveErr)
env.ParseStatus = envelope.ParsePartial
s.recordIdentityMetric(identityStatus)
s.recordIdentityIssueMetric(identityStatus, env, identityIssueReason(identityStatus, resolveErr))
} else {
env = resolved
annotateIdentityUnresolved(&env)
identityStatus := identityStatus(env)
s.recordIdentityDuration(identityStatus, time.Since(resolveStarted))
s.recordIdentityMetric(identityStatus)
if identityStatus != "resolved" {
s.recordIdentityIssueMetric(identityStatus, env, "no_binding")
}
recordGatewayIdentityCacheStatus(s.metrics, s.protocol.Protocol, env)
}
} else {
s.recordIdentitySkipMetric("non_vehicle_frame")
}
}
enrichConnectionPlatform(&env, state)
frameStatus = env.ParseStatus
env.EventKind = envelope.EventKindRaw
s.recordFrameMetric(env.ParseStatus)
if env.ParseStatus != envelope.ParseBadFrame {
realtime.EnsureParsedFields(&env)
}
if err := s.sink.PublishRaw(frameCtx, env); err != nil {
canonicalRaw := canonicalRawEnvelope(env)
if err := s.sink.PublishRaw(frameCtx, canonicalRaw); err != nil {
s.recordPublishMetric("raw", "error")
s.logger.Error("publish raw failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
@@ -259,37 +352,79 @@ func (s *TCPServer) handleFrame(ctx context.Context, conn net.Conn, raw []byte,
if env.ParseStatus == envelope.ParseBadFrame {
return
}
if fieldsEnv, ok := realtime.BuildFieldsEnvelope(env); ok {
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
s.recordPublishMetric("fields", "error")
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
s.writeProtocolResponse(conn, raw, env, started)
if shouldCloseAfterAuthentication(env) && conn != nil {
_ = conn.Close()
return
}
if s.delegateFields {
fieldCount, fieldsStatus, ok := gatewayDelegatedFields(env)
s.recordFieldsMetric(fieldsStatus)
if ok {
s.recordFieldsCount(fieldsStatus, fieldCount)
s.recordPublishMetric("fields", "delegated")
}
} else {
fieldsEnv, fieldsStatus, ok := gatewayFieldsEnvelope(env)
if !ok {
s.recordFieldsMetric(fieldsStatus)
} else {
if err := s.sink.PublishFields(frameCtx, fieldsEnv); err != nil {
s.recordFieldsMetric(gatewayFieldsPublishError)
s.recordFieldsCount(gatewayFieldsPublishError, len(fieldsEnv.Fields))
s.recordPublishMetric("fields", "error")
s.logger.Error("publish fields failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
} else {
s.recordFieldsMetric(fieldsStatus)
s.recordFieldsCount(fieldsStatus, len(fieldsEnv.Fields))
s.recordPublishMetric("fields", "ok")
}
}
s.recordPublishMetric("fields", "ok")
}
if s.publishUnified {
if err := s.sink.PublishUnified(frameCtx, env); err != nil {
if err := s.sink.PublishUnified(frameCtx, canonicalRaw); err != nil {
s.recordPublishMetric("unified", "error")
s.logger.Error("publish unified failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
} else {
s.recordPublishMetric("unified", "ok")
}
s.recordPublishMetric("unified", "ok")
}
}
func (s *TCPServer) writeProtocolResponse(conn net.Conn, raw []byte, env envelope.FrameEnvelope, frameStarted time.Time) {
if s.protocol.Respond == nil {
return
}
status := "skipped"
defer func() {
s.recordResponseDuration(env.MessageID, status, time.Since(frameStarted))
}()
response, ok, err := s.protocol.Respond(raw, env)
if err != nil {
status = "build_error"
s.recordResponseMetric(env.MessageID, "build_error")
s.logger.Warn("build protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
}
if !ok || len(response) == 0 {
s.recordResponseMetric(env.MessageID, "skipped")
return
}
if conn == nil {
status = "write_error"
s.recordResponseMetric(env.MessageID, "write_error")
s.logger.Warn("write protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", "nil connection")
return
}
_ = conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if _, err := conn.Write(response); err != nil {
status = "write_error"
s.recordResponseMetric(env.MessageID, "write_error")
s.logger.Warn("write protocol response failed", "protocol", s.protocol.Protocol, "event_id", env.StableEventID(), "error", err)
return
}
status = "ok"
s.recordResponseMetric(env.MessageID, "ok")
}
func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionState) {
@@ -299,6 +434,9 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
if env.Fields == nil {
env.Fields = map[string]any{}
}
if shouldCloseAfterAuthentication(*env) {
return
}
if current := strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])); current != "" && current != "<nil>" {
state.platformName = current
}
@@ -308,6 +446,15 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
if strings.TrimSpace(fmt.Sprint(env.Fields["platform_account"])) == "" || fmt.Sprint(env.Fields["platform_account"]) == "<nil>" {
env.Fields["platform_account"] = state.platformName
}
if strings.TrimSpace(env.PlatformName) == "" {
env.PlatformName = state.platformName
}
if strings.TrimSpace(env.SourceCode) == "" {
env.SourceCode = sourceCodeFromPlatformName(state.platformName)
}
if strings.TrimSpace(env.SourceKind) == "" || strings.EqualFold(strings.TrimSpace(env.SourceKind), "UNKNOWN") {
env.SourceKind = "PLATFORM"
}
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
@@ -316,14 +463,37 @@ func enrichConnectionPlatform(env *envelope.FrameEnvelope, state *connectionStat
}
}
func sourceCodeFromPlatformName(value string) string {
value = strings.TrimSpace(value)
if value == "" {
return ""
}
var builder strings.Builder
for _, r := range value {
switch {
case r >= 'a' && r <= 'z':
builder.WriteRune(r)
case r >= 'A' && r <= 'Z':
builder.WriteRune(r)
case r >= '0' && r <= '9':
builder.WriteRune(r)
case r == '_' || r == '-' || r == '.':
builder.WriteRune(r)
}
}
return builder.String()
}
func (s *TCPServer) recordFrameMetric(status envelope.ParseStatus) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_frames_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"status": string(status),
})
}
s.metrics.IncCounter("vehicle_gateway_frames_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_frame_unix_seconds", labels)
}
func (s *TCPServer) recordFrameDuration(status envelope.ParseStatus, elapsed time.Duration) {
@@ -346,10 +516,74 @@ func (s *TCPServer) recordPublishMetric(kind string, status string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_publish_total", metrics.Labels{
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"kind": kind,
"status": status,
}
s.metrics.IncCounter("vehicle_gateway_publish_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_publish_unix_seconds", labels)
}
func (s *TCPServer) recordFieldsMetric(status string) {
recordGatewayFieldsMetric(s.metrics, s.protocol.Protocol, status)
}
func (s *TCPServer) recordFieldsCount(status string, fieldCount int) {
recordGatewayFieldsCount(s.metrics, s.protocol.Protocol, status, fieldCount)
}
func (s *TCPServer) recordResponseMetric(messageID string, status string) {
if s.metrics == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
messageID = "unknown"
}
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"message_id": messageID,
"status": status,
}
s.metrics.IncCounter("vehicle_gateway_response_total", labels)
metrics.RecordLastActivity(s.metrics, "vehicle_gateway_last_response_unix_seconds", labels)
}
func (s *TCPServer) recordResponseDuration(messageID string, status string, elapsed time.Duration) {
if s.metrics == nil {
return
}
messageID = strings.TrimSpace(messageID)
if messageID == "" {
messageID = "unknown"
}
elapsedMS := float64(elapsed.Microseconds()) / 1000
labels := metrics.Labels{
"protocol": string(s.protocol.Protocol),
"message_id": messageID,
"status": status,
}
s.metrics.ObserveHistogram("vehicle_gateway_response_duration_ms_histogram", labels, gatewayResponseDurationBucketsMS, elapsedMS)
if status != "ok" {
return
}
protocol := string(s.protocol.Protocol)
p99, samples := gatewayResponseE2ERecent.Observe(protocol, elapsedMS)
protocolLabels := metrics.Labels{"protocol": protocol}
s.metrics.SetGauge("vehicle_gateway_response_e2e_recent_p99_ms", protocolLabels, p99)
s.metrics.SetGauge("vehicle_gateway_response_e2e_recent_samples", protocolLabels, float64(samples))
}
func (s *TCPServer) recordAuthenticationMetric(result authentication.Result) {
if s.metrics == nil || !result.Applicable {
return
}
s.metrics.IncCounter("vehicle_gateway_authentication_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"mode": string(result.Mode),
"source": result.Source,
"status": result.Status,
})
}
@@ -373,6 +607,40 @@ func (s *TCPServer) recordIdentityMetric(status string) {
})
}
func (s *TCPServer) recordIdentitySkipMetric(reason string) {
if s.metrics == nil {
return
}
s.metrics.IncCounter("vehicle_gateway_identity_skips_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"reason": reason,
})
}
func (s *TCPServer) recordIdentityIssueMetric(status string, env envelope.FrameEnvelope, reason string) {
if s.metrics == nil {
return
}
messageID := strings.TrimSpace(env.MessageID)
if messageID == "" {
messageID = "unknown"
}
reason = strings.TrimSpace(reason)
if reason == "" {
reason = "unknown"
}
s.metrics.IncCounter("vehicle_gateway_identity_issues_total", metrics.Labels{
"protocol": string(s.protocol.Protocol),
"status": status,
"message_id": messageID,
"reason": reason,
})
}
func (s *TCPServer) recordIdentityDuration(status string, elapsed time.Duration) {
recordGatewayIdentityDuration(s.metrics, s.protocol.Protocol, status, elapsed)
}
func (s *TCPServer) recordConnectionMetric(delta float64) {
if s.metrics == nil {
return
@@ -402,6 +670,12 @@ func (s *TCPServer) recordConnectionClose(reason string) {
})
}
func shouldCloseAfterAuthentication(env envelope.FrameEnvelope) bool {
return env.AuthenticationEnforced &&
strings.TrimSpace(env.AuthenticationStatus) != "" &&
env.AuthenticationStatus != authentication.StatusAccepted
}
func identityStatus(env envelope.FrameEnvelope) string {
if strings.TrimSpace(env.VIN) != "" {
return "resolved"
@@ -412,6 +686,28 @@ func identityStatus(env envelope.FrameEnvelope) string {
return "unresolved"
}
func identityIssueReason(status string, err error) string {
switch status {
case "timeout":
return "timeout"
case "error":
if err == nil {
return "resolver_error"
}
text := strings.ToLower(strings.TrimSpace(err.Error()))
switch {
case strings.Contains(text, "connection"), strings.Contains(text, "tcp"), strings.Contains(text, "network"):
return "identity_store_connection"
case strings.Contains(text, "timeout"), strings.Contains(text, "deadline"):
return "timeout"
default:
return "resolver_error"
}
default:
return "no_binding"
}
}
func annotateIdentityUnresolved(env *envelope.FrameEnvelope) {
if env == nil || env.ParseStatus == envelope.ParseBadFrame || strings.TrimSpace(env.VIN) != "" {
return

View File

@@ -3,20 +3,48 @@ package gateway
import (
"context"
"encoding/hex"
"errors"
"fmt"
"io"
"log/slog"
"net"
"os"
"strings"
"syscall"
"testing"
"time"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/authentication"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/gb32960"
"lingniu-vehicle-ingest/go/vehicle-gateway/internal/protocol/jt808"
)
func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
func TestEnrichConnectionPlatformPromotesSourceMetadata(t *testing.T) {
env := envelope.FrameEnvelope{
Protocol: envelope.ProtocolGB32960,
MessageID: "0x02",
ParseStatus: envelope.ParseOK,
Fields: map[string]any{},
Parsed: map[string]any{},
}
state := &connectionState{platformName: "Hyundai"}
enrichConnectionPlatform(&env, state)
if env.PlatformName != "Hyundai" || env.SourceCode != "Hyundai" || env.SourceKind != "PLATFORM" {
t.Fatalf("source metadata = code:%q platform:%q kind:%q", env.SourceCode, env.PlatformName, env.SourceKind)
}
if got := fmt.Sprint(env.Fields["platform_account"]); got != "Hyundai" {
t.Fatalf("platform_account = %q", got)
}
if got := fmt.Sprint(env.Parsed["platform_name"]); got != "Hyundai" {
t.Fatalf("parsed platform_name = %q", got)
}
}
func TestTCPServerPublishesGoodFrameToRawAndFieldsByDefault(t *testing.T) {
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
if err != nil {
t.Fatal(err)
@@ -28,6 +56,7 @@ func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
}, sink)
server.resolver = vinResolver{vin: "LNBVIN00000000001"}
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
@@ -39,11 +68,71 @@ func TestTCPServerPublishesGoodFrameOnlyToRawByDefault(t *testing.T) {
if len(sink.raw) != 1 || len(sink.unified) != 0 {
t.Fatalf("raw=%d unified=%d", len(sink.raw), len(sink.unified))
}
if len(sink.fields) != 1 {
t.Fatalf("fields=%d, want 1", len(sink.fields))
}
if sink.raw[0].Phone != "13307795425" {
t.Fatalf("phone = %q", sink.raw[0].Phone)
}
if sink.raw[0].Fields[envelope.FieldTotalMileageKM] != 10241.2 {
t.Fatalf("total mileage = %#v", sink.raw[0].Fields[envelope.FieldTotalMileageKM])
if sink.raw[0].EventKind != envelope.EventKindRaw {
t.Fatalf("raw event kind = %q, want %q", sink.raw[0].EventKind, envelope.EventKindRaw)
}
if len(sink.raw[0].Fields) != 0 {
t.Fatalf("canonical raw must not carry bare standardized fields: %#v", sink.raw[0].Fields)
}
if got := sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got == nil {
t.Fatalf("raw parsed fields missing total mileage: %#v", sink.raw[0].ParsedFields)
}
if got, want := sink.fields[0].Fields["jt808.location.total_mileage_km"], sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got != want {
t.Fatalf("fields event should reuse raw parsed field, got %#v want %#v", got, want)
}
if sink.fields[0].EventKind != envelope.EventKindFields {
t.Fatalf("fields event kind = %q, want %q", sink.fields[0].EventKind, envelope.EventKindFields)
}
if got, want := sink.fields[0].SourceEventID, sink.raw[0].StableEventID(); got != want {
t.Fatalf("fields source event id = %#v, want %s", got, want)
}
if sink.fields[0].FieldMapping == "" {
t.Fatal("fields event should expose field mapping version")
}
if len(sink.fields[0].Parsed) != 0 || len(sink.fields[0].ParsedFields) != 0 {
t.Fatalf("fields envelope should not duplicate parsed payload: parsed=%#v parsed_fields=%#v", sink.fields[0].Parsed, sink.fields[0].ParsedFields)
}
}
func TestTCPServerRecordsFieldsPublishedMetric(t *testing.T) {
frame, err := hex.DecodeString("7E020000320133077954250001000000000048000301D2C4C707376139000A00E6004F26063016235701040001900C2504000000000202000030011F31010F867E")
if err != nil {
t.Fatal(err)
}
sink := &recordingSink{}
registry := metrics.NewRegistry()
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = vinResolver{vin: "LNBVIN00000000001"}
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
t.Fatalf("client.Write() error = %v", err)
}
_ = client.Close()
<-done
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_fields_total{protocol="JT808",status="published"} 1`,
`vehicle_gateway_fields_count{protocol="JT808",status="published"} `,
`vehicle_gateway_fields_count_histogram_count{protocol="JT808",status="published"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="ok"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
@@ -79,8 +168,16 @@ func TestTCPServerRecordsFrameMetrics(t *testing.T) {
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_frames_total{protocol="JT808",status="OK"} 1`,
`vehicle_gateway_last_frame_unix_seconds{protocol="JT808",status="OK"} `,
`vehicle_gateway_identity_total{protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="no_binding",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="JT808",status="unresolved"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="JT808",status="unresolved"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_sum{protocol="JT808",status="unresolved"}`,
`vehicle_gateway_publish_total{kind="raw",protocol="JT808",status="ok"} 1`,
`vehicle_gateway_last_publish_unix_seconds{kind="raw",protocol="JT808",status="ok"} `,
`vehicle_gateway_fields_total{protocol="JT808",status="skipped_non_realtime"} 1`,
`vehicle_gateway_frame_duration_ms{protocol="JT808",status="OK"}`,
`vehicle_gateway_frame_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="OK"} 1`,
`vehicle_gateway_frame_duration_ms_histogram_count{protocol="JT808",status="OK"} 1`,
@@ -95,6 +192,244 @@ func TestTCPServerRecordsFrameMetrics(t *testing.T) {
}
}
func TestTCPServerRecordsNonRealtimeFieldsSkipMetric(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.handleFrame(context.Background(), nil, buildGBFrame(0x07, 0xfe, "LNBSCB3D4R1234567", nil), "127.0.0.1:32960", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if len(sink.fields) != 0 {
t.Fatalf("fields=%d, want 0", len(sink.fields))
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_fields_total{protocol="GB32960",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestTCPServerSkipsIdentityForGB32960PlatformControlFrame(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
resolver := &countingResolver{vin: "LNBVIN00000000001"}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = resolver
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "PLATFORMLOGIN0001", nil), "127.0.0.1:32960", &connectionState{})
if resolver.calls != 0 {
t.Fatalf("resolver calls = %d, want 0 for platform control frame", resolver.calls)
}
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
text := registry.Render()
if strings.Contains(text, "vehicle_gateway_identity_total") {
t.Fatalf("identity metric should not be recorded for platform control frame:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_identity_skips_total{protocol="GB32960",reason="non_vehicle_frame"} 1`) {
t.Fatalf("identity skip metric missing:\n%s", text)
}
if !strings.Contains(text, `vehicle_gateway_fields_total{protocol="GB32960",status="skipped_non_realtime"} 1`) {
t.Fatalf("non realtime fields skip metric missing:\n%s", text)
}
}
func TestTCPServerAuthenticatesAndRedactsGB32960PlatformLogin(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
Authenticate: authentication.NewGB32960PlatformAuthenticator(authentication.ModeObserve, map[string][]string{"platform-a": {"secret-a"}}),
}, sink)
server.metrics = registry
body := []byte{0x1a, 0x07, 0x0d, 0x14, 0x00, 0x00, 0x00, 0x01}
body = append(body, fixedASCIIBytes("platform-a", 12)...)
body = append(body, fixedASCIIBytes("secret-a", 20)...)
body = append(body, 0x01)
server.handleFrame(context.Background(), nil, buildGBFrame(0x05, 0xfe, "12345678901234567", body), "127.0.0.1:32960", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
raw := sink.raw[0]
if raw.AuthenticationMode != "observe" || raw.AuthenticationStatus != authentication.StatusAccepted || raw.AuthenticationEnforced {
t.Fatalf("authentication metadata = mode:%q status:%q enforced:%v", raw.AuthenticationMode, raw.AuthenticationStatus, raw.AuthenticationEnforced)
}
if _, exists := raw.ParsedFields["gb32960.platform_login.password"]; exists {
t.Fatalf("plaintext password leaked into parsed fields: %#v", raw.ParsedFields)
}
if got := raw.ParsedFields["gb32960.platform_login.password_present"]; got != "true" {
t.Fatalf("password presence marker = %#v", got)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_authentication_total{mode="observe",protocol="GB32960",source="configured",status="accepted"} 1`) {
t.Fatalf("authentication metric missing:\n%s", text)
}
}
func TestTCPServerResolvesIdentityForGB32960RealtimeFrame(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
resolver := &countingResolver{vin: "LNBVIN00000000001"}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
Addr: ":0",
Extract: gb32960.ExtractFrames,
Parse: gb32960.ParseFrame,
}, sink)
server.metrics = registry
server.resolver = resolver
server.handleFrame(context.Background(), nil, buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil), "127.0.0.1:32960", &connectionState{})
if resolver.calls != 1 {
t.Fatalf("resolver calls = %d, want 1 for realtime frame", resolver.calls)
}
if len(sink.raw) != 1 {
t.Fatalf("raw=%d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LNBVIN00000000001" {
t.Fatalf("raw vin = %q, want resolved vin", sink.raw[0].VIN)
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_identity_total{protocol="GB32960",status="resolved"} 1`) {
t.Fatalf("identity resolved metric missing:\n%s", text)
}
}
func TestTCPServerRecordsIdentityTimeoutMetrics(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParseStatus: envelope.ParseOK,
}, nil
},
},
Sink: &recordingSink{},
Resolver: errorResolver{err: context.DeadlineExceeded},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="timeout",status="timeout"} 1`,
`vehicle_gateway_identity_duration_ms{protocol="JT808",status="timeout"}`,
`vehicle_gateway_identity_duration_ms_histogram_bucket{le="+Inf",protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_identity_duration_ms_histogram_count{protocol="JT808",status="timeout"} 1`,
`vehicle_gateway_frames_total{protocol="JT808",status="PARTIAL"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metrics missing %s:\n%s", want, text)
}
}
}
func TestTCPServerPreservesResolvedEnvelopeWhenIdentitySideEffectFails(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{}
wantErr := errors.New("registration upsert failed")
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
},
ParseStatus: envelope.ParseOK,
}, nil
},
},
Sink: sink,
Resolver: resolvedErrorResolver{vin: "LNBVIN00000000001", err: wantErr},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
server.handleFrame(context.Background(), nil, []byte{0x01}, "127.0.0.1:808", &connectionState{})
if len(sink.raw) != 1 {
t.Fatalf("raw count = %d, want 1", len(sink.raw))
}
if sink.raw[0].VIN != "LNBVIN00000000001" {
t.Fatalf("raw vin = %q, want resolved vin", sink.raw[0].VIN)
}
if sink.raw[0].ParseStatus != envelope.ParsePartial {
t.Fatalf("parse status = %q, want PARTIAL", sink.raw[0].ParseStatus)
}
if len(sink.raw[0].Parsed) != 0 {
t.Fatalf("canonical raw should not duplicate parsed tree: %#v", sink.raw[0].Parsed)
}
if got := sink.raw[0].ParsedFields["jt808.location.total_mileage_km"]; got != "10241.2" {
t.Fatalf("protocol field lost after identity side-effect failure: %#v", got)
}
for field := range sink.raw[0].ParsedFields {
if strings.HasPrefix(field, "jt808.identity.") {
t.Fatalf("derived identity annotation leaked into protocol fields: %s", field)
}
}
if len(sink.fields) != 1 || sink.fields[0].VIN != "LNBVIN00000000001" {
t.Fatalf("fields should preserve resolved vin, fields=%#v", sink.fields)
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_identity_total{protocol="JT808",status="error"} 1`,
`vehicle_gateway_identity_issues_total{message_id="0x0200",protocol="JT808",reason="resolver_error",status="error"} 1`,
`vehicle_gateway_frames_total{protocol="JT808",status="PARTIAL"} 1`,
`vehicle_gateway_fields_total{protocol="JT808",status="published"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
@@ -140,6 +475,45 @@ func TestTCPServerRecordsActiveConnectionGauge(t *testing.T) {
}
}
func TestTCPServerClosesActiveConnectionWhenContextCancelled(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
Protocol: TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: jt808.ExtractFrames,
Parse: jt808.ParseFrame,
},
Sink: &recordingSink{},
Logger: slog.New(slog.NewTextHandler(testWriter{t: t}, nil)),
Metrics: registry,
IdleTimeout: time.Minute,
})
if err != nil {
t.Fatalf("NewTCPServer() error = %v", err)
}
ctx, cancel := context.WithCancel(context.Background())
client, srv := net.Pipe()
done := make(chan struct{})
go func() {
server.handleConnection(ctx, srv)
close(done)
}()
waitForMetric(t, registry, `vehicle_gateway_active_connections{protocol="JT808"} 1`)
cancel()
server.closeActiveConnections()
defer client.Close()
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("connection handler did not exit after context cancellation")
}
if text := registry.Render(); !strings.Contains(text, `vehicle_gateway_connection_closes_total{protocol="JT808",reason="context_cancelled"} 1`) {
t.Fatalf("context cancellation close metric missing:\n%s", text)
}
}
func TestTCPServerRecordsConnectionRejectionMetric(t *testing.T) {
registry := metrics.NewRegistry()
server, err := NewTCPServer(TCPServerConfig{
@@ -188,6 +562,57 @@ func TestTCPServerRecordsConnectionCloseReasonMetric(t *testing.T) {
}
}
func TestTCPServerConnectionLifecycleLogsStayDebug(t *testing.T) {
source, err := os.ReadFile("tcp_server.go")
if err != nil {
t.Fatal(err)
}
text := string(source)
for _, forbidden := range []string{
`Info("tcp connection opened"`,
`Info("tcp connection closed"`,
`Warn("tcp connection idle timeout"`,
`Warn("tcp connection closed by peer"`,
} {
if strings.Contains(text, forbidden) {
t.Fatalf("high-volume connection lifecycle log should not be info/warn: %s", forbidden)
}
}
for _, want := range []string{
`Debug("tcp connection opened"`,
`Debug("tcp connection closed"`,
`Debug("tcp connection idle timeout"`,
`Debug("tcp connection closed by peer"`,
} {
if !strings.Contains(text, want) {
t.Fatalf("connection lifecycle log should remain debug: %s", want)
}
}
}
func TestIsRoutineTCPReadCloseClassifiesRemoteCloseNoise(t *testing.T) {
tests := []struct {
name string
err error
want bool
}{
{name: "eof", err: io.EOF, want: true},
{name: "net closed", err: net.ErrClosed, want: true},
{name: "reset wrapped", err: fmt.Errorf("read tcp: %w", syscall.ECONNRESET), want: true},
{name: "pipe wrapped", err: fmt.Errorf("write tcp: %w", syscall.EPIPE), want: true},
{name: "reset text", err: errors.New("read tcp 172.17.111.55:808->117.132.196.176:22187: read: connection reset by peer"), want: true},
{name: "unexpected", err: errors.New("checksum parser exploded"), want: false},
{name: "nil", err: nil, want: false},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := isRoutineTCPReadClose(tt.err); got != tt.want {
t.Fatalf("isRoutineTCPReadClose(%v) = %v, want %v", tt.err, got, tt.want)
}
})
}
}
func TestTCPServerPublishesBadFrameOnlyToRaw(t *testing.T) {
good := buildGBFrame(0x02, 0xfe, "LNBSCB3D4R1234567", nil)
good[len(good)-1] ^= 0xff
@@ -248,14 +673,14 @@ func TestTCPServerAnnotatesUnresolvedIdentity(t *testing.T) {
if len(sink.raw) != 1 {
t.Fatalf("raw count = %d", len(sink.raw))
}
identity, ok := sink.raw[0].Parsed["identity"].(map[string]any)
if !ok || identity["resolved"] != false || identity["reason"] != "no_binding" {
t.Fatalf("identity metadata = %#v", sink.raw[0].Parsed["identity"])
if len(sink.raw[0].Parsed) != 0 || len(sink.raw[0].ParsedFields) != 0 {
t.Fatalf("unresolved derived metadata must not enter canonical protocol payload: parsed=%#v fields=%#v", sink.raw[0].Parsed, sink.raw[0].ParsedFields)
}
}
func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
frame := buildGBFrame(0x07, 0xfe, "LNBSCB3D4R1234567", nil)
registry := metrics.NewRegistry()
sink := &recordingSink{}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolGB32960,
@@ -269,6 +694,7 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
return []byte("ACK"), true, nil
},
}, sink)
server.metrics = registry
client, done := runPipe(t, server)
if _, err := client.Write(frame); err != nil {
@@ -283,6 +709,79 @@ func TestTCPServerWritesProtocolResponseAfterPublish(t *testing.T) {
}
_ = client.Close()
<-done
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_response_total{message_id="0x07",protocol="GB32960",status="ok"} 1`,
`vehicle_gateway_last_response_unix_seconds{message_id="0x07",protocol="GB32960",status="ok"} `,
`vehicle_gateway_response_duration_ms_histogram_count{message_id="0x07",protocol="GB32960",status="ok"} 1`,
`vehicle_gateway_response_e2e_recent_p99_ms{protocol="GB32960"} `,
`vehicle_gateway_response_e2e_recent_samples{protocol="GB32960"} `,
} {
if !strings.Contains(text, want) {
t.Fatalf("response metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerWritesProtocolResponseWhenFieldsPublishFails(t *testing.T) {
registry := metrics.NewRegistry()
sink := &recordingSink{fieldsErr: errors.New("fields queue full")}
server := newTestServer(t, TCPProtocol{
Protocol: envelope.ProtocolJT808,
Addr: ":0",
Extract: func(raw []byte) ([][]byte, []byte, error) {
return [][]byte{raw}, nil, nil
},
Parse: func(_ []byte, receivedAtMS int64, sourceEndpoint string) (envelope.FrameEnvelope, error) {
return envelope.FrameEnvelope{
Protocol: envelope.ProtocolJT808,
MessageID: "0x0200",
VIN: "LNBVIN00000000001",
Phone: "13307795425",
SourceEndpoint: sourceEndpoint,
ReceivedAtMS: receivedAtMS,
EventTimeMS: receivedAtMS,
ParsedFields: map[string]any{
"jt808.location.total_mileage_km": "10241.2",
},
ParseStatus: envelope.ParseOK,
}, nil
},
Respond: func(_ []byte, env envelope.FrameEnvelope) ([]byte, bool, error) {
if len(sink.raw) != 1 || sink.raw[0].EventID != env.EventID {
t.Fatalf("response built before raw publish: raw=%d", len(sink.raw))
}
return []byte("ACK"), true, nil
},
}, sink)
server.metrics = registry
client, done := runPipe(t, server)
if _, err := client.Write([]byte{0x01}); err != nil {
t.Fatalf("client.Write() error = %v", err)
}
buf := make([]byte, 3)
if _, err := io.ReadFull(client, buf); err != nil {
t.Fatalf("read response error = %v", err)
}
if string(buf) != "ACK" {
t.Fatalf("response = %q", string(buf))
}
_ = client.Close()
<-done
if len(sink.fields) != 1 {
t.Fatalf("fields publish attempts = %d, want 1", len(sink.fields))
}
text := registry.Render()
for _, want := range []string{
`vehicle_gateway_response_total{message_id="0x0200",protocol="JT808",status="ok"} 1`,
`vehicle_gateway_fields_total{protocol="JT808",status="publish_error"} 1`,
`vehicle_gateway_publish_total{kind="fields",protocol="JT808",status="error"} 1`,
} {
if !strings.Contains(text, want) {
t.Fatalf("metric missing %s:\n%s", want, text)
}
}
}
func TestTCPServerUsesUncancelledFrameContextForAlreadyReadFrame(t *testing.T) {
@@ -368,6 +867,49 @@ func (r *contextCheckingResolver) Resolve(ctx context.Context, env envelope.Fram
return env, r.ctxErr
}
type vinResolver struct {
vin string
}
func (r vinResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
env.VIN = r.vin
return env, nil
}
type countingResolver struct {
calls int
vin string
}
func (r *countingResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
r.calls++
env.VIN = r.vin
return env, nil
}
type errorResolver struct {
err error
}
func (r errorResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
return env, r.err
}
type resolvedErrorResolver struct {
vin string
err error
}
func (r resolvedErrorResolver) Resolve(_ context.Context, env envelope.FrameEnvelope) (envelope.FrameEnvelope, error) {
env.VIN = r.vin
if env.Parsed == nil {
env.Parsed = map[string]any{}
}
env.Parsed["identity"] = map[string]any{"resolved": true, "source": "test"}
env.EventID = env.StableEventID()
return env, r.err
}
type contextCheckingSink struct {
rawCtxErr error
unifiedCtxErr error
@@ -426,25 +968,40 @@ func runPipe(t *testing.T, server *TCPServer) (net.Conn, <-chan struct{}) {
return client, done
}
func waitForMetric(t *testing.T, registry *metrics.Registry, want string) {
t.Helper()
deadline := time.Now().Add(2 * time.Second)
for time.Now().Before(deadline) {
if strings.Contains(registry.Render(), want) {
return
}
time.Sleep(10 * time.Millisecond)
}
t.Fatalf("metric missing %s:\n%s", want, registry.Render())
}
type recordingSink struct {
raw []envelope.FrameEnvelope
unified []envelope.FrameEnvelope
fields []envelope.FrameEnvelope
raw []envelope.FrameEnvelope
unified []envelope.FrameEnvelope
fields []envelope.FrameEnvelope
rawErr error
unifiedErr error
fieldsErr error
}
func (s *recordingSink) PublishRaw(_ context.Context, env envelope.FrameEnvelope) error {
s.raw = append(s.raw, env)
return nil
return s.rawErr
}
func (s *recordingSink) PublishUnified(_ context.Context, env envelope.FrameEnvelope) error {
s.unified = append(s.unified, env)
return nil
return s.unifiedErr
}
func (s *recordingSink) PublishFields(_ context.Context, env envelope.FrameEnvelope) error {
s.fields = append(s.fields, env)
return nil
return s.fieldsErr
}
func (s *recordingSink) Close() error {
@@ -475,3 +1032,9 @@ func buildGBFrame(command byte, response byte, vin string, body []byte) []byte {
}
return append(frame, bcc)
}
func fixedASCIIBytes(value string, size int) []byte {
out := make([]byte, size)
copy(out, []byte(value))
return out
}