Merge pull request #31 from foomo/sesamy-go-0.4.2

feat: collect
This commit is contained in:
Kevin Franklin Kim 2024-11-25 15:49:30 +01:00 committed by GitHub
commit ad3a673307
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
26 changed files with 734 additions and 263 deletions

View File

@ -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",

View File

@ -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
}
}
}

View File

@ -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))
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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 {

View File

@ -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
}
}

View File

@ -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)

View File

@ -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)
}
}

184
pkg/collect/collect.go Normal file
View File

@ -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
}

View File

@ -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()
}

View File

@ -40,7 +40,7 @@ func TestDecode(t *testing.T) {
},
{
name: "add_to_cart",
args: GTagAddToCart,
args: "v=2&tid=G-F9XM71K45T&gtm=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"}}`,
},
{

View File

@ -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"`

View File

@ -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"`

View File

@ -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)
}

View File

@ -6,3 +6,10 @@ const (
ConsentDenied Consent = "DENIED"
ConsentGranted Consent = "GRANTED"
)
func ConsentText(v bool) Consent {
if v {
return ConsentGranted
}
return ConsentDenied
}

View File

@ -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(),
}
}

View File

@ -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{}

View File

@ -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"`
}

10
pkg/http/eventhandler.go Normal file
View File

@ -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

61
pkg/http/gtag/handler.go Normal file
View File

@ -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
}

View File

@ -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
}
}

37
pkg/http/mpv2/handler.go Normal file
View File

@ -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
}

170
pkg/http/mpv2/middleware.go Normal file
View File

@ -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
}
}

View File

@ -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)
}

35
pkg/sesamy/event_test.go Normal file
View File

@ -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)
}