Merge pull request #23 from foomo/tracify

feat: tracify & cookiebot
This commit is contained in:
Kevin Franklin Kim 2024-10-01 12:55:13 +02:00 committed by GitHub
commit dae9b3ee29
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
40 changed files with 1042 additions and 118 deletions

View File

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

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

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

View File

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

View File

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

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

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

View File

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

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

View File

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

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

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

View File

@ -0,0 +1,5 @@
package mpv2
const (
MetadataRequestQuery = "RequestQuery"
)

View File

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

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

View File

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

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

View File

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

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

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

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

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

View File

@ -0,0 +1,5 @@
package params
type TracifyPageView struct {
PageLocation string `json:"page_location,omitempty"`
}

View File

@ -0,0 +1,5 @@
package params
type TracifyProductView struct {
PageLocation string `json:"page_location,omitempty"`
}

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

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

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

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

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

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