Files
lingniu-vehicle-ingest/go/vehicle-gateway/cmd/nats-kafka-bridge/main.go

852 lines
28 KiB
Go

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
}