package protocollog import ( "context" "encoding/json" "fmt" "hash/fnv" "strconv" "strings" "sync/atomic" "time" "github.com/redis/go-redis/v9" ) const defaultStream = "gateway.protocol.logs" type Event struct { EventID string `json:"eventId"` GatewayInstanceID string `json:"gatewayInstanceId,omitempty"` ConnectionID string `json:"connectionId,omitempty"` Protocol string `json:"protocol"` Direction string `json:"direction"` EventType string `json:"eventType"` Status string `json:"status"` TenantID string `json:"tenantId,omitempty"` ApplicationID string `json:"applicationId,omitempty"` ChannelID string `json:"channelId,omitempty"` Account string `json:"account,omitempty"` MessageID string `json:"messageId,omitempty"` SubmitID string `json:"submitId,omitempty"` GatewayMessageID string `json:"gatewayMessageId,omitempty"` Phone string `json:"phone,omitempty"` ResultCode string `json:"resultCode,omitempty"` DurationMs int `json:"durationMs,omitempty"` PayloadBytes int `json:"payloadBytes,omitempty"` Detail map[string]any `json:"detail,omitempty"` CreatedAt time.Time `json:"createdAt"` } type Publisher struct { Redis *redis.Client Stream string GatewayInstanceID string SuccessSampleRate int MaxLen int64 published atomic.Int64 sampled atomic.Int64 errors atomic.Int64 } func New(redisURL string) (*Publisher, error) { if strings.TrimSpace(redisURL) == "" { redisURL = "redis://127.0.0.1:6379" } options, err := redis.ParseURL(redisURL) if err != nil { return nil, err } return &Publisher{Redis: redis.NewClient(options)}, nil } func (p *Publisher) Publish(ctx context.Context, event Event) error { if p == nil || p.Redis == nil { return fmt.Errorf("protocol log Redis publisher is unavailable") } if event.CreatedAt.IsZero() { event.CreatedAt = time.Now().UTC() } if event.GatewayInstanceID == "" { event.GatewayInstanceID = p.GatewayInstanceID } if event.EventID == "" { event.EventID = fmt.Sprintf("PL-%d-%d", event.CreatedAt.UnixNano(), p.published.Load()+p.sampled.Load()+1) } if !p.mustKeep(event) && !p.sample(event) { p.sampled.Add(1) return nil } data, err := json.Marshal(event) if err != nil { p.errors.Add(1) return err } args := &redis.XAddArgs{Stream: p.stream(), Values: map[string]any{"data": string(data)}} if p.MaxLen > 0 { args.MaxLen = p.MaxLen args.Approx = true } if err := p.Redis.XAdd(ctx, args).Err(); err != nil { p.errors.Add(1) return err } p.published.Add(1) return nil } func (p *Publisher) mustKeep(event Event) bool { if event.Status != "success" { return true } code := strings.TrimSpace(event.ResultCode) return code != "" && code != "0" } func (p *Publisher) sample(event Event) bool { rate := p.SuccessSampleRate if rate <= 0 { rate = 10 } if rate >= 100 { return true } h := fnv.New32a() _, _ = h.Write([]byte(event.ChannelID + "|" + event.MessageID + "|" + event.EventType + "|" + strconv.FormatInt(event.CreatedAt.UnixNano()/int64(time.Millisecond), 10))) return int(h.Sum32()%100) < rate } func (p *Publisher) stream() string { if strings.TrimSpace(p.Stream) != "" { return p.Stream } return defaultStream } func (p *Publisher) StreamName() string { return p.stream() } func (p *Publisher) Counts() (int64, int64, int64) { return p.published.Load(), p.sampled.Load(), p.errors.Load() }