mirror of
https://github.com/foomo/sesamy-go.git
synced 2025-10-16 12:35:43 +00:00
commit
dae9b3ee29
@ -113,7 +113,6 @@ linters:
|
||||
- errname # Checks that sentinel errors are prefixed with the `Err` and error types are suffixed with the `Error`. [fast: false, auto-fix: false]
|
||||
- errorlint # errorlint is a linter for that can be used to find code that will cause problems with the error wrapping scheme introduced in Go 1.13. [fast: false, auto-fix: false]
|
||||
- exhaustive # check exhaustiveness of enum switch statements [fast: false, auto-fix: false]
|
||||
- exportloopref # checks for pointers to enclosing loop variables [fast: false, auto-fix: false]
|
||||
#- forbidigo # Forbids identifiers [fast: false, auto-fix: false]
|
||||
- forcetypeassert # finds forced type assertions [fast: true, auto-fix: false]
|
||||
- gocheckcompilerdirectives # Checks that go compiler directive comments (//go:) are valid. [fast: true, auto-fix: false]
|
||||
|
||||
5
go.mod
5
go.mod
@ -6,6 +6,7 @@ toolchain go1.23.0
|
||||
|
||||
require (
|
||||
github.com/ThreeDotsLabs/watermill v1.3.5
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc
|
||||
github.com/foomo/go v0.0.3
|
||||
github.com/foomo/gostandards v0.1.0
|
||||
github.com/gogo/protobuf v1.3.2
|
||||
@ -16,9 +17,11 @@ require (
|
||||
github.com/json-iterator/go v1.1.12
|
||||
github.com/mitchellh/mapstructure v1.5.0
|
||||
github.com/pkg/errors v0.9.1
|
||||
github.com/pperaltaisern/watermillzap v1.0.0
|
||||
github.com/prometheus/common v0.55.0
|
||||
github.com/stretchr/testify v1.9.0
|
||||
go.uber.org/zap v1.27.0
|
||||
gopkg.in/yaml.v2 v2.4.0
|
||||
)
|
||||
|
||||
require (
|
||||
@ -40,7 +43,6 @@ require (
|
||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||
github.com/coreos/go-semver v0.3.0 // indirect
|
||||
github.com/coreos/go-systemd/v22 v22.5.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dennwc/varint v1.0.0 // indirect
|
||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
@ -140,7 +142,6 @@ require (
|
||||
google.golang.org/genproto/googleapis/rpc v0.0.0-20240318140521-94a12d6c2237 // indirect
|
||||
google.golang.org/grpc v1.62.1 // indirect
|
||||
google.golang.org/protobuf v1.34.2 // indirect
|
||||
gopkg.in/yaml.v2 v2.4.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
k8s.io/apimachinery v0.29.2 // indirect
|
||||
k8s.io/client-go v0.29.2 // indirect
|
||||
|
||||
2
go.sum
2
go.sum
@ -495,6 +495,8 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/posener/complete v1.1.1/go.mod h1:em0nMJCgc9GFtwrmVmEMR/ZL6WyhyjMBndrE9hABlRI=
|
||||
github.com/posener/complete v1.2.3/go.mod h1:WZIdtGGp+qx0sLrYKtIRAruyNpv6hFCicSgv7Sy7s/s=
|
||||
github.com/pperaltaisern/watermillzap v1.0.0 h1:TtlI/WW6VHgkwgyXOtcMd/Utvw/2ZBm22o4bJ9IaoD4=
|
||||
github.com/pperaltaisern/watermillzap v1.0.0/go.mod h1:tc6T7N3R5pKS7RAG2RUoscJI33yVk8aUPrfzHTGNR3c=
|
||||
github.com/prometheus/client_golang v0.9.1/go.mod h1:7SWBe2y4D6OKWSNQJUaRYU/AaXPKyh/dDVn+NZz0KFw=
|
||||
github.com/prometheus/client_golang v1.0.0/go.mod h1:db9x61etRT2tGnBNRi70OPL5FsnadC4Ky3P0J6CfImo=
|
||||
github.com/prometheus/client_golang v1.4.0/go.mod h1:e9GMxYsXl05ICDXkRhurwBS4Q3OK1iX/F2sw+iXX5zU=
|
||||
|
||||
@ -8,15 +8,16 @@ import (
|
||||
)
|
||||
|
||||
type Line struct {
|
||||
Name sesamy.EventName `json:"name"`
|
||||
Params any `json:"params"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserProperties map[string]any `json:"user_properties,omitempty"`
|
||||
Consent *mpv2.Consent `json:"consent,omitempty"`
|
||||
NonPersonalizedAds bool `json:"non_personalized_ads,omitempty"`
|
||||
UserData *mpv2.UserData `json:"user_data,omitempty"`
|
||||
DebugMode bool `json:"debug_mode,omitempty"`
|
||||
Name sesamy.EventName `json:"name"`
|
||||
Params any `json:"params"`
|
||||
ClientID string `json:"client_id"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
UserProperties map[string]any `json:"user_properties,omitempty"`
|
||||
Consent *mpv2.ConsentData `json:"consent,omitempty"`
|
||||
UserData *mpv2.UserData `json:"user_data,omitempty"`
|
||||
DebugMode bool `json:"debug_mode,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
EngagementTimeMSec int64 `json:"engagement_time_msec,omitempty"`
|
||||
}
|
||||
|
||||
func (l *Line) Marshal() ([]byte, error) {
|
||||
|
||||
@ -142,8 +142,9 @@ func (l *Loki) Write(payload mpv2.Payload[any]) {
|
||||
UserData: payload.UserData,
|
||||
ClientID: payload.ClientID,
|
||||
UserProperties: payload.UserProperties,
|
||||
NonPersonalizedAds: payload.NonPersonalizedAds,
|
||||
DebugMode: payload.DebugMode,
|
||||
SessionID: payload.SessionID,
|
||||
EngagementTimeMSec: payload.EngagementTimeMSec,
|
||||
}
|
||||
|
||||
lineBytes, err := line.Marshal()
|
||||
|
||||
23
integration/loki/messagehandler.go
Normal file
23
integration/loki/messagehandler.go
Normal file
@ -0,0 +1,23 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func MPv2MessageHandler(loki *Loki) message.NoPublishHandlerFunc {
|
||||
return func(msg *message.Message) error {
|
||||
var payload mpv2.Payload[any]
|
||||
|
||||
// unmarshal payload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
return errors.Wrap(err, "failed to unmarshal payload")
|
||||
}
|
||||
|
||||
loki.Write(payload)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
29
integration/loki/service.go
Normal file
29
integration/loki/service.go
Normal file
@ -0,0 +1,29 @@
|
||||
package loki
|
||||
|
||||
import (
|
||||
"context"
|
||||
)
|
||||
|
||||
type Service struct {
|
||||
loki *Loki
|
||||
}
|
||||
|
||||
func NewService(loki *Loki) *Service {
|
||||
return &Service{loki: loki}
|
||||
}
|
||||
|
||||
func (s *Service) Name() string {
|
||||
return "loki"
|
||||
}
|
||||
|
||||
// Start pulls lines out of the channel and sends them to Loki
|
||||
func (s *Service) Start(ctx context.Context) error {
|
||||
s.loki.Start(ctx)
|
||||
return nil
|
||||
}
|
||||
|
||||
// Close will cancel any ongoing requests and stop the goroutine listening for requests
|
||||
func (s *Service) Close(ctx context.Context) error {
|
||||
s.loki.Stop()
|
||||
return nil
|
||||
}
|
||||
@ -5,6 +5,8 @@ import (
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"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/pkg/errors"
|
||||
)
|
||||
|
||||
@ -32,3 +34,27 @@ func MessageHandler(handler func(payload *gtag.Payload, msg *message.Message) er
|
||||
return []*message.Message{msg}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func MPv2MessageHandler(msg *message.Message) ([]*message.Message, error) {
|
||||
var payload gtag.Payload
|
||||
|
||||
// unmarshal payload
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to unmarshal payload")
|
||||
}
|
||||
|
||||
// encode to mpv2
|
||||
var mpv2Payload *mpv2.Payload[any]
|
||||
if err := gtagencode.MPv2(payload, &mpv2Payload); err != nil {
|
||||
return nil, errors.Wrap(err, "failed to encode gtag to mpv2")
|
||||
}
|
||||
|
||||
// marshal payload
|
||||
b, err := json.Marshal(mpv2Payload)
|
||||
if err != nil {
|
||||
return nil, errors.Wrap(err, "failed to marshal payload")
|
||||
}
|
||||
msg.Payload = b
|
||||
|
||||
return []*message.Message{msg}, nil
|
||||
}
|
||||
|
||||
148
integration/watermill/gtag/messagehandler_test.go
Normal file
148
integration/watermill/gtag/messagehandler_test.go
Normal file
@ -0,0 +1,148 @@
|
||||
package gtag_test
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/ThreeDotsLabs/watermill/pubsub/gochannel"
|
||||
"github.com/foomo/sesamy-go/integration/watermill/gtag"
|
||||
encoding "github.com/foomo/sesamy-go/pkg/encoding/gtag"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
"github.com/pperaltaisern/watermillzap"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestMessageHandler(t *testing.T) {
|
||||
l := zaptest.NewLogger(t)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, watermillzap.NewLogger(l))
|
||||
require.NoError(t, err)
|
||||
defer router.Close()
|
||||
|
||||
// Create pubSub
|
||||
pubSub := gochannel.NewGoChannel(
|
||||
gochannel.Config{},
|
||||
watermillzap.NewLogger(l),
|
||||
)
|
||||
|
||||
var done atomic.Bool
|
||||
router.AddHandler("gtag", "in", pubSub, "out", pubSub, gtag.MessageHandler(func(payload *encoding.Payload, msg *message.Message) error {
|
||||
expected := `{"consent":{},"campaign":{},"ecommerce":{},"client_hints":{},"protocol_version":"2","client_id":"C123456","richsstsse":"1","document_location":"https://foomo.org","document_title":"Home","is_debug":"1","event_name":"add_to_cart"}`
|
||||
if !assert.JSONEq(t, expected, string(msg.Payload)) {
|
||||
fmt.Println(string(msg.Payload))
|
||||
}
|
||||
done.Store(true)
|
||||
return nil
|
||||
}))
|
||||
|
||||
go func() {
|
||||
assert.NoError(t, router.Run(context.TODO()))
|
||||
}()
|
||||
assert.Eventually(t, router.IsRunning, time.Second, 50*time.Millisecond)
|
||||
|
||||
payload := encoding.Payload{
|
||||
Consent: encoding.Consent{},
|
||||
Campaign: encoding.Campaign{},
|
||||
ECommerce: encoding.ECommerce{},
|
||||
ClientHints: encoding.ClientHints{},
|
||||
ProtocolVersion: encoding.Set("2"),
|
||||
TrackingID: nil,
|
||||
GTMHashInfo: nil,
|
||||
ClientID: encoding.Set("C123456"),
|
||||
Richsstsse: encoding.Set("1"),
|
||||
DocumentLocation: encoding.Set("https://foomo.org"),
|
||||
DocumentTitle: encoding.Set("Home"),
|
||||
DocumentReferrer: nil,
|
||||
IsDebug: encoding.Set("1"),
|
||||
EventName: encoding.Set(sesamy.EventNameAddToCart),
|
||||
EventParameter: nil,
|
||||
EventParameterNumber: nil,
|
||||
UserID: nil,
|
||||
SessionID: nil,
|
||||
UserProperty: nil,
|
||||
UserPropertyNumber: nil,
|
||||
NonPersonalizedAds: nil,
|
||||
SST: nil,
|
||||
Remain: nil,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), jsonPayload)
|
||||
|
||||
require.NoError(t, pubSub.Publish("in", msg))
|
||||
|
||||
assert.Eventually(t, done.Load, time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestMPv2MessageHandler(t *testing.T) {
|
||||
l := zaptest.NewLogger(t)
|
||||
|
||||
router, err := message.NewRouter(message.RouterConfig{}, watermillzap.NewLogger(l))
|
||||
require.NoError(t, err)
|
||||
defer router.Close()
|
||||
|
||||
// Create pubSub
|
||||
pubSub := gochannel.NewGoChannel(
|
||||
gochannel.Config{},
|
||||
watermillzap.NewLogger(l),
|
||||
)
|
||||
|
||||
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}`
|
||||
if !assert.JSONEq(t, expected, string(msg.Payload)) {
|
||||
fmt.Println(string(msg.Payload))
|
||||
}
|
||||
done.Store(true)
|
||||
return nil
|
||||
})
|
||||
|
||||
go func() {
|
||||
assert.NoError(t, router.Run(context.TODO()))
|
||||
}()
|
||||
assert.Eventually(t, router.IsRunning, time.Second, 50*time.Millisecond)
|
||||
|
||||
payload := encoding.Payload{
|
||||
Consent: encoding.Consent{},
|
||||
Campaign: encoding.Campaign{},
|
||||
ECommerce: encoding.ECommerce{},
|
||||
ClientHints: encoding.ClientHints{},
|
||||
ProtocolVersion: encoding.Set("2"),
|
||||
TrackingID: nil,
|
||||
GTMHashInfo: nil,
|
||||
ClientID: encoding.Set("C123456"),
|
||||
Richsstsse: encoding.Set("1"),
|
||||
DocumentLocation: encoding.Set("https://foomo.org"),
|
||||
DocumentTitle: encoding.Set("Home"),
|
||||
DocumentReferrer: nil,
|
||||
IsDebug: encoding.Set("1"),
|
||||
EventName: encoding.Set(sesamy.EventNameAddToCart),
|
||||
EventParameter: nil,
|
||||
EventParameterNumber: nil,
|
||||
UserID: nil,
|
||||
SessionID: nil,
|
||||
UserProperty: nil,
|
||||
UserPropertyNumber: nil,
|
||||
NonPersonalizedAds: nil,
|
||||
SST: nil,
|
||||
Remain: nil,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), jsonPayload)
|
||||
|
||||
require.NoError(t, pubSub.Publish("in", msg))
|
||||
|
||||
assert.Eventually(t, done.Load, time.Second, 50*time.Millisecond)
|
||||
}
|
||||
@ -19,13 +19,17 @@ var (
|
||||
|
||||
type (
|
||||
Publisher struct {
|
||||
l *zap.Logger
|
||||
host string
|
||||
path string
|
||||
client *http.Client
|
||||
closed bool
|
||||
l *zap.Logger
|
||||
host string
|
||||
path string
|
||||
client *http.Client
|
||||
closed bool
|
||||
middlewares []PublisherMiddleware
|
||||
maxResponseCode int
|
||||
}
|
||||
PublisherOption func(*Publisher)
|
||||
PublisherOption func(*Publisher)
|
||||
PublisherHandler func(l *zap.Logger, msg *message.Message) error
|
||||
PublisherMiddleware func(next PublisherHandler) PublisherHandler
|
||||
// PublisherMarshalMessageFunc transforms the message into a HTTP request to be sent to the specified url.
|
||||
PublisherMarshalMessageFunc func(url string, msg *message.Message) (*http.Request, error)
|
||||
)
|
||||
@ -36,10 +40,11 @@ type (
|
||||
|
||||
func NewPublisher(l *zap.Logger, host string, opts ...PublisherOption) *Publisher {
|
||||
inst := &Publisher{
|
||||
l: l,
|
||||
host: host,
|
||||
path: "/g/collect",
|
||||
client: http.DefaultClient,
|
||||
l: l,
|
||||
host: host,
|
||||
path: "/g/collect",
|
||||
client: http.DefaultClient,
|
||||
maxResponseCode: http.StatusBadRequest,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(inst)
|
||||
@ -63,6 +68,18 @@ func PublisherWithClient(v *http.Client) PublisherOption {
|
||||
}
|
||||
}
|
||||
|
||||
func PublisherWithMiddlewares(v ...PublisherMiddleware) PublisherOption {
|
||||
return func(o *Publisher) {
|
||||
o.middlewares = append(o.middlewares, v...)
|
||||
}
|
||||
}
|
||||
|
||||
func PublisherWithMaxResponseCode(v int) PublisherOption {
|
||||
return func(o *Publisher) {
|
||||
o.maxResponseCode = v
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// ~ Getter
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
@ -81,50 +98,16 @@ func (p *Publisher) Publish(topic string, messages ...*message.Message) error {
|
||||
}
|
||||
|
||||
for _, msg := range messages {
|
||||
var event *gtag.Payload
|
||||
if err := json.Unmarshal(msg.Payload, &event); err != nil {
|
||||
return err
|
||||
// compose middlewares
|
||||
next := p.handle
|
||||
for _, middleware := range p.middlewares {
|
||||
next = middleware(next)
|
||||
}
|
||||
|
||||
values, body, err := gtag.Encode(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(msg.Context(), http.MethodPost, fmt.Sprintf("%s%s?%s", p.host, p.path, gtag.EncodeValues(values)), body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create request")
|
||||
}
|
||||
|
||||
for s, s2 := range msg.Metadata {
|
||||
req.Header.Set(s, s2)
|
||||
}
|
||||
|
||||
l := p.l.With(
|
||||
// run handler
|
||||
if err := next(p.l.With(
|
||||
zap.String("message_id", msg.UUID),
|
||||
)
|
||||
|
||||
if err := func() error {
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to publish message: %s", msg.UUID)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
l = l.With(zap.Int("http_status_code", resp.StatusCode))
|
||||
|
||||
if resp.StatusCode >= http.StatusBadRequest {
|
||||
if body, err := io.ReadAll(resp.Body); err == nil {
|
||||
l = l.With(zap.String("http_response", string(body)))
|
||||
}
|
||||
l.Warn("server responded with error")
|
||||
return errors.Wrap(ErrErrorResponse, resp.Status)
|
||||
}
|
||||
|
||||
l.Debug("message published")
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
), msg); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
@ -140,3 +123,51 @@ func (p *Publisher) Close() error {
|
||||
p.closed = true
|
||||
return nil
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// ~ Private methods
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
func (p *Publisher) handle(l *zap.Logger, msg *message.Message) error {
|
||||
var event *gtag.Payload
|
||||
if err := json.Unmarshal(msg.Payload, &event); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
values, body, err := gtag.Encode(event)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(msg.Context(), http.MethodPost, fmt.Sprintf("%s%s?%s", p.host, p.path, gtag.EncodeValues(values)), body)
|
||||
if err != nil {
|
||||
return errors.Wrap(err, "failed to create request")
|
||||
}
|
||||
|
||||
for s, s2 := range msg.Metadata {
|
||||
req.Header.Set(s, s2)
|
||||
}
|
||||
|
||||
if err := func() error {
|
||||
resp, err := p.client.Do(req)
|
||||
if err != nil {
|
||||
return errors.Wrapf(err, "failed to publish message: %s", msg.UUID)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
l = l.With(zap.Int("http_status_code", resp.StatusCode))
|
||||
|
||||
if p.maxResponseCode > 0 && resp.StatusCode >= p.maxResponseCode {
|
||||
if body, err := io.ReadAll(resp.Body); err == nil {
|
||||
l = l.With(zap.String("http_response", string(body)))
|
||||
}
|
||||
return errors.Wrap(ErrErrorResponse, resp.Status)
|
||||
}
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
68
integration/watermill/gtag/publisher_test.go
Normal file
68
integration/watermill/gtag/publisher_test.go
Normal file
@ -0,0 +1,68 @@
|
||||
package gtag_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/foomo/sesamy-go/integration/watermill/gtag"
|
||||
encoding "github.com/foomo/sesamy-go/pkg/encoding/gtag"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestPublisher(t *testing.T) {
|
||||
l := zaptest.NewLogger(t)
|
||||
|
||||
var done atomic.Bool
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
expected := `_dbg=1&cid=C123456&dl=https%3A%2F%2Ffoomo.org&dt=Home&en=add_to_cart&v=2&richsstsse`
|
||||
assert.Equal(t, expected, r.URL.RawQuery)
|
||||
done.Store(true)
|
||||
}))
|
||||
|
||||
p := gtag.NewPublisher(l, s.URL)
|
||||
|
||||
payload := encoding.Payload{
|
||||
Consent: encoding.Consent{},
|
||||
Campaign: encoding.Campaign{},
|
||||
ECommerce: encoding.ECommerce{},
|
||||
ClientHints: encoding.ClientHints{},
|
||||
ProtocolVersion: encoding.Set("2"),
|
||||
TrackingID: nil,
|
||||
GTMHashInfo: nil,
|
||||
ClientID: encoding.Set("C123456"),
|
||||
Richsstsse: encoding.Set("1"),
|
||||
DocumentLocation: encoding.Set("https://foomo.org"),
|
||||
DocumentTitle: encoding.Set("Home"),
|
||||
DocumentReferrer: nil,
|
||||
IsDebug: encoding.Set("1"),
|
||||
EventName: encoding.Set(sesamy.EventNameAddToCart),
|
||||
EventParameter: nil,
|
||||
EventParameterNumber: nil,
|
||||
UserID: nil,
|
||||
SessionID: nil,
|
||||
UserProperty: nil,
|
||||
UserPropertyNumber: nil,
|
||||
NonPersonalizedAds: nil,
|
||||
SST: nil,
|
||||
Remain: nil,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
fmt.Println(string(jsonPayload))
|
||||
msg := message.NewMessage(watermill.NewUUID(), jsonPayload)
|
||||
|
||||
require.NoError(t, p.Publish("foo", msg))
|
||||
|
||||
assert.Eventually(t, done.Load, time.Second, 50*time.Millisecond)
|
||||
}
|
||||
14
integration/watermill/gtag/publishermiddleware.go
Normal file
14
integration/watermill/gtag/publishermiddleware.go
Normal file
@ -0,0 +1,14 @@
|
||||
package gtag
|
||||
|
||||
import (
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func PublisherMiddlewareIgnoreError(next PublisherHandler) PublisherHandler {
|
||||
return func(l *zap.Logger, msg *message.Message) error {
|
||||
err := next(l, msg)
|
||||
l.With(zap.Error(err)).Warn("ignoring error")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
5
integration/watermill/mpv2/metadata.go
Normal file
5
integration/watermill/mpv2/metadata.go
Normal file
@ -0,0 +1,5 @@
|
||||
package mpv2
|
||||
|
||||
const (
|
||||
MetadataRequestQuery = "RequestQuery"
|
||||
)
|
||||
@ -119,17 +119,20 @@ func (p *Publisher) handle(l *zap.Logger, msg *message.Message) error {
|
||||
return errors.Wrap(err, "failed to create request")
|
||||
}
|
||||
|
||||
for s, s2 := range msg.Metadata {
|
||||
if s == "Cookie" {
|
||||
for _, s3 := range strings.Split(s2, "; ") {
|
||||
for key, value := range msg.Metadata {
|
||||
switch key {
|
||||
case "Cookie":
|
||||
for _, s3 := range strings.Split(value, "; ") {
|
||||
val := strings.Split(s3, "=")
|
||||
req.AddCookie(&http.Cookie{
|
||||
Name: val[0],
|
||||
Value: strings.Join(val[1:], "="),
|
||||
})
|
||||
}
|
||||
} else {
|
||||
req.Header.Set(s, s2)
|
||||
case MetadataRequestQuery:
|
||||
req.URL.RawQuery = value
|
||||
default:
|
||||
req.Header.Set(key, value)
|
||||
}
|
||||
}
|
||||
|
||||
@ -146,12 +149,9 @@ func (p *Publisher) handle(l *zap.Logger, msg *message.Message) error {
|
||||
if body, err := io.ReadAll(resp.Body); err == nil {
|
||||
l = l.With(zap.String("http_response", string(body)))
|
||||
}
|
||||
l.Warn("server responded with error")
|
||||
return errors.Wrap(ErrErrorResponse, resp.Status)
|
||||
}
|
||||
|
||||
l.Debug("message published")
|
||||
|
||||
return nil
|
||||
}(); err != nil {
|
||||
return err
|
||||
|
||||
67
integration/watermill/mpv2/publisher_test.go
Normal file
67
integration/watermill/mpv2/publisher_test.go
Normal file
@ -0,0 +1,67 @@
|
||||
package mpv2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/foomo/sesamy-go/integration/watermill/mpv2"
|
||||
encoding "github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/foomo/sesamy-go/pkg/event"
|
||||
"github.com/foomo/sesamy-go/pkg/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestPublisher(t *testing.T) {
|
||||
l := zaptest.NewLogger(t)
|
||||
|
||||
var done atomic.Bool
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected := `{"client_id":"C123456","user_id":"U123456","timestamp_micros":1727701064057701,"events":[{"name":"page_view","params":{"page_title":"Home","page_location":"https://foomo.org"}}],"debug_mode":true,"session_id":"S123456","engagement_time_msec":100}`
|
||||
if !assert.JSONEq(t, expected, string(out)) {
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
done.Store(true)
|
||||
}))
|
||||
|
||||
p := mpv2.NewPublisher(l, s.URL)
|
||||
|
||||
payload := encoding.Payload[params.PageView]{
|
||||
ClientID: "C123456",
|
||||
UserID: "U123456",
|
||||
TimestampMicros: 1727701064057701,
|
||||
UserProperties: nil,
|
||||
Consent: nil,
|
||||
Events: []sesamy.Event[params.PageView]{
|
||||
event.NewPageView(params.PageView{
|
||||
PageTitle: "Home",
|
||||
PageLocation: "https://foomo.org",
|
||||
}),
|
||||
},
|
||||
UserData: nil,
|
||||
DebugMode: true,
|
||||
SessionID: "S123456",
|
||||
EngagementTimeMSec: 100,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), jsonPayload)
|
||||
|
||||
require.NoError(t, p.Publish("foo", msg))
|
||||
|
||||
assert.Eventually(t, done.Load, time.Second, 50*time.Millisecond)
|
||||
}
|
||||
@ -8,19 +8,41 @@ import (
|
||||
"go.uber.org/zap"
|
||||
)
|
||||
|
||||
func PublisherMiddlewareDebugMode(next PublisherHandler) PublisherHandler {
|
||||
// PublisherMiddlewareIgnoreError ignores error responses from the gtm endpoint to prevent retries.
|
||||
func PublisherMiddlewareIgnoreError(next PublisherHandler) PublisherHandler {
|
||||
return func(l *zap.Logger, msg *message.Message) error {
|
||||
err := next(l, msg)
|
||||
l.With(zap.Error(err)).Warn("ignoring error")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// PublisherMiddlewareEventParams moves the `debug_mode`, `session_id` & `engagement_time_msec` into the events params
|
||||
// since this is required by the measurement protocol but make coding much more complex. That's why it's part of the payload
|
||||
// in this library.
|
||||
func PublisherMiddlewareEventParams(next PublisherHandler) PublisherHandler {
|
||||
return func(l *zap.Logger, msg *message.Message) error {
|
||||
var payload *mpv2.Payload[any]
|
||||
if err := json.Unmarshal(msg.Payload, &payload); err != nil {
|
||||
return err
|
||||
}
|
||||
if payload.DebugMode {
|
||||
for i, event := range payload.Events {
|
||||
if params, ok := event.Params.(map[string]any); ok {
|
||||
for i, event := range payload.Events {
|
||||
if params, ok := event.Params.(map[string]any); ok {
|
||||
if payload.DebugMode {
|
||||
params["debug_mode"] = "1"
|
||||
payload.DebugMode = false
|
||||
}
|
||||
payload.Events[i] = event
|
||||
if len(payload.SessionID) > 0 {
|
||||
params["session_id"] = payload.SessionID
|
||||
payload.SessionID = ""
|
||||
}
|
||||
if payload.EngagementTimeMSec > 0 {
|
||||
params["engagement_time_msec"] = payload.EngagementTimeMSec
|
||||
payload.EngagementTimeMSec = 0
|
||||
}
|
||||
event.Params = params
|
||||
}
|
||||
payload.Events[i] = event
|
||||
|
||||
out, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
|
||||
106
integration/watermill/mpv2/publishermiddleware_test.go
Normal file
106
integration/watermill/mpv2/publishermiddleware_test.go
Normal file
@ -0,0 +1,106 @@
|
||||
package mpv2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/ThreeDotsLabs/watermill"
|
||||
"github.com/ThreeDotsLabs/watermill/message"
|
||||
"github.com/foomo/sesamy-go/integration/watermill/mpv2"
|
||||
encoding "github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/foomo/sesamy-go/pkg/event"
|
||||
"github.com/foomo/sesamy-go/pkg/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.uber.org/zap/zaptest"
|
||||
)
|
||||
|
||||
func TestPublisherMiddlewareIgnoreError(t *testing.T) {
|
||||
l := zaptest.NewLogger(t)
|
||||
|
||||
var done atomic.Bool
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.Error(w, http.StatusText(http.StatusInternalServerError), http.StatusInternalServerError)
|
||||
done.Store(true)
|
||||
}))
|
||||
|
||||
p := mpv2.NewPublisher(l, s.URL, mpv2.PublisherWithMiddlewares(mpv2.PublisherMiddlewareIgnoreError))
|
||||
|
||||
payload := encoding.Payload[params.PageView]{
|
||||
ClientID: "C123456",
|
||||
UserID: "U123456",
|
||||
TimestampMicros: 1727701064057701,
|
||||
UserProperties: nil,
|
||||
Consent: nil,
|
||||
Events: []sesamy.Event[params.PageView]{
|
||||
event.NewPageView(params.PageView{
|
||||
PageTitle: "Home",
|
||||
PageLocation: "https://foomo.org",
|
||||
}),
|
||||
},
|
||||
UserData: nil,
|
||||
DebugMode: true,
|
||||
SessionID: "S123456",
|
||||
EngagementTimeMSec: 100,
|
||||
}
|
||||
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), jsonPayload)
|
||||
|
||||
require.NoError(t, p.Publish("foo", msg))
|
||||
|
||||
assert.Eventually(t, done.Load, time.Second, 50*time.Millisecond)
|
||||
}
|
||||
|
||||
func TestPublisherMiddlewareEventParams(t *testing.T) {
|
||||
l := zaptest.NewLogger(t)
|
||||
|
||||
var done atomic.Bool
|
||||
s := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
out, err := io.ReadAll(r.Body)
|
||||
assert.NoError(t, err)
|
||||
|
||||
expected := `{"client_id":"C123456","user_id":"U123456","timestamp_micros":1727701064057701,"events":[{"name":"page_view","params":{"debug_mode":"1","engagement_time_msec":100,"page_location":"https://foomo.org","page_title":"Home","session_id":"S123456"}}]}`
|
||||
if !assert.JSONEq(t, expected, string(out)) {
|
||||
fmt.Println(string(out))
|
||||
}
|
||||
done.Store(true)
|
||||
}))
|
||||
|
||||
p := mpv2.NewPublisher(l, s.URL, mpv2.PublisherWithMiddlewares(mpv2.PublisherMiddlewareEventParams))
|
||||
|
||||
payload := encoding.Payload[params.PageView]{
|
||||
ClientID: "C123456",
|
||||
UserID: "U123456",
|
||||
TimestampMicros: 1727701064057701,
|
||||
UserProperties: nil,
|
||||
Consent: nil,
|
||||
Events: []sesamy.Event[params.PageView]{
|
||||
event.NewPageView(params.PageView{
|
||||
PageTitle: "Home",
|
||||
PageLocation: "https://foomo.org",
|
||||
}),
|
||||
},
|
||||
UserData: nil,
|
||||
DebugMode: true,
|
||||
SessionID: "S123456",
|
||||
EngagementTimeMSec: 100,
|
||||
}
|
||||
jsonPayload, err := json.Marshal(payload)
|
||||
require.NoError(t, err)
|
||||
|
||||
msg := message.NewMessage(watermill.NewUUID(), jsonPayload)
|
||||
|
||||
require.NoError(t, p.Publish("foo", msg))
|
||||
|
||||
assert.Eventually(t, done.Load, time.Second, 50*time.Millisecond)
|
||||
}
|
||||
@ -119,6 +119,10 @@ func (s *Subscriber) handle(l *zap.Logger, r *http.Request, payload *mpv2.Payloa
|
||||
l = l.With(zap.String("message_id", msg.UUID))
|
||||
msg.SetContext(context.WithoutCancel(r.Context()))
|
||||
|
||||
// store query
|
||||
msg.Metadata.Set(MetadataRequestQuery, r.URL.RawQuery)
|
||||
|
||||
// store header
|
||||
for name, headers := range r.Header {
|
||||
msg.Metadata.Set(name, strings.Join(headers, ","))
|
||||
}
|
||||
|
||||
@ -7,17 +7,43 @@ import (
|
||||
|
||||
"github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/foomo/sesamy-go/pkg/session"
|
||||
"github.com/pkg/errors"
|
||||
"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 errors.Wrap(err, "failed to parse client cookie")
|
||||
}
|
||||
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 == "" {
|
||||
clientID, err := session.ParseGAClientID(r)
|
||||
value, err := session.ParseGAClientID(r)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
payload.ClientID = clientID
|
||||
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 {
|
||||
payload.DebugMode = session.IsGTMDebug(r)
|
||||
}
|
||||
return next(l, r, payload)
|
||||
}
|
||||
@ -26,26 +52,23 @@ func SubscriberMiddlewareClientID(next SubscriberHandler) SubscriberHandler {
|
||||
func SubscriberMiddlewareUserID(cookieName string) SubscriberMiddleware {
|
||||
return func(next SubscriberHandler) SubscriberHandler {
|
||||
return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
if cookie, err := r.Cookie(cookieName); err == nil {
|
||||
payload.UserID = cookie.Value
|
||||
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 SubscriberMiddlewareDebugMode(next SubscriberHandler) SubscriberHandler {
|
||||
return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
if session.IsGTMDebug(r) {
|
||||
payload.DebugMode = true
|
||||
}
|
||||
return next(l, r, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func SubscriberMiddlewareTimestamp(next SubscriberHandler) SubscriberHandler {
|
||||
return func(l *zap.Logger, r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
payload.TimestampMicros = time.Now().UnixMicro()
|
||||
if payload.TimestampMicros == 0 {
|
||||
payload.TimestampMicros = time.Now().UnixMicro()
|
||||
}
|
||||
return next(l, r, payload)
|
||||
}
|
||||
}
|
||||
|
||||
@ -58,11 +58,11 @@ func GTagMiddlewarClientID(next GTagHandler) GTagHandler {
|
||||
}
|
||||
}
|
||||
|
||||
func GTagMiddlewarSessionID(trackingID string) GTagMiddleware {
|
||||
trackingID = strings.Split(trackingID, "-")[1]
|
||||
func GTagMiddlewarSessionID(measurementID string) GTagMiddleware {
|
||||
measurementID = strings.Split(measurementID, "-")[1]
|
||||
return func(next GTagHandler) GTagHandler {
|
||||
return func(r *http.Request, payload *gtag.Payload) error {
|
||||
value, err := session.ParseGASessionID(r, trackingID)
|
||||
value, err := session.ParseGASessionID(r, measurementID)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
return errors.Wrap(err, "failed to parse session cookie")
|
||||
}
|
||||
|
||||
@ -16,10 +16,16 @@ import (
|
||||
|
||||
type (
|
||||
MPv2 struct {
|
||||
l *zap.Logger
|
||||
path string
|
||||
host string
|
||||
cookies []string
|
||||
l *zap.Logger
|
||||
path string
|
||||
host string
|
||||
cookies []string
|
||||
// To create a new secret, navigate in the Google Analytics UI to:
|
||||
// Admin > Data Streams > choose your stream > Measurement Protocol > Create
|
||||
apiSecret string
|
||||
// Measurement ID. The identifier for a Data Stream. Found in the Google Analytics UI under:
|
||||
// Admin > Data Streams > choose your stream > Measurement ID
|
||||
measurementID string
|
||||
protocolVersion string
|
||||
httpClient *http.Client
|
||||
middlewares []MPv2Middleware
|
||||
@ -51,6 +57,18 @@ func MPv2WithCookies(v ...string) MPv2Option {
|
||||
}
|
||||
}
|
||||
|
||||
func MPv2WithAPISecret(v string) MPv2Option {
|
||||
return func(o *MPv2) {
|
||||
o.apiSecret = v
|
||||
}
|
||||
}
|
||||
|
||||
func MPv2WithMeasurementID(v string) MPv2Option {
|
||||
return func(o *MPv2) {
|
||||
o.measurementID = v
|
||||
}
|
||||
}
|
||||
|
||||
func MPv2WithMiddlewares(v ...MPv2Middleware) MPv2Option {
|
||||
return func(o *MPv2) {
|
||||
o.middlewares = append(o.middlewares, v...)
|
||||
@ -125,6 +143,16 @@ func (c *MPv2) SendRaw(r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
return errors.Wrap(err, "failed to create request")
|
||||
}
|
||||
|
||||
// query
|
||||
qry := req.URL.Query()
|
||||
if len(c.apiSecret) > 0 {
|
||||
qry.Add("api_secret", c.apiSecret)
|
||||
}
|
||||
if len(c.measurementID) > 0 {
|
||||
qry.Add("measurement_id", c.measurementID)
|
||||
}
|
||||
req.URL.RawQuery = qry.Encode()
|
||||
|
||||
// TODO valiate: copy headers
|
||||
req.Header = r.Header.Clone()
|
||||
|
||||
|
||||
@ -2,19 +2,37 @@ package client
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/foomo/sesamy-go/pkg/session"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
func MPv2MiddlewarSessionID(measurementID string) MPv2Middleware {
|
||||
measurementID = strings.Split(measurementID, "-")[1]
|
||||
return func(next MPv2Handler) MPv2Handler {
|
||||
return func(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 errors.Wrap(err, "failed to parse client cookie")
|
||||
}
|
||||
payload.SessionID = value
|
||||
}
|
||||
return next(r, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func MPv2MiddlewarClientID(next MPv2Handler) MPv2Handler {
|
||||
return func(r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
value, err := session.ParseGAClientID(r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
return errors.Wrap(err, "failed to parse client cookie")
|
||||
}
|
||||
if value != "" {
|
||||
if payload.ClientID == "" {
|
||||
value, err := session.ParseGAClientID(r)
|
||||
if err != nil && !errors.Is(err, http.ErrNoCookie) {
|
||||
return errors.Wrap(err, "failed to parse client cookie")
|
||||
}
|
||||
payload.ClientID = value
|
||||
}
|
||||
return next(r, payload)
|
||||
@ -23,8 +41,32 @@ func MPv2MiddlewarClientID(next MPv2Handler) MPv2Handler {
|
||||
|
||||
func MPv2MiddlewarDebugMode(next MPv2Handler) MPv2Handler {
|
||||
return func(r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
if session.IsGTMDebug(r) {
|
||||
payload.DebugMode = true
|
||||
if !payload.DebugMode {
|
||||
payload.DebugMode = session.IsGTMDebug(r)
|
||||
}
|
||||
return next(r, payload)
|
||||
}
|
||||
}
|
||||
|
||||
func MPv2MiddlewareUserID(cookieName string) MPv2Middleware {
|
||||
return func(next MPv2Handler) MPv2Handler {
|
||||
return func(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(r, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
|
||||
@ -1,5 +1,9 @@
|
||||
package gtag
|
||||
|
||||
import (
|
||||
"strings"
|
||||
)
|
||||
|
||||
type Consent struct {
|
||||
// Current Google Consent Status. Format 'G1'+'AdsStorageBoolStatus'`+'AnalyticsStorageBoolStatus'
|
||||
// Example: G101
|
||||
@ -14,3 +18,29 @@ type Consent struct {
|
||||
// Example: G111
|
||||
GoogleConsentDefault *string `json:"google_consent_default,omitempty" gtag:"gcd,omitempty"`
|
||||
}
|
||||
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
// ~ Public methods
|
||||
// ------------------------------------------------------------------------------------------------
|
||||
|
||||
func (c Consent) AdStorage() bool {
|
||||
if c.GoogleConsentUpdate != nil {
|
||||
gcs := *c.GoogleConsentUpdate
|
||||
if strings.HasPrefix(gcs, "G1") && len(gcs) == 4 {
|
||||
return gcs[2:3] == "1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (c Consent) AnalyticsStorage() bool {
|
||||
if c.GoogleConsentUpdate != nil {
|
||||
gcs := *c.GoogleConsentUpdate
|
||||
if strings.HasPrefix(gcs, "G1") && len(gcs) == 4 {
|
||||
return gcs[3:4] == "1"
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
@ -1,6 +1,8 @@
|
||||
package mpv2
|
||||
|
||||
type Consent struct {
|
||||
AdUserData *string `json:"ad_user_data,omitempty"`
|
||||
AdPersonalization *string `json:"ad_personalization,omitempty"`
|
||||
}
|
||||
type Consent string
|
||||
|
||||
const (
|
||||
ConsentDenied Consent = "DENIED"
|
||||
ConsentGranted Consent = "GRANTED"
|
||||
)
|
||||
|
||||
11
pkg/encoding/mpv2/consentdata.go
Normal file
11
pkg/encoding/mpv2/consentdata.go
Normal file
@ -0,0 +1,11 @@
|
||||
package mpv2
|
||||
|
||||
type ConsentData struct {
|
||||
AdStorage *Consent `json:"ad_storage,omitempty"`
|
||||
AdUserData *Consent `json:"ad_user_data,omitempty"`
|
||||
AdPersonalization *Consent `json:"ad_personalization,omitempty"`
|
||||
AnalyticsStorage *Consent `json:"analytics_storage,omitempty"`
|
||||
FunctionalityStorage *Consent `json:"functionality_storage,omitempty"`
|
||||
PersonalizationStorage *Consent `json:"personalization_storage,omitempty"`
|
||||
SecurityStorage *Consent `json:"security_storage,omitempty"`
|
||||
}
|
||||
@ -4,15 +4,16 @@ import (
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
)
|
||||
|
||||
// https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?client_type=gtag#payload_post_body
|
||||
type Payload[P any] struct {
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
TimestampMicros int64 `json:"timestamp_micros,omitempty"`
|
||||
// Reserved user property names: https://developers.google.com/analytics/devguides/collection/protocol/ga4/reference?client_type=gtag#reserved_user_property_names
|
||||
ClientID string `json:"client_id,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
TimestampMicros int64 `json:"timestamp_micros,omitempty"`
|
||||
UserProperties map[string]any `json:"user_properties,omitempty"`
|
||||
Consent *Consent `json:"consent,omitempty"`
|
||||
NonPersonalizedAds bool `json:"non_personalized_ads,omitempty"`
|
||||
Consent *ConsentData `json:"consent,omitempty"`
|
||||
Events []sesamy.Event[P] `json:"events,omitempty"`
|
||||
UserData *UserData `json:"user_data,omitempty"`
|
||||
DebugMode bool `json:"debug_mode,omitempty"`
|
||||
SessionID string `json:"session_id,omitempty"`
|
||||
EngagementTimeMSec int64 `json:"engagement_time_msec,omitempty"`
|
||||
}
|
||||
|
||||
43
pkg/encoding/mpv2/payload_test.go
Normal file
43
pkg/encoding/mpv2/payload_test.go
Normal file
@ -0,0 +1,43 @@
|
||||
package mpv2_test
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"testing"
|
||||
|
||||
"github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/foomo/sesamy-go/pkg/event"
|
||||
"github.com/foomo/sesamy-go/pkg/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestPayload(t *testing.T) {
|
||||
v := mpv2.Payload[params.PageView]{
|
||||
ClientID: "C123456",
|
||||
UserID: "U123456",
|
||||
TimestampMicros: 1727701064057701,
|
||||
UserProperties: nil,
|
||||
Consent: nil,
|
||||
Events: []sesamy.Event[params.PageView]{
|
||||
event.NewPageView(params.PageView{
|
||||
PageTitle: "Home",
|
||||
PageLocation: "https://foomo.org",
|
||||
}),
|
||||
},
|
||||
UserData: nil,
|
||||
DebugMode: true,
|
||||
SessionID: "S123456",
|
||||
EngagementTimeMSec: 100,
|
||||
}
|
||||
|
||||
out, err := json.Marshal(v)
|
||||
require.NoError(t, err)
|
||||
expected := `{"debug_mode":true,"session_id":"S123456","engagement_time_msec":100,"client_id":"C123456","user_id":"U123456","timestamp_micros":1727701064057701,"events":[{"name":"page_view","params":{"page_title":"Home","page_location":"https://foomo.org"}}]}`
|
||||
assert.JSONEq(t, expected, string(out))
|
||||
|
||||
var in mpv2.Payload[params.PageView]
|
||||
err = json.Unmarshal(out, &in)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, v, in)
|
||||
}
|
||||
@ -12,9 +12,8 @@ import (
|
||||
|
||||
func GTag[P any](source mpv2.Payload[P], target any) error {
|
||||
targetData := map[string]any{
|
||||
"client_id": source.ClientID,
|
||||
"user_id": source.UserID,
|
||||
"non_personalized_ads": source.NonPersonalizedAds,
|
||||
"client_id": source.ClientID,
|
||||
"user_id": source.UserID,
|
||||
}
|
||||
|
||||
{ // user_property
|
||||
|
||||
66
pkg/provider/cookiebot/client/mpv2middleware.go
Normal file
66
pkg/provider/cookiebot/client/mpv2middleware.go
Normal file
@ -0,0 +1,66 @@
|
||||
package client
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"github.com/davecgh/go-spew/spew"
|
||||
"github.com/foomo/sesamy-go/pkg/client"
|
||||
"github.com/foomo/sesamy-go/pkg/encoding/mpv2"
|
||||
"github.com/foomo/sesamy-go/pkg/provider/cookiebot"
|
||||
"go.uber.org/zap"
|
||||
"gopkg.in/yaml.v2"
|
||||
)
|
||||
|
||||
func MPv2MiddlewarConsent(l *zap.Logger) client.MPv2Middleware {
|
||||
return func(next client.MPv2Handler) client.MPv2Handler {
|
||||
return func(r *http.Request, payload *mpv2.Payload[any]) error {
|
||||
cookie, err := r.Cookie(cookiebot.CookieName)
|
||||
if errors.Is(err, http.ErrNoCookie) {
|
||||
return next(r, payload)
|
||||
} else if err != nil {
|
||||
l.With(zap.Error(err)).Warn("failed to retrieve cookie bot cookie")
|
||||
return next(r, payload)
|
||||
} else if cookie.Value == "" {
|
||||
l.With(zap.Error(err)).Warn("empty cookie bot cookie")
|
||||
return next(r, payload)
|
||||
}
|
||||
|
||||
data, err := url.QueryUnescape(cookie.Value)
|
||||
if err != nil {
|
||||
l.With(zap.Error(err), zap.String("value", cookie.Value)).Warn("failed to unescape cookie bot cookie")
|
||||
return next(r, payload)
|
||||
}
|
||||
|
||||
var value cookiebot.Cookie
|
||||
if err := yaml.Unmarshal([]byte(strings.ReplaceAll(data, ":", ": ")), &value); err != nil {
|
||||
l.With(zap.Error(err), zap.String("value", data)).Warn("failed to unmarshal cookie bot cookie")
|
||||
return next(r, payload)
|
||||
}
|
||||
spew.Dump(value)
|
||||
|
||||
consent := func(b bool) *mpv2.Consent {
|
||||
ret := mpv2.ConsentDenied
|
||||
if b {
|
||||
ret = mpv2.ConsentGranted
|
||||
}
|
||||
return &ret
|
||||
}
|
||||
|
||||
payload.Consent = &mpv2.ConsentData{
|
||||
AdStorage: consent(value.Marketing),
|
||||
AdUserData: consent(value.Marketing),
|
||||
AdPersonalization: consent(value.Marketing),
|
||||
PersonalizationStorage: consent(value.Marketing),
|
||||
AnalyticsStorage: consent(value.Statistics),
|
||||
FunctionalityStorage: consent(value.Necessary),
|
||||
SecurityStorage: consent(value.Necessary),
|
||||
}
|
||||
spew.Dump(payload)
|
||||
|
||||
return next(r, payload)
|
||||
}
|
||||
}
|
||||
}
|
||||
16
pkg/provider/cookiebot/cookie.go
Normal file
16
pkg/provider/cookiebot/cookie.go
Normal file
@ -0,0 +1,16 @@
|
||||
package cookiebot
|
||||
|
||||
const CookieName = "CookieConsent"
|
||||
|
||||
// {stamp:'VLZnHUKBPLqZCJyClLLmnGglmUPeZsGxrmiAEZ48i7UH39ptKHY4MA==',necessary:true,preferences:true,statistics:true,marketing:true,method:'explicit',ver:1,utc:1724770548958,region:'de'}
|
||||
type Cookie struct {
|
||||
Stamp string `json:"stamp" yaml:"stamp"`
|
||||
Necessary bool `json:"necessary" yaml:"necessary"`
|
||||
Preferences bool `json:"preferences" yaml:"preferences"`
|
||||
Statistics bool `json:"statistics" yaml:"statistics"`
|
||||
Marketing bool `json:"marketing" yaml:"marketing"`
|
||||
Method string `json:"method" yaml:"method"`
|
||||
Version string `json:"ver" yaml:"ver"`
|
||||
UTC int `json:"utc" yaml:"utc"`
|
||||
Region string `json:"region" yaml:"region"`
|
||||
}
|
||||
11
pkg/provider/tracify/event/params/tracifyaddtocart.go
Normal file
11
pkg/provider/tracify/event/params/tracifyaddtocart.go
Normal file
@ -0,0 +1,11 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"github.com/foomo/gostandards/iso4217"
|
||||
)
|
||||
|
||||
type TracifyAddToCart[I any] struct {
|
||||
Currency iso4217.Currency `json:"currency,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
Items []I `json:"items,omitempty"`
|
||||
}
|
||||
11
pkg/provider/tracify/event/params/tracifyconversion.go
Normal file
11
pkg/provider/tracify/event/params/tracifyconversion.go
Normal file
@ -0,0 +1,11 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"github.com/foomo/gostandards/iso4217"
|
||||
)
|
||||
|
||||
type TracifyConversion struct {
|
||||
Currency iso4217.Currency `json:"currency,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
ConversionID string `json:"conversion_id,omitempty"`
|
||||
}
|
||||
5
pkg/provider/tracify/event/params/tracifypagesview.go
Normal file
5
pkg/provider/tracify/event/params/tracifypagesview.go
Normal file
@ -0,0 +1,5 @@
|
||||
package params
|
||||
|
||||
type TracifyPageView struct {
|
||||
PageLocation string `json:"page_location,omitempty"`
|
||||
}
|
||||
5
pkg/provider/tracify/event/params/tracifyproductview.go
Normal file
5
pkg/provider/tracify/event/params/tracifyproductview.go
Normal file
@ -0,0 +1,5 @@
|
||||
package params
|
||||
|
||||
type TracifyProductView struct {
|
||||
PageLocation string `json:"page_location,omitempty"`
|
||||
}
|
||||
12
pkg/provider/tracify/event/params/tracifypurchase.go
Normal file
12
pkg/provider/tracify/event/params/tracifypurchase.go
Normal file
@ -0,0 +1,12 @@
|
||||
package params
|
||||
|
||||
import (
|
||||
"github.com/foomo/gostandards/iso4217"
|
||||
)
|
||||
|
||||
type TracifyPurchase[I any] struct {
|
||||
Currency iso4217.Currency `json:"currency,omitempty"`
|
||||
Value float64 `json:"value,omitempty"`
|
||||
TransactionID string `json:"transaction_id,omitempty"`
|
||||
Items []I `json:"items,omitempty"`
|
||||
}
|
||||
15
pkg/provider/tracify/event/tracifyaddtocart.go
Normal file
15
pkg/provider/tracify/event/tracifyaddtocart.go
Normal file
@ -0,0 +1,15 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
sesamyparams "github.com/foomo/sesamy-go/pkg/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/provider/tracify/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
)
|
||||
|
||||
const EventNameTracifyAddToCart sesamy.EventName = "tracify_add_to_cart"
|
||||
|
||||
type TracifyAddToCart sesamy.Event[params.TracifyAddToCart[sesamyparams.Item]]
|
||||
|
||||
func NewTracifyAddToCart(p params.TracifyAddToCart[sesamyparams.Item]) sesamy.Event[params.TracifyAddToCart[sesamyparams.Item]] {
|
||||
return sesamy.NewEvent(EventNameTracifyAddToCart, p)
|
||||
}
|
||||
14
pkg/provider/tracify/event/tracifyconversion.go
Normal file
14
pkg/provider/tracify/event/tracifyconversion.go
Normal file
@ -0,0 +1,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/foomo/sesamy-go/pkg/provider/tracify/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
)
|
||||
|
||||
const EventNameTracifyConversion sesamy.EventName = "tracify_conversion"
|
||||
|
||||
type TracifyConversion sesamy.Event[params.TracifyConversion]
|
||||
|
||||
func NewTracifyConversion(p params.TracifyConversion) sesamy.Event[params.TracifyConversion] {
|
||||
return sesamy.NewEvent(EventNameTracifyConversion, p)
|
||||
}
|
||||
14
pkg/provider/tracify/event/tracifypageview.go
Normal file
14
pkg/provider/tracify/event/tracifypageview.go
Normal file
@ -0,0 +1,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/foomo/sesamy-go/pkg/provider/tracify/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
)
|
||||
|
||||
const EventNameTracifyPageView sesamy.EventName = "tracify_page_view"
|
||||
|
||||
type TracifyPageView sesamy.Event[params.TracifyPageView]
|
||||
|
||||
func NewTracifyPageView(p params.TracifyPageView) sesamy.Event[params.TracifyPageView] {
|
||||
return sesamy.NewEvent(EventNameTracifyPageView, p)
|
||||
}
|
||||
14
pkg/provider/tracify/event/tracifyproductview.go
Normal file
14
pkg/provider/tracify/event/tracifyproductview.go
Normal file
@ -0,0 +1,14 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"github.com/foomo/sesamy-go/pkg/provider/tracify/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
)
|
||||
|
||||
const EventNameTracifyProductView sesamy.EventName = "tracify_product_view"
|
||||
|
||||
type TracifyProductView sesamy.Event[params.TracifyProductView]
|
||||
|
||||
func NewTracifyProductView(p params.TracifyProductView) sesamy.Event[params.TracifyProductView] {
|
||||
return sesamy.NewEvent(EventNameTracifyProductView, p)
|
||||
}
|
||||
15
pkg/provider/tracify/event/tracifypurchase.go
Normal file
15
pkg/provider/tracify/event/tracifypurchase.go
Normal file
@ -0,0 +1,15 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
sesamyparams "github.com/foomo/sesamy-go/pkg/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/provider/tracify/event/params"
|
||||
"github.com/foomo/sesamy-go/pkg/sesamy"
|
||||
)
|
||||
|
||||
const EventNameTracifyPurchase sesamy.EventName = "tracify_purchase"
|
||||
|
||||
type TracifyPurchase sesamy.Event[params.TracifyPurchase[sesamyparams.Item]]
|
||||
|
||||
func NewTracifyPurchase(p params.TracifyPurchase[sesamyparams.Item]) sesamy.Event[params.TracifyPurchase[sesamyparams.Item]] {
|
||||
return sesamy.NewEvent(EventNameTracifyPurchase, p)
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user