package main import ( "context" "encoding/json" "errors" "fmt" "log/slog" "os" "os/signal" "sort" "strconv" "strings" "syscall" "time" "github.com/nats-io/nats.go" "github.com/segmentio/kafka-go" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/envelope" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/health" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/metrics" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/observability" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/realtime" "lingniu-vehicle-ingest/go/vehicle-gateway/internal/topics" ) func main() { logger := observability.NewLogger("nats-kafka-bridge") ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM) defer stop() cfg := loadConfig() if err := cfg.Validate(); err != nil { logger.Error("invalid bridge config", "error", err) os.Exit(1) } conn, err := nats.Connect(cfg.NATSURL, nats.Name(cfg.NATSClientName), nats.Timeout(5*time.Second)) if err != nil { logger.Error("nats connect failed", "error", err) os.Exit(1) } defer conn.Close() registry := metrics.NewRegistry() recordBridgeConfigMetrics(registry, cfg) health.Start(ctx, logger, health.NewServer(env("HEALTH_ADDR", ""), "nats-kafka-bridge", []health.Check{ {Name: "nats", Check: func(context.Context) error { if conn.Status() != nats.CONNECTED { return fmt.Errorf("nats status is %s", conn.Status().String()) } return nil }}, }, registry)) js, err := conn.JetStream() if err != nil { logger.Error("nats jetstream init failed", "error", err) os.Exit(1) } if err := ensureStream(js, cfg); err != nil { logger.Error("nats stream ensure failed", "stream", cfg.NATSStream, "error", err) os.Exit(1) } sub, err := js.PullSubscribe( cfg.NATSFilter, cfg.NATSDurable, nats.BindStream(cfg.NATSStream), nats.ManualAck(), nats.AckWait(cfg.AckWait), nats.MaxDeliver(-1), ) if err != nil { logger.Error("nats pull consumer init failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err) os.Exit(1) } logger.Info("nats kafka bridge started", "nats_url", cfg.NATSURL, "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "filter", cfg.NATSFilter, "kafka_brokers", strings.Join(cfg.KafkaBrokers, ","), "kafka_batch_timeout_ms", cfg.KafkaBatchTimeout.Milliseconds(), "kafka_write_concurrency", cfg.KafkaWriteConcurrency, "fetch_wait_ms", cfg.FetchWait.Milliseconds(), "batch_size", cfg.BatchSize, "workers", cfg.Workers, "derive_fields_from_raw_enabled", cfg.DeriveFieldsFromRaw) for i := 0; i < cfg.Workers; i++ { writer := newKafkaWriter(cfg) defer writer.Close() go runBridge(ctx, logger.With("worker", i), registry, js, sub, writer, cfg) } <-ctx.Done() } type config struct { NATSURL string NATSClientName string NATSStream string NATSDurable string NATSFilter string NATSSubjects []string KafkaBrokers []string RawRoutes []subjectRoute FieldsRoutes []subjectRoute Route map[string]string RawFieldRoutes map[string]fieldsProjectionRoute DeriveFieldsFromRaw bool KafkaBatchTimeout time.Duration KafkaWriteConcurrency int BatchSize int FetchWait time.Duration OperationWait time.Duration AckWait time.Duration StreamMaxAge time.Duration StreamMaxBytes int64 StreamEnsureWait time.Duration Workers int } type subjectRoute struct { Protocol envelope.Protocol Subject string Topic string } type fieldsProjectionRoute struct { Protocol envelope.Protocol Topic string } func loadConfig() config { rawRoutes := []subjectRoute{ {Protocol: envelope.ProtocolGB32960, Subject: env("NATS_SUBJECT_GB32960_RAW", env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)), Topic: env("KAFKA_TOPIC_GB32960_RAW", topics.RawGB32960)}, {Protocol: envelope.ProtocolJT808, Subject: env("NATS_SUBJECT_JT808_RAW", env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)), Topic: env("KAFKA_TOPIC_JT808_RAW", topics.RawJT808)}, {Protocol: envelope.ProtocolYutongMQTT, Subject: env("NATS_SUBJECT_YUTONG_MQTT_RAW", env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)), Topic: env("KAFKA_TOPIC_YUTONG_MQTT_RAW", topics.RawYutongMQTT)}, } fieldsRoutes := []subjectRoute{ {Protocol: envelope.ProtocolGB32960, Subject: env("NATS_SUBJECT_GB32960_FIELDS", env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)), Topic: env("KAFKA_TOPIC_GB32960_FIELDS", topics.FieldsGB32960)}, {Protocol: envelope.ProtocolJT808, Subject: env("NATS_SUBJECT_JT808_FIELDS", env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)), Topic: env("KAFKA_TOPIC_JT808_FIELDS", topics.FieldsJT808)}, {Protocol: envelope.ProtocolYutongMQTT, Subject: env("NATS_SUBJECT_YUTONG_MQTT_FIELDS", env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)), Topic: env("KAFKA_TOPIC_YUTONG_MQTT_FIELDS", topics.FieldsYutongMQTT)}, } route := routeMap(rawRoutes, fieldsRoutes) if unifiedSubject, unifiedTopic, ok := unifiedRouteFromEnv(); ok { route[unifiedSubject] = unifiedTopic } workers := envInt("BRIDGE_WORKERS", 4) if workers < 1 { workers = 1 } return config{ NATSURL: env("NATS_URL", "nats://127.0.0.1:4222"), NATSClientName: env("NATS_CLIENT_NAME", "lingniu-nats-kafka-bridge"), NATSStream: env("NATS_STREAM", "VEHICLE_INGEST"), NATSDurable: env("NATS_DURABLE", "vehicle-kafka-bridge"), NATSFilter: env("NATS_FILTER", "vehicle.>"), NATSSubjects: splitCSV(env("NATS_STREAM_SUBJECTS", strings.Join(mapKeys(route), ","))), KafkaBrokers: splitCSV(env("KAFKA_BROKERS", "127.0.0.1:9092")), RawRoutes: rawRoutes, FieldsRoutes: fieldsRoutes, Route: route, RawFieldRoutes: rawFieldProjectionRoutes(rawRoutes, fieldsRoutes), DeriveFieldsFromRaw: envBool("BRIDGE_DERIVE_FIELDS_FROM_RAW_ENABLED", true), KafkaBatchTimeout: time.Duration(envInt("BRIDGE_KAFKA_BATCH_TIMEOUT_MS", 20)) * time.Millisecond, KafkaWriteConcurrency: envInt("BRIDGE_KAFKA_WRITE_CONCURRENCY", 6), BatchSize: envInt("BRIDGE_BATCH_SIZE", 500), FetchWait: time.Duration(envInt("BRIDGE_FETCH_WAIT_MS", 20)) * time.Millisecond, OperationWait: time.Duration(envInt("BRIDGE_OPERATION_TIMEOUT_MS", 30000)) * time.Millisecond, AckWait: time.Duration(envInt("NATS_ACK_WAIT_SECONDS", 60)) * time.Second, StreamMaxAge: time.Duration(envInt("NATS_STREAM_MAX_AGE_HOURS", 24)) * time.Hour, StreamMaxBytes: envInt64("NATS_STREAM_MAX_BYTES", 20*1024*1024*1024), StreamEnsureWait: time.Duration(envInt("NATS_STREAM_ENSURE_TIMEOUT_SECONDS", 60)) * time.Second, Workers: workers, } } func recordBridgeConfigMetrics(registry *metrics.Registry, cfg config) { if registry == nil { return } registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "workers"}, float64(cfg.Workers)) registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "batch_size"}, float64(cfg.BatchSize)) registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "fetch_wait_ms"}, float64(cfg.FetchWait.Milliseconds())) registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "kafka_batch_timeout_ms"}, float64(cfg.KafkaBatchTimeout.Milliseconds())) registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "kafka_write_concurrency"}, float64(cfg.KafkaWriteConcurrency)) registry.SetGauge("vehicle_bridge_config", metrics.Labels{"setting": "derive_fields_from_raw_enabled"}, boolMetric(cfg.DeriveFieldsFromRaw)) } func newKafkaWriter(cfg config) *kafka.Writer { return &kafka.Writer{ Addr: kafka.TCP(cfg.KafkaBrokers...), Balancer: &kafka.Hash{}, AllowAutoTopicCreation: false, RequiredAcks: kafka.RequireAll, BatchTimeout: cfg.KafkaBatchTimeout, Async: false, } } func routeMap(rawRoutes []subjectRoute, fieldsRoutes []subjectRoute) map[string]string { route := make(map[string]string, len(rawRoutes)+len(fieldsRoutes)) for _, item := range rawRoutes { if item.Subject != "" { route[item.Subject] = item.Topic } } for _, item := range fieldsRoutes { if item.Subject != "" { route[item.Subject] = item.Topic } } return route } func rawFieldProjectionRoutes(rawRoutes []subjectRoute, fieldsRoutes []subjectRoute) map[string]fieldsProjectionRoute { fieldsByProtocol := make(map[envelope.Protocol]string, len(fieldsRoutes)) for _, route := range fieldsRoutes { if route.Protocol != "" && strings.TrimSpace(route.Topic) != "" { fieldsByProtocol[route.Protocol] = strings.TrimSpace(route.Topic) } } out := make(map[string]fieldsProjectionRoute, len(rawRoutes)) for _, route := range rawRoutes { subject := strings.TrimSpace(route.Subject) topic := fieldsByProtocol[route.Protocol] if subject == "" || topic == "" { continue } out[subject] = fieldsProjectionRoute{Protocol: route.Protocol, Topic: topic} } return out } func (c config) Validate() error { rawSubjects, rawTopics := routeSubjectsAndTopics(c.RawRoutes) fieldsSubjects, fieldsTopics := routeSubjectsAndTopics(c.FieldsRoutes) if err := topics.ValidateKafkaRawFields(rawTopics, fieldsTopics); err != nil { return err } return topics.ValidateRawFieldsDisjoint(rawSubjects, fieldsSubjects, "nats subject") } func routeSubjectsAndTopics(routes []subjectRoute) (map[string]string, map[string]string) { subjects := make(map[string]string, len(routes)) kafkaTopics := make(map[string]string, len(routes)) for _, route := range routes { name := strings.TrimSpace(route.Subject) if name == "" { name = strings.TrimSpace(route.Topic) } if name == "" { name = "unknown" } subjects[name] = strings.TrimSpace(route.Subject) topicName := routeProtocolName(route) if topicName == "" { topicName = name } kafkaTopics[topicName] = strings.TrimSpace(route.Topic) } return subjects, kafkaTopics } func routeProtocolName(route subjectRoute) string { if protocol := strings.TrimSpace(string(route.Protocol)); protocol != "" { return protocol } if protocol, ok := topics.ProtocolForKnownRawTopic(route.Topic); ok { return protocol } if protocol, ok := topics.ProtocolForKnownFieldsTopic(route.Topic); ok { return protocol } return "" } func unifiedRouteFromEnv() (string, string, bool) { subject, hasSubject := envOptional("NATS_SUBJECT_UNIFIED") topic, hasTopic := envOptional("KAFKA_TOPIC_UNIFIED") if !hasSubject && !hasTopic { return "", "", false } if subject == "" { subject = topic } if topic == "" { topic = subject } if subject == "" || topic == "" { return "", "", false } return subject, topic, true } type natsPullSubscription interface { Fetch(int, ...nats.PullOpt) ([]*nats.Msg, error) } type natsConsumerInfoReader interface { ConsumerInfo(stream string, name string, opts ...nats.JSOpt) (*nats.ConsumerInfo, error) } type kafkaBatchWriter interface { WriteMessages(context.Context, ...kafka.Message) error } type bridgeMessage struct { subject string data []byte ack func() error } type routedBridgeMessage struct { sourceIndex int kafka kafka.Message receivedAtMS int64 } type bridgeSourceState struct { source bridgeMessage routed bool failed bool } func runBridge(ctx context.Context, logger *slog.Logger, registry *metrics.Registry, infoReader natsConsumerInfoReader, sub natsPullSubscription, writer kafkaBatchWriter, cfg config) { var lastConsumerInfoAt time.Time for { if ctx.Err() != nil { return } if time.Since(lastConsumerInfoAt) >= time.Duration(envInt("NATS_CONSUMER_METRICS_INTERVAL_SECONDS", 10))*time.Second { lastConsumerInfoAt = time.Now() if infoReader != nil { info, err := infoReader.ConsumerInfo(cfg.NATSStream, cfg.NATSDurable, nats.Context(ctx)) if err != nil { logger.Warn("nats consumer info failed", "stream", cfg.NATSStream, "durable", cfg.NATSDurable, "error", err) } else { recordNATSConsumerInfoMetrics(registry, cfg, info) } } } msgs, err := sub.Fetch(cfg.BatchSize, nats.MaxWait(cfg.FetchWait)) if err != nil { if isBridgeShutdownFetchError(ctx, err) { return } if errors.Is(err, nats.ErrTimeout) { continue } if isTransientBridgeFetchError(err) { logger.Warn("nats fetch interrupted", "error", err) time.Sleep(time.Second) continue } logger.Error("nats fetch failed", "error", err) time.Sleep(time.Second) continue } bridgeMessages := make([]bridgeMessage, 0, len(msgs)) for _, msg := range msgs { msg := msg bridgeMessages = append(bridgeMessages, bridgeMessage{ subject: msg.Subject, data: msg.Data, ack: func() error { return msg.Ack() }, }) } operationCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cfg.OperationWait) err = bridgeBatchWithProjectionConcurrency(operationCtx, registry, writer, bridgeMessages, cfg.Route, cfg.RawFieldRoutes, cfg.DeriveFieldsFromRaw, cfg.KafkaWriteConcurrency) cancel() if err != nil { logger.Error("bridge batch failed", "count", len(bridgeMessages), "error", err) continue } } } func isBridgeShutdownFetchError(ctx context.Context, err error) bool { if err == nil { return false } if ctx.Err() != nil { return true } return errors.Is(err, nats.ErrConnectionClosed) } func isTransientBridgeFetchError(err error) bool { if err == nil { return false } text := strings.ToLower(strings.TrimSpace(err.Error())) return strings.Contains(text, "disconnected during fetch") || strings.Contains(text, "connection closed") || strings.Contains(text, "connection reset") || strings.Contains(text, "broken pipe") || strings.Contains(text, "temporary") || strings.Contains(text, "temporarily") || strings.Contains(text, "timeout") } func ensureStream(js nats.JetStreamContext, cfg config) error { ctx, cancel := context.WithTimeout(context.Background(), cfg.StreamEnsureWait) defer cancel() opts := []nats.JSOpt{nats.Context(ctx)} stream := &nats.StreamConfig{ Name: cfg.NATSStream, Subjects: cfg.NATSSubjects, Storage: nats.FileStorage, Retention: nats.LimitsPolicy, MaxAge: cfg.StreamMaxAge, MaxBytes: cfg.StreamMaxBytes, Duplicates: 2 * time.Minute, } if _, err := js.StreamInfo(cfg.NATSStream, opts...); err == nil { _, err = js.UpdateStream(stream, opts...) return err } _, err := js.AddStream(stream, opts...) if err == nil { return nil } _, updateErr := js.UpdateStream(stream, opts...) if updateErr == nil { return nil } return err } func bridgeBatch(ctx context.Context, registry *metrics.Registry, writer kafkaBatchWriter, messages []bridgeMessage, route map[string]string) error { return bridgeBatchWithProjectionConcurrency(ctx, registry, writer, messages, route, nil, false, 6) } func bridgeBatchWithProjection( ctx context.Context, registry *metrics.Registry, writer kafkaBatchWriter, messages []bridgeMessage, route map[string]string, rawFieldRoutes map[string]fieldsProjectionRoute, deriveFieldsFromRaw bool, ) error { return bridgeBatchWithProjectionConcurrency(ctx, registry, writer, messages, route, rawFieldRoutes, deriveFieldsFromRaw, 6) } func bridgeBatchWithProjectionConcurrency( ctx context.Context, registry *metrics.Registry, writer kafkaBatchWriter, messages []bridgeMessage, route map[string]string, rawFieldRoutes map[string]fieldsProjectionRoute, deriveFieldsFromRaw bool, kafkaWriteConcurrency int, ) error { if len(messages) == 0 { return nil } started := time.Now() status := "ok" defer func() { recordBridgeBatchDuration(registry, status, time.Since(started)) }() routed := make([]routedBridgeMessage, 0, len(messages)*2) sources := make([]bridgeSourceState, len(messages)) for sourceIndex, message := range messages { sources[sourceIndex].source = message addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "received") topic, ok := route[message.subject] if !ok || topic == "" { addBridgeSubjectMetric(registry, "vehicle_bridge_messages_total", message.subject, "route_error") if message.ack != nil { if err := message.ack(); err != nil { addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "error") status = "error" return fmt.Errorf("ack unrouted nats subject %q: %w", message.subject, err) } addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", message.subject, "dropped_route_error") } continue } kafkaMessage, receivedAtMS, decoded, decodeErr := kafkaMessageWithEnvelope(topic, message.data) sources[sourceIndex].routed = true routed = append(routed, routedBridgeMessage{ sourceIndex: sourceIndex, kafka: kafkaMessage, receivedAtMS: receivedAtMS, }) if !deriveFieldsFromRaw { continue } projection, ok := rawFieldRoutes[message.subject] if !ok { continue } fieldsMessage, fieldsReceivedAtMS, fieldCount, projectionStatus, projected := projectRawFields(message.subject, decoded, decodeErr, projection) recordBridgeFieldsProjection(registry, projection.Protocol, projectionStatus, fieldCount) if !projected { continue } routed = append(routed, routedBridgeMessage{ sourceIndex: sourceIndex, kafka: fieldsMessage, receivedAtMS: fieldsReceivedAtMS, }) } if len(routed) == 0 { addBridgeBatchPending(registry, 0, 0) return nil } addBridgeBatchPending(registry, len(messages), len(routed)) defer addBridgeBatchPending(registry, -len(messages), -len(routed)) groups := groupRoutedBridgeMessagesByTopic(routed) results := writeBridgeKafkaGroups(ctx, writer, groups, kafkaWriteConcurrency) var firstErr error for _, result := range results { group := result.group err := result.err recordBridgeKafkaWriteDuration(registry, group[0].kafka.Topic, statusFromError(err), result.elapsed) if err != nil { for _, item := range group { addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", item.kafka.Topic, "error") sources[item.sourceIndex].failed = true } status = "error" if firstErr == nil { firstErr = fmt.Errorf("kafka write topic %s: %w", group[0].kafka.Topic, err) } continue } for _, item := range group { addBridgeTopicMetric(registry, "vehicle_bridge_kafka_writes_total", item.kafka.Topic, "ok") recordBridgeKafkaE2EDuration(registry, item.kafka.Topic, item.receivedAtMS) } } for i := range sources { source := &sources[i] if !source.routed || source.failed || source.source.ack == nil { continue } if err := source.source.ack(); err != nil { addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", source.source.subject, "error") status = "error" return err } addBridgeSubjectMetric(registry, "vehicle_bridge_nats_acks_total", source.source.subject, "ok") } return firstErr } type bridgeKafkaWriteResult struct { group []routedBridgeMessage elapsed time.Duration err error } func writeBridgeKafkaGroups(ctx context.Context, writer kafkaBatchWriter, groups [][]routedBridgeMessage, concurrency int) []bridgeKafkaWriteResult { if len(groups) == 0 { return nil } if concurrency < 1 { concurrency = 1 } if concurrency > len(groups) { concurrency = len(groups) } semaphore := make(chan struct{}, concurrency) results := make(chan bridgeKafkaWriteResult, len(groups)) for _, group := range groups { group := group semaphore <- struct{}{} go func() { defer func() { <-semaphore }() messages := make([]kafka.Message, 0, len(group)) for _, item := range group { messages = append(messages, item.kafka) } started := time.Now() err := writer.WriteMessages(ctx, messages...) results <- bridgeKafkaWriteResult{group: group, elapsed: time.Since(started), err: err} }() } out := make([]bridgeKafkaWriteResult, 0, len(groups)) for range groups { out = append(out, <-results) } return out } func projectRawFields(subject string, raw envelope.FrameEnvelope, decodeErr error, projection fieldsProjectionRoute) (kafka.Message, int64, int, string, bool) { if decodeErr != nil { return kafka.Message{}, 0, 0, "invalid_json", false } if _, err := topics.ValidateRawEnvelope(subject, raw); err != nil { return kafka.Message{}, 0, 0, "invalid_envelope", false } if raw.Protocol != projection.Protocol { return kafka.Message{}, 0, 0, "protocol_mismatch", false } if !envelope.IsRealtimeTelemetryFrame(raw) { return kafka.Message{}, 0, 0, "skipped_non_realtime", false } if len(raw.ParsedFields) == 0 { return kafka.Message{}, 0, 0, "skipped_missing_fields", false } fields, ok := realtime.BuildFieldsEnvelope(raw) if !ok || len(fields.Fields) == 0 { return kafka.Message{}, 0, 0, "skipped_missing_fields", false } if _, err := topics.ValidateFieldsEnvelope(projection.Topic, fields); err != nil { return kafka.Message{}, 0, 0, "invalid_fields_envelope", false } payload, err := fields.MarshalJSONBytes() if err != nil { return kafka.Message{}, 0, 0, "marshal_error", false } result := kafka.Message{Topic: projection.Topic, Key: fields.KafkaKey(), Value: payload} return result, fields.ReceivedAtMS, len(fields.Fields), "published", true } func groupRoutedBridgeMessagesByTopic(messages []routedBridgeMessage) [][]routedBridgeMessage { if len(messages) == 0 { return nil } groupsByTopic := make(map[string][]routedBridgeMessage) for _, message := range messages { groupsByTopic[message.kafka.Topic] = append(groupsByTopic[message.kafka.Topic], message) } topics := make([]string, 0, len(groupsByTopic)) for topic := range groupsByTopic { topics = append(topics, topic) } sort.Strings(topics) groups := make([][]routedBridgeMessage, 0, len(topics)) for _, topic := range topics { groups = append(groups, groupsByTopic[topic]) } return groups } var bridgeBatchDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} var bridgeKafkaWriteDurationBucketsMS = []float64{1, 5, 10, 25, 50, 100, 250, 500, 1000, 5000} var bridgeKafkaE2EDurationBucketsMS = []float64{10, 25, 50, 100, 250, 500, 1000, 2500, 5000, 10000} var bridgeKafkaE2ERecent = metrics.NewRecentLatencyByKey(512) var bridgeBatchPending = metrics.PendingPairGauge{} func addBridgeSubjectMetric(registry *metrics.Registry, name string, subject string, status string) { if registry == nil { return } labels := metrics.Labels{"subject": subject, "status": status} registry.IncCounter(name, labels) if name == "vehicle_bridge_messages_total" { metrics.RecordLastActivity(registry, "vehicle_bridge_last_message_unix_seconds", labels) return } if name == "vehicle_bridge_nats_acks_total" { metrics.RecordLastActivity(registry, "vehicle_bridge_last_ack_unix_seconds", labels) } } func addBridgeTopicMetric(registry *metrics.Registry, name string, topic string, status string) { if registry == nil { return } labels := metrics.Labels{"topic": topic, "status": status} registry.IncCounter(name, labels) if name == "vehicle_bridge_kafka_writes_total" { metrics.RecordLastActivity(registry, "vehicle_bridge_last_kafka_write_unix_seconds", labels) } } func recordBridgeFieldsProjection(registry *metrics.Registry, protocol envelope.Protocol, status string, fieldCount int) { if registry == nil { return } protocolLabel := strings.TrimSpace(string(protocol)) if protocolLabel == "" { protocolLabel = "unknown" } if strings.TrimSpace(status) == "" { status = "unknown" } labels := metrics.Labels{"protocol": protocolLabel, "status": status} registry.IncCounter("vehicle_bridge_fields_projection_total", labels) metrics.RecordLastActivity(registry, "vehicle_bridge_last_fields_projection_unix_seconds", labels) if status == "published" { registry.SetGauge("vehicle_bridge_fields_projection_count", labels, float64(fieldCount)) } } func addBridgeBatchPending(registry *metrics.Registry, messages int, kafkaMessages int) { if registry == nil { return } bridgeBatchPending.Add(registry, "vehicle_bridge_batch_pending_messages", "vehicle_bridge_batch_pending_kafka_messages", messages, kafkaMessages) } func recordBridgeBatchDuration(registry *metrics.Registry, status string, elapsed time.Duration) { if registry == nil { return } registry.ObserveHistogram("vehicle_bridge_batch_duration_ms_histogram", metrics.Labels{ "status": status, }, bridgeBatchDurationBucketsMS, float64(elapsed.Milliseconds())) } func recordBridgeKafkaWriteDuration(registry *metrics.Registry, topic string, status string, elapsed time.Duration) { if registry == nil { return } registry.ObserveHistogram("vehicle_bridge_kafka_write_duration_ms_histogram", metrics.Labels{ "topic": topic, "status": status, }, bridgeKafkaWriteDurationBucketsMS, float64(elapsed.Milliseconds())) } func recordBridgeKafkaE2EDuration(registry *metrics.Registry, topic string, receivedAtMS int64) { if registry == nil || receivedAtMS <= 0 { return } elapsedMS := time.Since(time.UnixMilli(receivedAtMS)).Milliseconds() if elapsedMS < 0 { elapsedMS = 0 } labels := metrics.Labels{ "topic": topic, } registry.ObserveHistogram("vehicle_bridge_kafka_e2e_duration_ms_histogram", labels, bridgeKafkaE2EDurationBucketsMS, float64(elapsedMS)) p99, samples := bridgeKafkaE2ERecent.Observe(topic, float64(elapsedMS)) registry.SetGauge("vehicle_bridge_kafka_e2e_recent_p99_ms", labels, p99) registry.SetGauge("vehicle_bridge_kafka_e2e_recent_samples", labels, float64(samples)) metrics.RecordLastActivity(registry, "vehicle_bridge_last_kafka_e2e_unix_seconds", labels) } func statusFromError(err error) string { if err != nil { return "error" } return "ok" } func recordNATSConsumerInfoMetrics(registry *metrics.Registry, cfg config, info *nats.ConsumerInfo) { if registry == nil || info == nil { return } labels := metrics.Labels{"stream": cfg.NATSStream, "consumer": cfg.NATSDurable} registry.SetGauge("vehicle_bridge_nats_consumer_pending", labels, float64(info.NumPending)) registry.SetGauge("vehicle_bridge_nats_consumer_ack_pending", labels, float64(info.NumAckPending)) registry.SetGauge("vehicle_bridge_nats_consumer_waiting", labels, float64(info.NumWaiting)) } func kafkaMessage(topic string, data []byte) kafka.Message { message, _ := kafkaMessageWithReceivedAt(topic, data) return message } func kafkaMessageWithReceivedAt(topic string, data []byte) (kafka.Message, int64) { message, receivedAtMS, _, _ := kafkaMessageWithEnvelope(topic, data) return message, receivedAtMS } func kafkaMessageWithEnvelope(topic string, data []byte) (kafka.Message, int64, envelope.FrameEnvelope, error) { var env envelope.FrameEnvelope message := kafka.Message{Topic: topic, Value: data} if err := json.Unmarshal(data, &env); err != nil { return message, 0, envelope.FrameEnvelope{}, err } message.Key = env.KafkaKey() return message, env.ReceivedAtMS, env, nil } func env(key string, fallback string) string { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } return value } func envBool(key string, fallback bool) bool { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseBool(value) if err != nil { return fallback } return parsed } func boolMetric(value bool) float64 { if value { return 1 } return 0 } func envOptional(key string) (string, bool) { value, ok := os.LookupEnv(key) if !ok { return "", false } return strings.TrimSpace(value), true } func envInt(key string, fallback int) int { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.Atoi(value) if err != nil || parsed <= 0 { return fallback } return parsed } func envInt64(key string, fallback int64) int64 { value := strings.TrimSpace(os.Getenv(key)) if value == "" { return fallback } parsed, err := strconv.ParseInt(value, 10, 64) if err != nil || parsed <= 0 { return fallback } return parsed } func splitCSV(value string) []string { var out []string for _, item := range strings.Split(value, ",") { item = strings.TrimSpace(item) if item != "" { out = append(out, item) } } return out } func mapKeys(values map[string]string) []string { out := make([]string, 0, len(values)) for key := range values { out = append(out, key) } return out }