Add duration-aware tracker pattern merging
Trackers sharing a prefix but with materially different lifetimes (e.g. session vs 1-year) were incorrectly merged into a single prefix pattern. Port the snap table from cookie-utils.ts into Go and use it to bucket durations so only trackers that display the same human-readable lifetime can merge. Update the unique index to include COALESCE(max_age_seconds, -1) so prefix patterns with different durations can coexist. Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
@@ -31,6 +31,53 @@ import (
|
|||||||
|
|
||||||
const patternMergeThreshold = 3
|
const patternMergeThreshold = 3
|
||||||
|
|
||||||
|
// durationUnits mirrors the snap table from cookie-utils.ts. The same
|
||||||
|
// tracker observed across different clients can have jitter in its
|
||||||
|
// max-age (e.g. an "Expires" header computed from Date.now() yields
|
||||||
|
// slightly different seconds each time). Snapping to the nearest
|
||||||
|
// human-meaningful unit absorbs that jitter so the patterns still
|
||||||
|
// merge. This is compliant because the resulting bucket matches the
|
||||||
|
// duration shown to end users in the cookie banner — two cookies that
|
||||||
|
// display the same human-readable lifetime will merge, two that
|
||||||
|
// display differently will not.
|
||||||
|
var durationUnits = [...]struct {
|
||||||
|
seconds int
|
||||||
|
snap int
|
||||||
|
}{
|
||||||
|
{365 * 24 * 3600, 21 * 24 * 3600}, // years, snap +-21 days
|
||||||
|
{30 * 24 * 3600, 2 * 24 * 3600}, // months, snap +-2 days
|
||||||
|
{7 * 24 * 3600, 12 * 3600}, // weeks, snap +-12 hours
|
||||||
|
{24 * 3600, 2 * 3600}, // days, snap +-2 hours
|
||||||
|
{3600, 5 * 60}, // hours, snap +-5 minutes
|
||||||
|
{60, 5}, // minutes, snap +-5 seconds
|
||||||
|
{1, 0}, // seconds, no snap
|
||||||
|
}
|
||||||
|
|
||||||
|
func durationBucket(maxAge *int) int {
|
||||||
|
if maxAge == nil || *maxAge <= 0 {
|
||||||
|
return -1
|
||||||
|
}
|
||||||
|
|
||||||
|
remaining := *maxAge
|
||||||
|
total := 0
|
||||||
|
for _, u := range durationUnits {
|
||||||
|
if remaining >= u.seconds-u.snap {
|
||||||
|
count := remaining / u.seconds
|
||||||
|
leftover := remaining - count*u.seconds
|
||||||
|
if leftover >= u.seconds-u.snap {
|
||||||
|
count++
|
||||||
|
remaining = 0
|
||||||
|
} else if leftover <= u.snap {
|
||||||
|
remaining = 0
|
||||||
|
} else {
|
||||||
|
remaining = leftover
|
||||||
|
}
|
||||||
|
total += count * u.seconds
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return total
|
||||||
|
}
|
||||||
|
|
||||||
type patternAnalysisHandler struct {
|
type patternAnalysisHandler struct {
|
||||||
svc *Service
|
svc *Service
|
||||||
pg *pg.Client
|
pg *pg.Client
|
||||||
@@ -110,8 +157,12 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
|
|||||||
|
|
||||||
consentChanged := false
|
consentChanged := false
|
||||||
for key, group := range mergeGroups {
|
for key, group := range mergeGroups {
|
||||||
|
var maxAge *int
|
||||||
|
if key.durationBucket >= 0 {
|
||||||
|
v := key.durationBucket
|
||||||
|
maxAge = &v
|
||||||
|
}
|
||||||
|
|
||||||
maxAge := mostCommonMaxAge(group)
|
|
||||||
source := bestSource(group)
|
source := bestSource(group)
|
||||||
|
|
||||||
prefixPattern := &coredata.TrackerPattern{
|
prefixPattern := &coredata.TrackerPattern{
|
||||||
@@ -135,7 +186,7 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
|
|||||||
return fmt.Errorf("cannot insert prefix pattern %q: %w", key.prefix, err)
|
return fmt.Errorf("cannot insert prefix pattern %q: %w", key.prefix, err)
|
||||||
}
|
}
|
||||||
if !inserted {
|
if !inserted {
|
||||||
if err := prefixPattern.LoadByBannerIDTypeAndPattern(ctx, tx, scope, banner.ID, key.trackerType, key.prefix); err != nil {
|
if err := prefixPattern.LoadByBannerIDTypeAndPattern(ctx, tx, scope, banner.ID, key.trackerType, key.prefix, maxAge); err != nil {
|
||||||
return fmt.Errorf("cannot load existing prefix pattern %q: %w", key.prefix, err)
|
return fmt.Errorf("cannot load existing prefix pattern %q: %w", key.prefix, err)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -192,6 +243,7 @@ type mergeGroupKey struct {
|
|||||||
categoryID gid.GID
|
categoryID gid.GID
|
||||||
trackerType coredata.TrackerType
|
trackerType coredata.TrackerType
|
||||||
prefix string
|
prefix string
|
||||||
|
durationBucket int
|
||||||
}
|
}
|
||||||
|
|
||||||
func findMergeGroups(
|
func findMergeGroups(
|
||||||
@@ -200,8 +252,9 @@ func findMergeGroups(
|
|||||||
) map[mergeGroupKey][]*coredata.TrackerPattern {
|
) map[mergeGroupKey][]*coredata.TrackerPattern {
|
||||||
prefixCounts := make(map[mergeGroupKey][]*coredata.TrackerPattern)
|
prefixCounts := make(map[mergeGroupKey][]*coredata.TrackerPattern)
|
||||||
for _, p := range patterns {
|
for _, p := range patterns {
|
||||||
|
bucket := durationBucket(p.MaxAgeSeconds)
|
||||||
for _, pfx := range separatorPrefixes(p.Pattern) {
|
for _, pfx := range separatorPrefixes(p.Pattern) {
|
||||||
key := mergeGroupKey{categoryID: p.CookieCategoryID, trackerType: p.TrackerType, prefix: pfx}
|
key := mergeGroupKey{categoryID: p.CookieCategoryID, trackerType: p.TrackerType, prefix: pfx, durationBucket: bucket}
|
||||||
prefixCounts[key] = append(prefixCounts[key], p)
|
prefixCounts[key] = append(prefixCounts[key], p)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -266,39 +319,6 @@ func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource {
|
|||||||
return &src
|
return &src
|
||||||
}
|
}
|
||||||
|
|
||||||
func mostCommonMaxAge(patterns []*coredata.TrackerPattern) *int {
|
|
||||||
type key struct {
|
|
||||||
valid bool
|
|
||||||
val int
|
|
||||||
}
|
|
||||||
counts := make(map[key]int)
|
|
||||||
for _, p := range patterns {
|
|
||||||
k := key{}
|
|
||||||
if p.MaxAgeSeconds != nil {
|
|
||||||
k = key{valid: true, val: *p.MaxAgeSeconds}
|
|
||||||
}
|
|
||||||
counts[k]++
|
|
||||||
}
|
|
||||||
|
|
||||||
type entry struct {
|
|
||||||
k key
|
|
||||||
count int
|
|
||||||
}
|
|
||||||
entries := make([]entry, 0, len(counts))
|
|
||||||
for k, c := range counts {
|
|
||||||
entries = append(entries, entry{k, c})
|
|
||||||
}
|
|
||||||
sort.Slice(entries, func(i, j int) bool {
|
|
||||||
return entries[i].count > entries[j].count
|
|
||||||
})
|
|
||||||
|
|
||||||
if !entries[0].k.valid {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
v := entries[0].k.val
|
|
||||||
return &v
|
|
||||||
}
|
|
||||||
|
|
||||||
func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
|
func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
|
||||||
ctx context.Context,
|
ctx context.Context,
|
||||||
tx pg.Tx,
|
tx pg.Tx,
|
||||||
@@ -349,8 +369,9 @@ func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
|
|||||||
adopted := false
|
adopted := false
|
||||||
for _, ep := range uncategorisedExact {
|
for _, ep := range uncategorisedExact {
|
||||||
var match *coredata.TrackerPattern
|
var match *coredata.TrackerPattern
|
||||||
|
epBucket := durationBucket(ep.MaxAgeSeconds)
|
||||||
for _, pp := range prefixPatterns {
|
for _, pp := range prefixPatterns {
|
||||||
if ep.TrackerType == pp.TrackerType && strings.HasPrefix(ep.Pattern, pp.Pattern) {
|
if ep.TrackerType == pp.TrackerType && strings.HasPrefix(ep.Pattern, pp.Pattern) && durationBucket(pp.MaxAgeSeconds) == epBucket {
|
||||||
match = pp
|
match = pp
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -88,11 +88,14 @@ func TestSeparatorPrefixes(t *testing.T) {
|
|||||||
func TestFindMergeGroups(t *testing.T) {
|
func TestFindMergeGroups(t *testing.T) {
|
||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
makePattern := func(name string) *coredata.TrackerPattern {
|
oneYear := 365 * 24 * 3600
|
||||||
|
|
||||||
|
makePattern := func(name string, maxAge *int) *coredata.TrackerPattern {
|
||||||
return &coredata.TrackerPattern{
|
return &coredata.TrackerPattern{
|
||||||
Pattern: name,
|
Pattern: name,
|
||||||
TrackerType: coredata.TrackerTypeCookie,
|
TrackerType: coredata.TrackerTypeCookie,
|
||||||
MatchType: coredata.TrackerPatternMatchTypeExact,
|
MatchType: coredata.TrackerPatternMatchTypeExact,
|
||||||
|
MaxAgeSeconds: maxAge,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -102,15 +105,15 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("ph_phc_abc123"),
|
makePattern("ph_phc_abc123", &oneYear),
|
||||||
makePattern("ph_phc_def456"),
|
makePattern("ph_phc_def456", &oneYear),
|
||||||
makePattern("ph_phc_ghi789"),
|
makePattern("ph_phc_ghi789", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
require.Len(t, groups, 1)
|
require.Len(t, groups, 1)
|
||||||
|
|
||||||
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_phc_"}]
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_phc_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, group, 3)
|
assert.Len(t, group, 3)
|
||||||
},
|
},
|
||||||
@@ -122,15 +125,15 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("_ga_ABC123"),
|
makePattern("_ga_ABC123", &oneYear),
|
||||||
makePattern("_ga_DEF456"),
|
makePattern("_ga_DEF456", &oneYear),
|
||||||
makePattern("_ga_GHI789"),
|
makePattern("_ga_GHI789", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
require.Len(t, groups, 1)
|
require.Len(t, groups, 1)
|
||||||
|
|
||||||
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "_ga_"}]
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "_ga_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, group, 3)
|
assert.Len(t, group, 3)
|
||||||
},
|
},
|
||||||
@@ -142,15 +145,15 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("auth0_session_abc123"),
|
makePattern("auth0_session_abc123", &oneYear),
|
||||||
makePattern("auth0_session_def456"),
|
makePattern("auth0_session_def456", &oneYear),
|
||||||
makePattern("auth0_session_ghi789"),
|
makePattern("auth0_session_ghi789", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
require.Len(t, groups, 1)
|
require.Len(t, groups, 1)
|
||||||
|
|
||||||
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "auth0_session_"}]
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "auth0_session_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, group, 3)
|
assert.Len(t, group, 3)
|
||||||
},
|
},
|
||||||
@@ -162,8 +165,8 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("deadbeef_setting"),
|
makePattern("deadbeef_setting", &oneYear),
|
||||||
makePattern("something_else"),
|
makePattern("something_else", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
@@ -177,22 +180,22 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("foo_bar_aaa"),
|
makePattern("foo_bar_aaa", &oneYear),
|
||||||
makePattern("foo_bar_bbb"),
|
makePattern("foo_bar_bbb", &oneYear),
|
||||||
makePattern("foo_bar_ccc"),
|
makePattern("foo_bar_ccc", &oneYear),
|
||||||
makePattern("foo_baz_xxx"),
|
makePattern("foo_baz_xxx", &oneYear),
|
||||||
makePattern("foo_baz_yyy"),
|
makePattern("foo_baz_yyy", &oneYear),
|
||||||
makePattern("foo_baz_zzz"),
|
makePattern("foo_baz_zzz", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
require.Len(t, groups, 2)
|
require.Len(t, groups, 2)
|
||||||
|
|
||||||
barGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "foo_bar_"}]
|
barGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "foo_bar_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, barGroup, 3)
|
assert.Len(t, barGroup, 3)
|
||||||
|
|
||||||
bazGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "foo_baz_"}]
|
bazGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "foo_baz_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, bazGroup, 3)
|
assert.Len(t, bazGroup, 3)
|
||||||
},
|
},
|
||||||
@@ -204,16 +207,16 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("ph_phc_abc123"),
|
makePattern("ph_phc_abc123", &oneYear),
|
||||||
makePattern("ph_phc_def456"),
|
makePattern("ph_phc_def456", &oneYear),
|
||||||
makePattern("ph_phc_ghi789"),
|
makePattern("ph_phc_ghi789", &oneYear),
|
||||||
makePattern("ph_session_xyz"),
|
makePattern("ph_session_xyz", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
require.Len(t, groups, 1)
|
require.Len(t, groups, 1)
|
||||||
|
|
||||||
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_phc_"}]
|
group, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_phc_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, group, 3)
|
assert.Len(t, group, 3)
|
||||||
},
|
},
|
||||||
@@ -225,22 +228,22 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("ph_phc_abc123"),
|
makePattern("ph_phc_abc123", &oneYear),
|
||||||
makePattern("ph_phc_def456"),
|
makePattern("ph_phc_def456", &oneYear),
|
||||||
makePattern("ph_phc_ghi789"),
|
makePattern("ph_phc_ghi789", &oneYear),
|
||||||
makePattern("ph_session_aaa"),
|
makePattern("ph_session_aaa", &oneYear),
|
||||||
makePattern("ph_session_bbb"),
|
makePattern("ph_session_bbb", &oneYear),
|
||||||
makePattern("ph_session_ccc"),
|
makePattern("ph_session_ccc", &oneYear),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
require.Len(t, groups, 2)
|
require.Len(t, groups, 2)
|
||||||
|
|
||||||
phcGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_phc_"}]
|
phcGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_phc_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, phcGroup, 3)
|
assert.Len(t, phcGroup, 3)
|
||||||
|
|
||||||
sessionGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_session_"}]
|
sessionGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "ph_session_", durationBucket: durationBucket(&oneYear)}]
|
||||||
require.True(t, ok)
|
require.True(t, ok)
|
||||||
assert.Len(t, sessionGroup, 3)
|
assert.Len(t, sessionGroup, 3)
|
||||||
},
|
},
|
||||||
@@ -252,13 +255,171 @@ func TestFindMergeGroups(t *testing.T) {
|
|||||||
t.Parallel()
|
t.Parallel()
|
||||||
|
|
||||||
patterns := coredata.TrackerPatterns{
|
patterns := coredata.TrackerPatterns{
|
||||||
makePattern("PHPSESSID"),
|
makePattern("PHPSESSID", nil),
|
||||||
makePattern("JSESSIONID"),
|
makePattern("JSESSIONID", nil),
|
||||||
makePattern("ASPSESSIONID"),
|
makePattern("ASPSESSIONID", nil),
|
||||||
}
|
}
|
||||||
|
|
||||||
groups := findMergeGroups(patterns, 3)
|
groups := findMergeGroups(patterns, 3)
|
||||||
assert.Empty(t, groups)
|
assert.Empty(t, groups)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"session and persistent cookies do not merge",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
patterns := coredata.TrackerPatterns{
|
||||||
|
makePattern("_ga_ABC123", nil),
|
||||||
|
makePattern("_ga_DEF456", nil),
|
||||||
|
makePattern("_ga_GHI789", nil),
|
||||||
|
makePattern("_ga_JKL012", &oneYear),
|
||||||
|
makePattern("_ga_MNO345", &oneYear),
|
||||||
|
makePattern("_ga_PQR678", &oneYear),
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := findMergeGroups(patterns, 3)
|
||||||
|
require.Len(t, groups, 2)
|
||||||
|
|
||||||
|
sessionGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "_ga_", durationBucket: -1}]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, sessionGroup, 3)
|
||||||
|
|
||||||
|
persistentGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "_ga_", durationBucket: durationBucket(&oneYear)}]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, persistentGroup, 3)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"close durations snap to same bucket and merge",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
exactYear := 365 * 24 * 3600
|
||||||
|
almostYear := 364 * 24 * 3600
|
||||||
|
|
||||||
|
patterns := coredata.TrackerPatterns{
|
||||||
|
makePattern("_ga_ABC123", &exactYear),
|
||||||
|
makePattern("_ga_DEF456", &almostYear),
|
||||||
|
makePattern("_ga_GHI789", &exactYear),
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := findMergeGroups(patterns, 3)
|
||||||
|
require.Len(t, groups, 1)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
t.Run(
|
||||||
|
"different durations do not merge",
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
oneDay := 24 * 3600
|
||||||
|
thirtyDays := 30 * 24 * 3600
|
||||||
|
|
||||||
|
patterns := coredata.TrackerPatterns{
|
||||||
|
makePattern("_ga_ABC123", &oneDay),
|
||||||
|
makePattern("_ga_DEF456", &oneDay),
|
||||||
|
makePattern("_ga_GHI789", &oneDay),
|
||||||
|
makePattern("_ga_JKL012", &thirtyDays),
|
||||||
|
makePattern("_ga_MNO345", &thirtyDays),
|
||||||
|
makePattern("_ga_PQR678", &thirtyDays),
|
||||||
|
}
|
||||||
|
|
||||||
|
groups := findMergeGroups(patterns, 3)
|
||||||
|
require.Len(t, groups, 2)
|
||||||
|
|
||||||
|
dayGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "_ga_", durationBucket: durationBucket(&oneDay)}]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, dayGroup, 3)
|
||||||
|
|
||||||
|
monthGroup, ok := groups[mergeGroupKey{categoryID: gid.Nil, trackerType: coredata.TrackerTypeCookie, prefix: "_ga_", durationBucket: durationBucket(&thirtyDays)}]
|
||||||
|
require.True(t, ok)
|
||||||
|
assert.Len(t, monthGroup, 3)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDurationBucket(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
maxAge *int
|
||||||
|
expected int
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "nil is session",
|
||||||
|
maxAge: nil,
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "zero is session",
|
||||||
|
maxAge: new(0),
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "negative is session",
|
||||||
|
maxAge: new(-1),
|
||||||
|
expected: -1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact 1 year",
|
||||||
|
maxAge: new(365 * 24 * 3600),
|
||||||
|
expected: 365 * 24 * 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "364 days snaps to 1 year",
|
||||||
|
maxAge: new(364 * 24 * 3600),
|
||||||
|
expected: 365 * 24 * 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact 30 days",
|
||||||
|
maxAge: new(30 * 24 * 3600),
|
||||||
|
expected: 30 * 24 * 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact 1 day",
|
||||||
|
maxAge: new(24 * 3600),
|
||||||
|
expected: 24 * 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "23h snaps to 1 day",
|
||||||
|
maxAge: new(23 * 3600),
|
||||||
|
expected: 24 * 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact 1 hour",
|
||||||
|
maxAge: new(3600),
|
||||||
|
expected: 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "58 minutes snaps to 1 hour",
|
||||||
|
maxAge: new(58 * 60),
|
||||||
|
expected: 3600,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "exact 5 minutes",
|
||||||
|
maxAge: new(5 * 60),
|
||||||
|
expected: 5 * 60,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "1 day and 30 days are different buckets",
|
||||||
|
maxAge: new(24 * 3600),
|
||||||
|
expected: 24 * 3600,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(
|
||||||
|
tt.name,
|
||||||
|
func(t *testing.T) {
|
||||||
|
t.Parallel()
|
||||||
|
result := durationBucket(tt.maxAge)
|
||||||
|
assert.Equal(t, tt.expected, result)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
18
pkg/coredata/migrations/20260506T123706Z.sql
Normal file
18
pkg/coredata/migrations/20260506T123706Z.sql
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
-- Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
|
||||||
|
--
|
||||||
|
-- Permission to use, copy, modify, and/or distribute this software for any
|
||||||
|
-- purpose with or without fee is hereby granted, provided that the above
|
||||||
|
-- copyright notice and this permission notice appear in all copies.
|
||||||
|
--
|
||||||
|
-- THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
|
||||||
|
-- REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
|
||||||
|
-- AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
|
||||||
|
-- INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
|
||||||
|
-- LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
|
||||||
|
-- OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
|
||||||
|
-- PERFORMANCE OF THIS SOFTWARE.
|
||||||
|
|
||||||
|
DROP INDEX idx_tracker_patterns_unique_pattern_per_banner;
|
||||||
|
|
||||||
|
CREATE UNIQUE INDEX idx_tracker_patterns_unique_pattern_per_banner
|
||||||
|
ON tracker_patterns (cookie_banner_id, tracker_type, pattern, COALESCE(max_age_seconds, -1));
|
||||||
@@ -149,6 +149,7 @@ func (tp *TrackerPattern) LoadByBannerIDTypeAndPattern(
|
|||||||
cookieBannerID gid.GID,
|
cookieBannerID gid.GID,
|
||||||
trackerType TrackerType,
|
trackerType TrackerType,
|
||||||
pattern string,
|
pattern string,
|
||||||
|
maxAgeSeconds *int,
|
||||||
) error {
|
) error {
|
||||||
q := `
|
q := `
|
||||||
SELECT
|
SELECT
|
||||||
@@ -174,6 +175,7 @@ WHERE
|
|||||||
AND cookie_banner_id = @cookie_banner_id
|
AND cookie_banner_id = @cookie_banner_id
|
||||||
AND tracker_type = @tracker_type
|
AND tracker_type = @tracker_type
|
||||||
AND pattern = @pattern
|
AND pattern = @pattern
|
||||||
|
AND COALESCE(max_age_seconds, -1) = COALESCE(@max_age_seconds, -1)
|
||||||
LIMIT 1;
|
LIMIT 1;
|
||||||
`
|
`
|
||||||
|
|
||||||
@@ -183,6 +185,7 @@ LIMIT 1;
|
|||||||
"cookie_banner_id": cookieBannerID,
|
"cookie_banner_id": cookieBannerID,
|
||||||
"tracker_type": trackerType,
|
"tracker_type": trackerType,
|
||||||
"pattern": pattern,
|
"pattern": pattern,
|
||||||
|
"max_age_seconds": maxAgeSeconds,
|
||||||
}
|
}
|
||||||
maps.Copy(args, scope.SQLArguments())
|
maps.Copy(args, scope.SQLArguments())
|
||||||
|
|
||||||
@@ -393,7 +396,7 @@ INSERT INTO tracker_patterns (
|
|||||||
@created_at,
|
@created_at,
|
||||||
@updated_at
|
@updated_at
|
||||||
)
|
)
|
||||||
ON CONFLICT (cookie_banner_id, tracker_type, pattern) DO NOTHING
|
ON CONFLICT (cookie_banner_id, tracker_type, pattern, COALESCE(max_age_seconds, -1)) DO NOTHING
|
||||||
`
|
`
|
||||||
|
|
||||||
args := pgx.StrictNamedArgs{
|
args := pgx.StrictNamedArgs{
|
||||||
|
|||||||
Reference in New Issue
Block a user