diff --git a/integration/loki/loki.go b/integration/loki/loki.go index 5ac23f6..bc28fc7 100644 --- a/integration/loki/loki.go +++ b/integration/loki/loki.go @@ -151,9 +151,16 @@ func (l *Loki) Write(payload mpv2.Payload[any]) { l.l.Warn("buffer size reached", zap.Int("size", l.bufferSize)) } + var timestamp time.Time + if payload.TimestampMicros > 0 { + timestamp = time.UnixMicro(payload.TimestampMicros) + } else { + timestamp = time.Now() + } + l.entries <- logproto.Entry{ Line: string(lineBytes), - Timestamp: time.UnixMicro(payload.TimestampMicros), + Timestamp: timestamp, StructuredMetadata: push.LabelsAdapter{ { Name: "event_name", diff --git a/integration/loki/middleware.go b/integration/loki/middleware.go new file mode 100644 index 0000000..dbd88cc --- /dev/null +++ b/integration/loki/middleware.go @@ -0,0 +1,42 @@ +package loki + +import ( + "net/http" + + "github.com/foomo/sesamy-go/pkg/encoding/gtag" + "github.com/foomo/sesamy-go/pkg/encoding/gtagencode" + "github.com/foomo/sesamy-go/pkg/encoding/mpv2" + gtaghttp "github.com/foomo/sesamy-go/pkg/http/gtag" + mpv2http "github.com/foomo/sesamy-go/pkg/http/mpv2" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +func GTagMiddleware(loki *Loki) gtaghttp.Middleware { + return func(next gtaghttp.MiddlewareHandler) gtaghttp.MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error { + err := next(l, w, r, payload) + if err != nil { + // encode to mpv2 + var mpv2Payload mpv2.Payload[any] + if err := gtagencode.MPv2(*payload, &mpv2Payload); err != nil { + return errors.Wrap(err, "failed to encode gtag to mpv2") + } + loki.Write(mpv2Payload) + } + return err + } + } +} + +func MPv2Middleware(loki *Loki) mpv2http.Middleware { + return func(next mpv2http.MiddlewareHandler) mpv2http.MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + err := next(l, w, r, payload) + if err != nil { + loki.Write(*payload) + } + return err + } + } +} diff --git a/integration/watermill/gtag/messagehandler_test.go b/integration/watermill/gtag/messagehandler_test.go index 5aa4085..43c00e6 100644 --- a/integration/watermill/gtag/messagehandler_test.go +++ b/integration/watermill/gtag/messagehandler_test.go @@ -99,7 +99,7 @@ func TestMPv2MessageHandler(t *testing.T) { var done atomic.Bool router.AddHandler("gtag", "in", pubSub, "out", pubSub, gtag.MPv2MessageHandler) router.AddNoPublisherHandler("mpv2", "out", pubSub, func(msg *message.Message) error { - expected := `{"client_id":"C123456","events":[{"name":"add_to_cart","params":{}}],"debug_mode":true}` + expected := `{"client_id":"C123456","consent":{"ad_user_data":"GRANTED","ad_personalization":"GRANTED","analytics_storage":"GRANTED"},"events":[{"name":"add_to_cart","params":{"page_location":"https://foomo.org","page_title":"Home"}}],"debug_mode":true}` if !assert.JSONEq(t, expected, string(msg.Payload)) { fmt.Println(string(msg.Payload)) } diff --git a/integration/watermill/gtag/subscriber.go b/integration/watermill/gtag/subscriber.go index 5f6314f..9bf1b75 100644 --- a/integration/watermill/gtag/subscriber.go +++ b/integration/watermill/gtag/subscriber.go @@ -3,15 +3,13 @@ package gtag import ( "context" "encoding/json" - "fmt" - "io" "net/http" - "net/url" "strings" "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/foomo/sesamy-go/pkg/encoding/gtag" + gtaghttp "github.com/foomo/sesamy-go/pkg/http/gtag" "github.com/pkg/errors" "go.uber.org/zap" ) @@ -22,12 +20,10 @@ type ( uuidFunc func() string messages chan *message.Message messageFunc func(l *zap.Logger, r *http.Request, msg *message.Message) error - middlewares []SubscriberMiddleware + middlewares []gtaghttp.Middleware closed bool } - SubscriberOption func(*Subscriber) - SubscriberHandler func(l *zap.Logger, r *http.Request, payload *gtag.Payload) error - SubscriberMiddleware func(next SubscriberHandler) SubscriberHandler + SubscriberOption func(*Subscriber) ) // ------------------------------------------------------------------------------------------------ @@ -46,7 +42,7 @@ func SubscriberWithMessageFunc(v func(l *zap.Logger, r *http.Request, msg *messa } } -func SubscriberWithMiddlewares(v ...SubscriberMiddleware) SubscriberOption { +func SubscriberWithMiddlewares(v ...gtaghttp.Middleware) SubscriberOption { return func(o *Subscriber) { o.middlewares = append(o.middlewares, v...) } @@ -69,52 +65,8 @@ func NewSubscriber(l *zap.Logger, opts ...SubscriberOption) *Subscriber { } func (s *Subscriber) ServeHTTP(w http.ResponseWriter, r *http.Request) { - var values url.Values - - switch r.Method { - case http.MethodGet: - values = r.URL.Query() - case http.MethodPost: - values = r.URL.Query() - - // read request body - out, err := io.ReadAll(r.Body) - if err != nil { - http.Error(w, fmt.Sprintf("failed to read body: %s", err.Error()), http.StatusInternalServerError) - return - } - defer r.Body.Close() - - // append request body to query - if len(out) > 0 { - v, err := url.ParseQuery(string(out)) - if err != nil { - http.Error(w, fmt.Sprintf("failed to parse extended url: %s", err.Error()), http.StatusInternalServerError) - return - } - for s2, i := range v { - values.Set(s2, i[0]) - } - } else { - values = r.URL.Query() - } - default: - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - // unmarshal event - var payload *gtag.Payload - if err := gtag.Decode(values, &payload); err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // validate - if payload.EventName == nil || payload.EventName.String() == "" { - http.Error(w, "missing event name", http.StatusBadRequest) - return - } + // retrieve payload + payload := gtaghttp.Handler(w, r) // compose middlewares next := s.handle @@ -123,13 +75,13 @@ func (s *Subscriber) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // run handler - if err := next(s.l, r, payload); err != nil { + if err := next(s.l, w, r, payload); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } -func (s *Subscriber) handle(l *zap.Logger, r *http.Request, payload *gtag.Payload) error { +func (s *Subscriber) handle(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error { // marshal message payload data, err := json.Marshal(payload) if err != nil { diff --git a/integration/watermill/gtag/subscribermiddleware.go b/integration/watermill/gtag/subscribermiddleware.go deleted file mode 100644 index 71fbc0f..0000000 --- a/integration/watermill/gtag/subscribermiddleware.go +++ /dev/null @@ -1,40 +0,0 @@ -package gtag - -import ( - "net/http" - - "github.com/foomo/sesamy-go/pkg/encoding/gtag" - "go.opentelemetry.io/otel/trace" - "go.uber.org/zap" -) - -func SubscriberMiddlewareUserID(cookieName string) SubscriberMiddleware { - return func(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *gtag.Payload) error { - if cookie, err := r.Cookie(cookieName); err == nil { - payload.UserID = gtag.Set(cookie.Value) - } - return next(l, r, payload) - } - } -} - -func SubscriberMiddlewareLogger(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *gtag.Payload) error { - if spanCtx := trace.SpanContextFromContext(r.Context()); spanCtx.IsValid() && spanCtx.IsSampled() { - l = l.With(zap.String("trace_id", spanCtx.TraceID().String()), zap.String("span_id", spanCtx.SpanID().String())) - } - l = l.With( - zap.String("event_name", gtag.GetDefault(payload.EventName, "-").String()), - zap.String("event_user_id", gtag.GetDefault(payload.UserID, "-")), - zap.String("event_session_id", gtag.GetDefault(payload.SessionID, "-")), - ) - err := next(l, r, payload) - if err != nil { - l.Error("handled event", zap.Error(err)) - } else { - l.Info("handled event") - } - return err - } -} diff --git a/integration/watermill/mpv2/subscriber.go b/integration/watermill/mpv2/subscriber.go index e9b0a8a..2d8bade 100644 --- a/integration/watermill/mpv2/subscriber.go +++ b/integration/watermill/mpv2/subscriber.go @@ -9,6 +9,7 @@ import ( "github.com/ThreeDotsLabs/watermill" "github.com/ThreeDotsLabs/watermill/message" "github.com/foomo/sesamy-go/pkg/encoding/mpv2" + mpv2http "github.com/foomo/sesamy-go/pkg/http/mpv2" "github.com/pkg/errors" "go.uber.org/zap" ) @@ -19,12 +20,10 @@ type ( uuidFunc func() string messages chan *message.Message messageFunc func(l *zap.Logger, r *http.Request, msg *message.Message) error - middlewares []SubscriberMiddleware + middlewares []mpv2http.Middleware closed bool } - SubscriberOption func(*Subscriber) - SubscriberHandler func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error - SubscriberMiddleware func(next SubscriberHandler) SubscriberHandler + SubscriberOption func(*Subscriber) ) // ------------------------------------------------------------------------------------------------ @@ -43,7 +42,7 @@ func SubscriberWithMessageFunc(v func(l *zap.Logger, r *http.Request, msg *messa } } -func SubscriberWithMiddlewares(v ...SubscriberMiddleware) SubscriberOption { +func SubscriberWithMiddlewares(v ...mpv2http.Middleware) SubscriberOption { return func(o *Subscriber) { o.middlewares = append(o.middlewares, v...) } @@ -70,30 +69,8 @@ func NewSubscriber(l *zap.Logger, opts ...SubscriberOption) *Subscriber { // ------------------------------------------------------------------------------------------------ func (s *Subscriber) ServeHTTP(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodPost { - http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) - return - } - - // read request body - var payload *mpv2.Payload[any] - err := json.NewDecoder(r.Body).Decode(&payload) - if err != nil { - http.Error(w, err.Error(), http.StatusInternalServerError) - return - } - - // validate required fields - if len(payload.Events) == 0 { - http.Error(w, "missing events", http.StatusBadRequest) - return - } - for _, event := range payload.Events { - if event.Name == "" { - http.Error(w, "missing event name", http.StatusBadRequest) - return - } - } + // retrieve payload + payload := mpv2http.Handler(w, r) // compose middlewares next := s.handle @@ -102,13 +79,13 @@ func (s *Subscriber) ServeHTTP(w http.ResponseWriter, r *http.Request) { } // run handler - if err := next(s.l, r, payload); err != nil { + if err := next(s.l, w, r, payload); err != nil { http.Error(w, err.Error(), http.StatusInternalServerError) return } } -func (s *Subscriber) handle(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { +func (s *Subscriber) handle(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { // marshal message payload jsonPayload, err := json.Marshal(payload) if err != nil { diff --git a/integration/watermill/mpv2/subscribermiddleware.go b/integration/watermill/mpv2/subscribermiddleware.go deleted file mode 100644 index 6f55947..0000000 --- a/integration/watermill/mpv2/subscribermiddleware.go +++ /dev/null @@ -1,101 +0,0 @@ -package mpv2 - -import ( - "net/http" - "strings" - "time" - - "github.com/foomo/sesamy-go/pkg/encoding/mpv2" - "github.com/foomo/sesamy-go/pkg/session" - "github.com/pkg/errors" - "go.opentelemetry.io/otel/trace" - "go.uber.org/zap" -) - -func SubscriberMiddlewareSessionID(measurementID string) SubscriberMiddleware { - measurementID = strings.Split(measurementID, "-")[1] - return func(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { - if payload.SessionID == "" { - value, err := session.ParseGASessionID(r, measurementID) - if err != nil && !errors.Is(err, http.ErrNoCookie) { - return err - } - payload.SessionID = value - } - return next(l, r, payload) - } - } -} - -func SubscriberMiddlewareClientID(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { - if payload.ClientID == "" { - value, err := session.ParseGAClientID(r) - if err != nil && !errors.Is(err, http.ErrNoCookie) { - return err - } - payload.ClientID = value - } - return next(l, r, payload) - } -} - -func SubscriberMiddlewareDebugMode(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { - if !payload.DebugMode && session.IsGTMDebug(r) { - payload.DebugMode = true - } - return next(l, r, payload) - } -} - -func SubscriberMiddlewareUserID(cookieName string) SubscriberMiddleware { - return func(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { - if payload.UserID == "" { - value, err := r.Cookie(cookieName) - if err != nil && !errors.Is(err, http.ErrNoCookie) { - return err - } - payload.UserID = value.Value - } - return next(l, r, payload) - } - } -} - -func SubscriberMiddlewareTimestamp(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { - if payload.TimestampMicros == 0 { - payload.TimestampMicros = time.Now().UnixMicro() - } - return next(l, r, payload) - } -} - -func SubscriberMiddlewareLogger(next SubscriberHandler) SubscriberHandler { - return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error { - eventNames := make([]string, len(payload.Events)) - for i, event := range payload.Events { - eventNames[i] = event.Name.String() - } - - if spanCtx := trace.SpanContextFromContext(r.Context()); spanCtx.IsValid() && spanCtx.IsSampled() { - l = l.With(zap.String("trace_id", spanCtx.TraceID().String()), zap.String("span_id", spanCtx.SpanID().String())) - } - - l = l.With( - zap.String("event_names", strings.Join(eventNames, ",")), - zap.String("event_user_id", payload.UserID), - ) - - err := next(l, r, payload) - if err != nil { - l.Error("handled event", zap.Error(err)) - } else { - l.Info("handled event") - } - return err - } -} diff --git a/pkg/client/mpv2.go b/pkg/client/mpv2.go index 8bc797a..e8e99e3 100644 --- a/pkg/client/mpv2.go +++ b/pkg/client/mpv2.go @@ -6,7 +6,6 @@ import ( "fmt" "io" "net/http" - "time" "github.com/foomo/sesamy-go/pkg/encoding/mpv2" "github.com/foomo/sesamy-go/pkg/sesamy" @@ -110,16 +109,14 @@ func (c *MPv2) HTTPClient() *http.Client { // ------------------------------------------------------------------------------------------------ func (c *MPv2) Collect(r *http.Request, events ...sesamy.AnyEvent) error { - anyEvents := make([]sesamy.Event[any], len(events)) - for i, event := range events { - anyEvents[i] = event.AnyEvent() - } - - payload := &mpv2.Payload[any]{ - Events: anyEvents, - TimestampMicros: time.Now().UnixMicro(), + payload := mpv2.NewPayload[any]() + for _, event := range events { + payload.Events = append(payload.Events, event.AnyEvent()) } + return c.SendPayload(r, payload) +} +func (c *MPv2) SendPayload(r *http.Request, payload *mpv2.Payload[any]) error { next := c.SendRaw for _, middleware := range c.middlewares { next = middleware(next) diff --git a/pkg/client/mpv2middleware.go b/pkg/client/mpv2middleware.go index b52db65..5670d99 100644 --- a/pkg/client/mpv2middleware.go +++ b/pkg/client/mpv2middleware.go @@ -3,7 +3,6 @@ package client import ( "net/http" "strings" - "time" "github.com/foomo/sesamy-go/pkg/encoding/mpv2" "github.com/foomo/sesamy-go/pkg/session" @@ -62,12 +61,3 @@ func MPv2MiddlewareUserID(cookieName string) MPv2Middleware { } } } - -func MPv2MiddlewareTimestamp(next MPv2Handler) MPv2Handler { - return func(r *http.Request, payload *mpv2.Payload[any]) error { - if payload.TimestampMicros == 0 { - payload.TimestampMicros = time.Now().UnixMicro() - } - return next(r, payload) - } -} diff --git a/pkg/collect/collect.go b/pkg/collect/collect.go new file mode 100644 index 0000000..8746184 --- /dev/null +++ b/pkg/collect/collect.go @@ -0,0 +1,184 @@ +package collect + +import ( + "bytes" + "encoding/json" + "fmt" + "io" + "net/http" + + "github.com/foomo/sesamy-go/pkg/encoding/gtag" + "github.com/foomo/sesamy-go/pkg/encoding/mpv2" + gtaghttp "github.com/foomo/sesamy-go/pkg/http/gtag" + mpv2http "github.com/foomo/sesamy-go/pkg/http/mpv2" + "github.com/pkg/errors" + "go.uber.org/zap" +) + +type ( + Collect struct { + l *zap.Logger + taggingURL string + taggingClient *http.Client + gtagHTTPMiddlewares []gtaghttp.Middleware + mpv2HTTPMiddlewares []mpv2http.Middleware + } + Option func(*Collect) error +) + +// ------------------------------------------------------------------------------------------------ +// ~ Options +// ------------------------------------------------------------------------------------------------ + +func WithTagging(v string) Option { + return func(c *Collect) error { + c.taggingURL = v + return nil + } +} + +func WithTaggingClient(v *http.Client) Option { + return func(c *Collect) error { + c.taggingClient = v + return nil + } +} + +func WithGTagHTTPMiddlewares(v ...gtaghttp.Middleware) Option { + return func(c *Collect) error { + c.gtagHTTPMiddlewares = append(c.gtagHTTPMiddlewares, v...) + return nil + } +} + +func WithMPv2HTTPMiddlewares(v ...mpv2http.Middleware) Option { + return func(c *Collect) error { + c.mpv2HTTPMiddlewares = append(c.mpv2HTTPMiddlewares, v...) + return nil + } +} + +// ------------------------------------------------------------------------------------------------ +// ~ Constructor +// ------------------------------------------------------------------------------------------------ + +func New(l *zap.Logger, opts ...Option) (*Collect, error) { + inst := &Collect{ + l: l, + taggingClient: http.DefaultClient, + } + + for _, opt := range opts { + if opt != nil { + if err := opt(inst); err != nil { + return nil, err + } + } + } + + return inst, nil +} + +// ------------------------------------------------------------------------------------------------ +// ~ Public methods +// ------------------------------------------------------------------------------------------------ + +func (c *Collect) GTagHTTPHandler(w http.ResponseWriter, r *http.Request) { + // retrieve payload + payload := gtaghttp.Handler(w, r) + + // compose middlewares + next := c.gtagHandler + for _, middleware := range c.gtagHTTPMiddlewares { + next = middleware(next) + } + + // run handler + if err := next(c.l, w, r, payload); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +func (c *Collect) MPv2HTTPHandler(w http.ResponseWriter, r *http.Request) { + // retrieve payload + payload := mpv2http.Handler(w, r) + + // compose middlewares + next := c.mpv2Handler + for _, middleware := range c.mpv2HTTPMiddlewares { + next = middleware(next) + } + + // run handler + if err := next(c.l, w, r, payload); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return + } +} + +// ------------------------------------------------------------------------------------------------ +// ~ Private methods +// ------------------------------------------------------------------------------------------------ + +func (c *Collect) gtagHandler(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error { + values, body, err := gtag.Encode(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s%s?%s", c.taggingURL, "/g/collect", gtag.EncodeValues(values)), body) + if err != nil { + return errors.Wrap(err, "failed to create request") + } + + // copy headers + req.Header = r.Header.Clone() + + resp, err := c.taggingClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // copy headers + r.Header = resp.Header.Clone() + + if _, err := io.Copy(w, resp.Body); err != nil { + return err + } + + return nil +} + +func (c *Collect) mpv2Handler(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + body, err := json.Marshal(payload) + if err != nil { + return err + } + + req, err := http.NewRequestWithContext(r.Context(), http.MethodPost, fmt.Sprintf("%s%s", c.taggingURL, "/mp/collect"), bytes.NewReader(body)) + if err != nil { + return errors.Wrap(err, "failed to create request") + } + + // copy headers + req.Header = r.Header.Clone() + // copy raw query + req.URL.RawQuery = r.URL.RawQuery + + resp, err := c.taggingClient.Do(req) + if err != nil { + return err + } + defer resp.Body.Close() + + // copy headers + r.Header = resp.Header.Clone() + + if _, err := io.Copy(w, resp.Body); err != nil { + return err + } + + return nil +} diff --git a/pkg/encoding/gtag/consent.go b/pkg/encoding/gtag/consent.go index c1ab4d8..7808b38 100644 --- a/pkg/encoding/gtag/consent.go +++ b/pkg/encoding/gtag/consent.go @@ -1,9 +1,11 @@ package gtag import ( + "slices" "strings" ) +// See https://developers.google.com/tag-platform/security/concepts/consent-mode type Consent struct { // Current Google Consent Status. Format 'G1'+'AdsStorageBoolStatus'`+'AnalyticsStorageBoolStatus' // Example: G101 @@ -17,6 +19,11 @@ type Consent struct { // Will be added with the value "1" if the Google Consent had a default value before getting an update // Example: G111 GoogleConsentDefault *string `json:"google_consent_default,omitempty" gtag:"gcd,omitempty"` + // Example: 1 + // DigitalMarketAct *string `json:"digital_market_act,omitempty" gtag:"dma,omitempty"` + // Example: sypham + // DigitalMarketActParameters *string `json:"digital_market_act_parameters,omitempty" gtag:"dma_cps,omitempty"` + // Example: noapi | denied } // ------------------------------------------------------------------------------------------------ @@ -24,6 +31,12 @@ type Consent struct { // ------------------------------------------------------------------------------------------------ func (c Consent) AdStorage() bool { + if c.GoogleConsentDefault != nil { + gcd := strings.Split(*c.GoogleConsentDefault, "") + if len(gcd) > 3 { + return slices.Contains([]string{"l", "t", "r", "n", "u", "v"}, gcd[2]) + } + } if c.GoogleConsentUpdate != nil { gcs := *c.GoogleConsentUpdate if strings.HasPrefix(gcs, "G1") && len(gcs) == 4 { @@ -35,6 +48,12 @@ func (c Consent) AdStorage() bool { } func (c Consent) AnalyticsStorage() bool { + if c.GoogleConsentDefault != nil { + gcd := strings.Split(*c.GoogleConsentDefault, "") + if len(gcd) > 5 { + return slices.Contains([]string{"l", "t", "r", "n", "u", "v"}, gcd[4]) + } + } if c.GoogleConsentUpdate != nil { gcs := *c.GoogleConsentUpdate if strings.HasPrefix(gcs, "G1") && len(gcs) == 4 { @@ -44,3 +63,23 @@ func (c Consent) AnalyticsStorage() bool { } return true } + +func (c Consent) AdUserData() bool { + if c.GoogleConsentDefault != nil { + gcd := strings.Split(*c.GoogleConsentDefault, "") + if len(gcd) > 7 { + return slices.Contains([]string{"l", "t", "r", "n", "u", "v"}, gcd[6]) + } + } + return c.AdStorage() +} + +func (c Consent) AdPersonalization() bool { + if c.GoogleConsentDefault != nil { + gcd := strings.Split(*c.GoogleConsentDefault, "") + if len(gcd) > 9 { + return slices.Contains([]string{"l", "t", "r", "n", "u", "v"}, gcd[8]) + } + } + return c.AdStorage() +} diff --git a/pkg/encoding/gtag/decode_test.go b/pkg/encoding/gtag/decode_test.go index 33f93e0..0b060a7 100644 --- a/pkg/encoding/gtag/decode_test.go +++ b/pkg/encoding/gtag/decode_test.go @@ -40,7 +40,7 @@ func TestDecode(t *testing.T) { }, { name: "add_to_cart", - args: GTagAddToCart, + args: "v=2&tid=G-F9XM71K45T>m=45he45m0v9184715813z89184708445za200zb9184708445&_p=1716795486104&_dbg=1&gcd=13l3l3l2l1&npa=1&dma_cps=sypham&dma=1&cid=179294588.1715353601&ecid=788548699&ul=en-us&sr=2056x1329&_fplc=0&ur=&uaa=arm&uab=64&uafvl=Chromium%3B124.0.6367.201%7CGoogle%2520Chrome%3B124.0.6367.201%7CNot-A.Brand%3B99.0.0.0&uamb=0&uam=&uap=macOS&uapv=14.4.1&uaw=0&are=1&frm=0&pscdl=noapi&sst.gcd=13l3l3l2l1&sst.tft=1716795486104&sst.ude=0&_s=4&cu=USD&sid=1716793773&sct=14&seg=1&dl=https%3A%2F%2Fsesamy.local.bestbytes.net%2F%3Fgtm_debug%3D1716795486020&dr=https%3A%2F%2Ftagassistant.google.com%2F&dt=Home&en=add_to_cart&pr1=idSKU_12345~nmStan%20and%20Friends%20Tee~afGoogle%20Merchandise%20Store~cpSUMMER_FUN~ds2.22~lp0~brGoogle~caApparel~c2Adult~c3Shirts~c4Crew~c5Short%20sleeve~lirelated_products~lnRelated%20Products~vagreen~loChIJIQBpAG2ahYAR_6128GcTUEo~pr10.01~qt3&ep.enable_page_views=false&epn.value=30.03&_et=1255&tfd=145479&richsstsse", want: `{"consent":{"google_consent_default":"13l3l3l2l1"},"campaign":{},"ecommerce":{"currency":"USD","items":[{"affiliation":"Google Merchandise Store","coupon":"SUMMER_FUN","discount":"2.22","item_brand":"Google","item_category":"Apparel","item_category2":"Adult","item_category3":"Shirts","item_category4":"Crew","item_category5":"Short sleeve","item_id":"SKU_12345","item_list_id":"related_products","item_list_name":"Related Products","item_name":"Stan and Friends Tee","item_variant":"green","item_list_position":"0","location_id":"ChIJIQBpAG2ahYAR_6128GcTUEo","price":"10.01","quantity":"3"}]},"client_hints":{"screen_resolution":"2056x1329","user_language":"en-us","user_agent_architecture":"arm","user_agent_bitness":"64","user_agent_full_version_list":"Chromium;124.0.6367.201|Google%20Chrome;124.0.6367.201|Not-A.Brand;99.0.0.0","user_agent_mobile":"0","user_agent_model":"","user_agent_platform":"macOS","user_agent_platform_version":"14.4.1","user_agent_wow_64":"0","user_region":""},"protocol_version":"2","tracking_id":"G-F9XM71K45T","gtmhash_info":"45he45m0v9184715813z89184708445za200zb9184708445","client_id":"179294588.1715353601","richsstsse":"","document_location":"https://sesamy.local.bestbytes.net/?gtm_debug=1716795486020","document_title":"Home","document_referrer":"https://tagassistant.google.com/","is_debug":"1","event_name":"add_to_cart","event_parameter":{"enable_page_views":"false"},"event_parameter_number":{"value":"30.03"},"session_id":"1716793773","non_personalized_ads":"1","sst":{"tft":"1716795486104","gcd":"13l3l3l2l1","ude":"0"}}`, }, { diff --git a/pkg/encoding/gtag/payload.go b/pkg/encoding/gtag/payload.go index bdaffdd..6438ebd 100644 --- a/pkg/encoding/gtag/payload.go +++ b/pkg/encoding/gtag/payload.go @@ -151,11 +151,6 @@ type Payload struct { NonPersonalizedAds *string `json:"non_personalized_ads,omitempty" gtag:"npa,omitempty"` // Example: 1 // ARE *string `json:"are,omitempty" gtag:"are,omitempty"` - // Example: 1 - // DigitalMarketAct *string `json:"digital_market_act,omitempty" gtag:"dma,omitempty"` - // Example: sypham - // DigitalMarketActParameters *string `json:"digital_market_act_parameters,omitempty" gtag:"dma_cps,omitempty"` - // Example: noapi | denied // PrivacySandboxCookieDeprecationLabel *string `json:"privacy_sandbox_cookie_deprecation_label,omitempty" gtag:"pscdl,omitempty"` // A timestamp measuring the difference between the moment this parameter gets populated and the moment the navigation started on that particular page. // TFD *string `json:"tfd,omitempty" gtag:"tfd,omitempty"` diff --git a/pkg/encoding/gtag/sst.go b/pkg/encoding/gtag/sst.go index 587f175..4712a9b 100644 --- a/pkg/encoding/gtag/sst.go +++ b/pkg/encoding/gtag/sst.go @@ -13,7 +13,7 @@ type SST struct { GCSub *string `json:"gcsub,omitempty" gtag:"gcsub,omitempty"` // Example: DE UC *string `json:"uc,omitempty" gtag:"uc,omitempty"` - // Example: 1708250245344 + // Session start time, time first seen. Example: 1708250245344 TFT *string `json:"tft,omitempty" gtag:"tft,omitempty"` // Example: 13l3l3l3l1 GCD *string `json:"gcd,omitempty" gtag:"gcd,omitempty"` diff --git a/pkg/encoding/gtagencode/mpv2.go b/pkg/encoding/gtagencode/mpv2.go index 06c5ceb..547b1da 100644 --- a/pkg/encoding/gtagencode/mpv2.go +++ b/pkg/encoding/gtagencode/mpv2.go @@ -7,6 +7,7 @@ import ( "strconv" "github.com/foomo/sesamy-go/pkg/encoding/gtag" + "github.com/foomo/sesamy-go/pkg/encoding/mpv2" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" ) @@ -26,12 +27,19 @@ func MPv2(source gtag.Payload, target any) error { targetData := map[string]any{ "client_id": source.ClientID, "user_id": source.UserID, + "session_id": source.SessionID, "non_personalized_ads": source.NonPersonalizedAds, "debug_mode": source.IsDebug, } - if source.SST != nil && source.SST.TFT != nil { - targetData["timestamp_micros"] = gtag.Get(source.SST.TFT) + "000" + + // consent + targetConsentData := map[string]any{ + "add_storage": mpv2.ConsentText(source.AdStorage()), + "ad_user_data": mpv2.ConsentText(source.AdUserData()), + "ad_personalization": mpv2.ConsentText(source.AdPersonalization()), + "analytics_storage": mpv2.ConsentText(source.AnalyticsStorage()), } + targetData["consent"] = targetConsentData // combine user properties targetUserProperties := map[string]any{} @@ -52,6 +60,15 @@ func MPv2(source gtag.Payload, target any) error { "name": source.EventName, } targetEventDataParams := map[string]any{} + if value, ok := sourceData["document_title"]; ok { + targetEventDataParams["page_title"] = value + } + if value, ok := sourceData["document_referrer"]; ok { + targetEventDataParams["page_referrer"] = value + } + if value, ok := sourceData["document_location"]; ok { + targetEventDataParams["page_location"] = value + } if node, ok := sourceData["ecommerce"].(map[string]any); ok { maps.Copy(targetEventDataParams, node) } diff --git a/pkg/encoding/mpv2/consent.go b/pkg/encoding/mpv2/consent.go index 2a78551..a770e01 100644 --- a/pkg/encoding/mpv2/consent.go +++ b/pkg/encoding/mpv2/consent.go @@ -6,3 +6,10 @@ const ( ConsentDenied Consent = "DENIED" ConsentGranted Consent = "GRANTED" ) + +func ConsentText(v bool) Consent { + if v { + return ConsentGranted + } + return ConsentDenied +} diff --git a/pkg/encoding/mpv2/payload.go b/pkg/encoding/mpv2/payload.go index 4abcf25..525276f 100644 --- a/pkg/encoding/mpv2/payload.go +++ b/pkg/encoding/mpv2/payload.go @@ -1,6 +1,8 @@ package mpv2 import ( + "time" + "github.com/foomo/sesamy-go/pkg/sesamy" ) @@ -17,3 +19,9 @@ type Payload[P any] struct { SessionID string `json:"session_id,omitempty"` EngagementTimeMSec int64 `json:"engagement_time_msec,omitempty"` } + +func NewPayload[P any]() *Payload[P] { + return &Payload[P]{ + TimestampMicros: time.Now().UnixMicro(), + } +} diff --git a/pkg/encoding/mpv2encode/gtag.go b/pkg/encoding/mpv2encode/gtag.go index 3ca1fab..1a9ed96 100644 --- a/pkg/encoding/mpv2encode/gtag.go +++ b/pkg/encoding/mpv2encode/gtag.go @@ -48,17 +48,23 @@ func GTag[P any](source mpv2.Payload[P], target any) error { targetData["event_name"] = sourceData["name"] if params, ok := sourceData["params"].(map[string]any); ok { + targetData["document_title"] = params["page_title"] + delete(params, "page_title") + targetData["document_referrer"] = params["page_referrer"] + delete(params, "page_referrer") + targetData["document_location"] = params["page_location"] + delete(params, "page_location") targetData["currency"] = params["currency"] - targetData["promotion_id"] = params["promotion_id"] - targetData["promotion_name"] = params["promotion_name"] - targetData["location_id"] = params["location_id"] - targetData["is_conversion"] = params["is_conversion"] - targetData["items"] = params["items"] delete(params, "currency") + targetData["promotion_id"] = params["promotion_id"] delete(params, "promotion_id") + targetData["promotion_name"] = params["promotion_name"] delete(params, "promotion_name") + targetData["location_id"] = params["location_id"] delete(params, "location_id") + targetData["is_conversion"] = params["is_conversion"] delete(params, "is_conversion") + targetData["items"] = params["items"] delete(params, "items") { // user_property targetEventProperty := map[string]any{} diff --git a/pkg/event/params/pageview.go b/pkg/event/params/pageview.go index 89c71a5..0bbc1a5 100644 --- a/pkg/event/params/pageview.go +++ b/pkg/event/params/pageview.go @@ -3,5 +3,6 @@ package params // PageView https://developers.google.com/analytics/devguides/collection/ga4/views?client_type=gtag#manually_send_page_view_events type PageView struct { PageTitle string `json:"page_title,omitempty"` + PageReferrer string `json:"page_referrer,omitempty"` PageLocation string `json:"page_location,omitempty"` } diff --git a/pkg/http/eventhandler.go b/pkg/http/eventhandler.go new file mode 100644 index 0000000..de10be5 --- /dev/null +++ b/pkg/http/eventhandler.go @@ -0,0 +1,10 @@ +package http + +import ( + "net/http" + + "github.com/foomo/sesamy-go/pkg/sesamy" + "go.uber.org/zap" +) + +type EventHandler func(l *zap.Logger, r *http.Request, event *sesamy.Event[any]) error diff --git a/pkg/http/gtag/handler.go b/pkg/http/gtag/handler.go new file mode 100644 index 0000000..bd214f8 --- /dev/null +++ b/pkg/http/gtag/handler.go @@ -0,0 +1,61 @@ +package gtag + +import ( + "fmt" + "io" + "net/http" + "net/url" + + "github.com/foomo/sesamy-go/pkg/encoding/gtag" +) + +func Handler(w http.ResponseWriter, r *http.Request) *gtag.Payload { + var values url.Values + + switch r.Method { + case http.MethodGet: + values = r.URL.Query() + case http.MethodPost: + values = r.URL.Query() + + // read request body + out, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, fmt.Sprintf("failed to read body: %s", err.Error()), http.StatusInternalServerError) + return nil + } + defer r.Body.Close() + + // append request body to query + if len(out) > 0 { + v, err := url.ParseQuery(string(out)) + if err != nil { + http.Error(w, fmt.Sprintf("failed to parse extended url: %s", err.Error()), http.StatusInternalServerError) + return nil + } + for s2, i := range v { + values.Set(s2, i[0]) + } + } else { + values = r.URL.Query() + } + default: + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return nil + } + + // unmarshal event + var payload *gtag.Payload + if err := gtag.Decode(values, &payload); err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil + } + + // validate + if payload.EventName == nil || payload.EventName.String() == "" { + http.Error(w, "missing event name", http.StatusBadRequest) + return nil + } + + return payload +} diff --git a/pkg/http/gtag/middleware.go b/pkg/http/gtag/middleware.go new file mode 100644 index 0000000..c2ee813 --- /dev/null +++ b/pkg/http/gtag/middleware.go @@ -0,0 +1,73 @@ +package gtag + +import ( + "net/http" + + "github.com/foomo/sesamy-go/pkg/encoding/gtag" + "github.com/foomo/sesamy-go/pkg/encoding/gtagencode" + "github.com/foomo/sesamy-go/pkg/encoding/mpv2" + "github.com/foomo/sesamy-go/pkg/encoding/mpv2encode" + sesamyhttp "github.com/foomo/sesamy-go/pkg/http" + "github.com/pkg/errors" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +type ( + Middleware func(next MiddlewareHandler) MiddlewareHandler + MiddlewareHandler func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error +) + +func MiddlewareEventHandler(h sesamyhttp.EventHandler) Middleware { + return func(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error { + var mpv2Payload *mpv2.Payload[any] + if err := gtagencode.MPv2(*payload, &mpv2Payload); err != nil { + return errors.Wrap(err, "failed to encode gtag to mpv2") + } + + for i, event := range mpv2Payload.Events { + if err := h(l, r, &event); err != nil { + return err + } + mpv2Payload.Events[i] = event + } + + if err := mpv2encode.GTag[any](*mpv2Payload, &payload); err != nil { + return errors.Wrap(err, "failed to encode mpv2 to gtag") + } + return next(l, w, r, payload) + } + } +} + +func MiddlewareUserID(cookieName string) Middleware { + return func(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error { + if cookie, err := r.Cookie(cookieName); err == nil { + payload.UserID = gtag.Set(cookie.Value) + } + return next(l, w, r, payload) + } + } +} + +func MiddlewareLogger(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *gtag.Payload) error { + if spanCtx := trace.SpanContextFromContext(r.Context()); spanCtx.IsValid() && spanCtx.IsSampled() { + l = l.With(zap.String("trace_id", spanCtx.TraceID().String()), zap.String("span_id", spanCtx.SpanID().String())) + } + l = l.With( + zap.String("event_name", gtag.GetDefault(payload.EventName, "-").String()), + zap.String("event_user_id", gtag.GetDefault(payload.UserID, "-")), + zap.String("event_session_id", gtag.GetDefault(payload.SessionID, "-")), + ) + err := next(l, w, r, payload) + if err != nil { + l.Error("handled event", zap.Error(err)) + } else { + l.Info("handled event") + } + return err + } +} diff --git a/pkg/http/mpv2/handler.go b/pkg/http/mpv2/handler.go new file mode 100644 index 0000000..2e59827 --- /dev/null +++ b/pkg/http/mpv2/handler.go @@ -0,0 +1,37 @@ +package mpv2 + +import ( + "encoding/json" + "net/http" + + "github.com/foomo/sesamy-go/pkg/encoding/mpv2" +) + +func Handler(w http.ResponseWriter, r *http.Request) *mpv2.Payload[any] { + if r.Method != http.MethodPost { + http.Error(w, http.StatusText(http.StatusMethodNotAllowed), http.StatusMethodNotAllowed) + return nil + } + + // read request body + var payload *mpv2.Payload[any] + err := json.NewDecoder(r.Body).Decode(&payload) + if err != nil { + http.Error(w, err.Error(), http.StatusInternalServerError) + return nil + } + + // validate required fields + if len(payload.Events) == 0 { + http.Error(w, "missing events", http.StatusBadRequest) + return nil + } + for _, event := range payload.Events { + if event.Name == "" { + http.Error(w, "missing event name", http.StatusBadRequest) + return nil + } + } + + return payload +} diff --git a/pkg/http/mpv2/middleware.go b/pkg/http/mpv2/middleware.go new file mode 100644 index 0000000..4cc1c24 --- /dev/null +++ b/pkg/http/mpv2/middleware.go @@ -0,0 +1,170 @@ +package mpv2 + +import ( + "net/http" + "strings" + "time" + + "github.com/foomo/sesamy-go/pkg/encoding/mpv2" + sesamyhttp "github.com/foomo/sesamy-go/pkg/http" + "github.com/foomo/sesamy-go/pkg/session" + "github.com/pkg/errors" + "go.opentelemetry.io/otel/trace" + "go.uber.org/zap" +) + +type ( + MiddlewareHandler func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error + Middleware func(next MiddlewareHandler) MiddlewareHandler +) + +func MiddlewareEventHandler(h sesamyhttp.EventHandler) Middleware { + return func(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + for i, event := range payload.Events { + if err := h(l, r, &event); err != nil { + return err + } + payload.Events[i] = event + } + return next(l, w, r, payload) + } + } +} + +func MiddlewareSessionID(measurementID string) Middleware { + measurementID = strings.Split(measurementID, "-")[1] + return func(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if payload.SessionID == "" { + value, err := session.ParseGASessionID(r, measurementID) + if err != nil && !errors.Is(err, http.ErrNoCookie) { + return err + } + payload.SessionID = value + } + return next(l, w, r, payload) + } + } +} + +func MiddlewareClientID(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if payload.ClientID == "" { + value, err := session.ParseGAClientID(r) + if err != nil && !errors.Is(err, http.ErrNoCookie) { + return err + } + payload.ClientID = value + } + return next(l, w, r, payload) + } +} + +func MiddlewareDebugMode(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if !payload.DebugMode && session.IsGTMDebug(r) { + payload.DebugMode = true + } + return next(l, w, r, payload) + } +} + +func MiddlewareUserID(cookieName string) Middleware { + return func(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if payload.UserID == "" { + value, err := r.Cookie(cookieName) + if err != nil && !errors.Is(err, http.ErrNoCookie) { + return err + } + payload.UserID = value.Value + } + return next(l, w, r, payload) + } + } +} + +func MiddlewareTimestamp(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if payload.TimestampMicros == 0 { + payload.TimestampMicros = time.Now().UnixMicro() + } + return next(l, w, r, payload) + } +} + +func MiddlewareUserAgent(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if userAgent := r.Header.Get("User-Agent"); userAgent != "" { + for i, event := range payload.Events { + if value, ok := event.Params.(map[string]any); ok { + value["user_agent"] = userAgent + payload.Events[i] = event + } + } + } + return next(l, w, r, payload) + } +} + +func MiddlewareIPOverride(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + var ipOverride string + for _, key := range []string{"CF-Connecting-IP", "X-Original-Forwarded-For", "X-Forwarded-For", "X-Real-Ip"} { + if value := r.Header.Get(key); value != "" { + ipOverride = value + break + } + } + if ipOverride != "" { + for i, event := range payload.Events { + if value, ok := event.Params.(map[string]any); ok { + value["ip_override"] = ipOverride + payload.Events[i] = event + } + } + } + return next(l, w, r, payload) + } +} + +func MiddlewarePageLocation(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + if referrer := r.Header.Get("Referer"); referrer != "" { + for i, event := range payload.Events { + if value, ok := event.Params.(map[string]any); ok { + value["page_location"] = referrer + payload.Events[i] = event + } + } + } + return next(l, w, r, payload) + } +} + +func MiddlewareLogger(next MiddlewareHandler) MiddlewareHandler { + return func(l *zap.Logger, w http.ResponseWriter, r *http.Request, payload *mpv2.Payload[any]) error { + eventNames := make([]string, len(payload.Events)) + for i, event := range payload.Events { + eventNames[i] = event.Name.String() + } + + if spanCtx := trace.SpanContextFromContext(r.Context()); spanCtx.IsValid() && spanCtx.IsSampled() { + l = l.With(zap.String("trace_id", spanCtx.TraceID().String()), zap.String("span_id", spanCtx.SpanID().String())) + } + + l = l.With( + zap.String("event_names", strings.Join(eventNames, ",")), + zap.String("event_user_id", payload.UserID), + ) + + err := next(l, w, r, payload) + if err != nil { + l.Error("handled event", zap.Error(err)) + } else { + l.Info("handled event") + } + return err + } +} diff --git a/pkg/sesamy/event.go b/pkg/sesamy/event.go index e302325..ab50dbf 100644 --- a/pkg/sesamy/event.go +++ b/pkg/sesamy/event.go @@ -36,3 +36,7 @@ func (e Event[P]) Decode(output any) error { func (e Event[P]) DecodeParams(output any) error { return Decode(e.Params, output) } + +func (e Event[P]) EncodeParams(input any) error { + return Decode(input, &e.Params) +} diff --git a/pkg/sesamy/event_test.go b/pkg/sesamy/event_test.go new file mode 100644 index 0000000..a030d8e --- /dev/null +++ b/pkg/sesamy/event_test.go @@ -0,0 +1,35 @@ +package sesamy_test + +import ( + "testing" + + "github.com/foomo/sesamy-go/pkg/sesamy" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestDecodeParams(t *testing.T) { + type params struct { + Title string `json:"title"` + } + + event := sesamy.Event[any]{ + Name: "test", + Params: map[string]any{ + "title": "foo", + "description": "foo", + }, + } + + var p params + require.NoError(t, event.DecodeParams(&p)) + assert.Equal(t, "foo", p.Title) + + p.Title = "bar" + + require.NoError(t, event.EncodeParams(p)) + assert.Equal(t, map[string]any{ + "title": "bar", + "description": "foo", + }, event.Params) +}