Implement glob-based pattern discovery and matching in worker

Replace prefix-only merge logic with token-template analysis that
discovers sandwich patterns (e.g. ph_phc_*_posthog). The worker now
emits GLOB patterns, adoption uses globMatch, and validation enforces
exactly one wildcard for GLOB patterns.

Signed-off-by: Émile Ré <emile@getprobo.com>
This commit is contained in:
Émile Ré
2026-05-06 20:34:47 +04:00
parent 70d86d68af
commit d006d4e462
2 changed files with 92 additions and 38 deletions

View File

@@ -346,6 +346,26 @@ func (r *CreateTrackerPatternRequest) Validate() error {
return s return s
}(), }(),
)) ))
v.Check(r.Pattern, "pattern", func(value any) *validator.ValidationError {
s, _ := value.(string)
switch r.MatchType {
case coredata.TrackerPatternMatchTypeGlob:
if strings.Count(s, "*") != 1 {
return &validator.ValidationError{
Code: validator.ErrorCodeInvalidFormat,
Message: "glob pattern must contain exactly one *",
}
}
case coredata.TrackerPatternMatchTypeExact:
if strings.Contains(s, "*") {
return &validator.ValidationError{
Code: validator.ErrorCodeInvalidFormat,
Message: "exact pattern must not contain *",
}
}
}
return nil
})
v.Check(r.DisplayName, "display_name", validator.Required(), validator.SafeTextNoNewLine(255)) v.Check(r.DisplayName, "display_name", validator.Required(), validator.SafeTextNoNewLine(255))
v.Check(r.Description, "description", validator.SafeText(1000)) v.Check(r.Description, "description", validator.SafeText(1000))

View File

@@ -165,15 +165,15 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
source := bestSource(group) source := bestSource(group)
prefixPattern := &coredata.TrackerPattern{ globPattern := &coredata.TrackerPattern{
ID: gid.New(banner.ID.TenantID(), coredata.TrackerPatternEntityType), ID: gid.New(banner.ID.TenantID(), coredata.TrackerPatternEntityType),
OrganizationID: group[0].OrganizationID, OrganizationID: group[0].OrganizationID,
CookieBannerID: banner.ID, CookieBannerID: banner.ID,
CookieCategoryID: key.categoryID, CookieCategoryID: key.categoryID,
TrackerType: key.trackerType, TrackerType: key.trackerType,
Pattern: key.prefix, Pattern: key.template,
MatchType: coredata.TrackerPatternMatchTypePrefix, MatchType: coredata.TrackerPatternMatchTypeGlob,
DisplayName: key.prefix + "*", DisplayName: key.template,
MaxAgeSeconds: maxAge, MaxAgeSeconds: maxAge,
Description: "", Description: "",
Source: source, Source: source,
@@ -181,23 +181,23 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
UpdatedAt: time.Now(), UpdatedAt: time.Now(),
} }
inserted, err := prefixPattern.InsertIfNotExists(ctx, tx, scope) inserted, err := globPattern.InsertIfNotExists(ctx, tx, scope)
if err != nil { if err != nil {
return fmt.Errorf("cannot insert prefix pattern %q: %w", key.prefix, err) return fmt.Errorf("cannot insert glob pattern %q: %w", key.template, err)
} }
if !inserted { if !inserted {
if err := prefixPattern.LoadByBannerIDTypeAndPattern(ctx, tx, scope, banner.ID, key.trackerType, key.prefix, maxAge); err != nil { if err := globPattern.LoadByBannerIDTypeAndPattern(ctx, tx, scope, banner.ID, key.trackerType, key.template, maxAge); err != nil {
return fmt.Errorf("cannot load existing prefix pattern %q: %w", key.prefix, err) return fmt.Errorf("cannot load existing glob pattern %q: %w", key.template, err)
} }
if prefixPattern.CookieCategoryID != key.categoryID || prefixPattern.MatchType != coredata.TrackerPatternMatchTypePrefix { if globPattern.CookieCategoryID != key.categoryID || globPattern.MatchType != coredata.TrackerPatternMatchTypeGlob {
continue continue
} }
} }
for _, exactPattern := range group { for _, exactPattern := range group {
var trackers coredata.DetectedTrackers var trackers coredata.DetectedTrackers
if err := trackers.RelinkByTrackerPatternID(ctx, tx, scope, exactPattern.ID, prefixPattern.ID); err != nil { if err := trackers.RelinkByTrackerPatternID(ctx, tx, scope, exactPattern.ID, globPattern.ID); err != nil {
return fmt.Errorf("cannot relink detected trackers from pattern %q: %w", exactPattern.Pattern, err) return fmt.Errorf("cannot relink detected trackers from pattern %q: %w", exactPattern.Pattern, err)
} }
@@ -212,8 +212,8 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
h.logger.InfoCtx( h.logger.InfoCtx(
ctx, ctx,
"merged exact patterns into prefix pattern", "merged exact patterns into glob pattern",
log.String("prefix", key.prefix), log.String("template", key.template),
log.Int("count", len(group)), log.Int("count", len(group)),
log.String("banner_id", banner.ID.String()), log.String("banner_id", banner.ID.String()),
) )
@@ -242,7 +242,7 @@ func (h *patternAnalysisHandler) Process(ctx context.Context, banner coredata.Co
type mergeGroupKey struct { type mergeGroupKey struct {
categoryID gid.GID categoryID gid.GID
trackerType coredata.TrackerType trackerType coredata.TrackerType
prefix string template string
durationBucket int durationBucket int
} }
@@ -250,29 +250,30 @@ func findMergeGroups(
patterns coredata.TrackerPatterns, patterns coredata.TrackerPatterns,
threshold int, threshold int,
) map[mergeGroupKey][]*coredata.TrackerPattern { ) map[mergeGroupKey][]*coredata.TrackerPattern {
prefixCounts := make(map[mergeGroupKey][]*coredata.TrackerPattern) templateCounts := make(map[mergeGroupKey][]*coredata.TrackerPattern)
for _, p := range patterns { for _, p := range patterns {
bucket := durationBucket(p.MaxAgeSeconds) bucket := durationBucket(p.MaxAgeSeconds)
for _, pfx := range separatorPrefixes(p.Pattern) { for _, tmpl := range templateCandidates(p.Pattern) {
key := mergeGroupKey{categoryID: p.CookieCategoryID, trackerType: p.TrackerType, prefix: pfx, durationBucket: bucket} key := mergeGroupKey{categoryID: p.CookieCategoryID, trackerType: p.TrackerType, template: tmpl, durationBucket: bucket}
prefixCounts[key] = append(prefixCounts[key], p) templateCounts[key] = append(templateCounts[key], p)
} }
} }
type candidate struct { type candidate struct {
key mergeGroupKey key mergeGroupKey
patterns []*coredata.TrackerPattern fixedChars int
patterns []*coredata.TrackerPattern
} }
var candidates []candidate var candidates []candidate
for key, pats := range prefixCounts { for key, pats := range templateCounts {
if len(pats) >= threshold { if len(pats) >= threshold {
candidates = append(candidates, candidate{key, pats}) candidates = append(candidates, candidate{key, len(key.template) - 1, pats})
} }
} }
sort.Slice(candidates, func(i, j int) bool { sort.Slice(candidates, func(i, j int) bool {
return len(candidates[i].key.prefix) > len(candidates[j].key.prefix) return candidates[i].fixedChars > candidates[j].fixedChars
}) })
assigned := make(map[*coredata.TrackerPattern]bool) assigned := make(map[*coredata.TrackerPattern]bool)
@@ -299,14 +300,47 @@ func findMergeGroups(
return groups return groups
} }
func separatorPrefixes(name string) []string { func templateCandidates(name string) []string {
var prefixes []string var candidates []string
for i, ch := range name { for i, ch := range name {
if ch == '_' || ch == '-' { if ch == '_' || ch == '-' {
prefixes = append(prefixes, name[:i+1]) candidates = append(candidates, name[:i+1]+"*")
} }
} }
return prefixes
tokens, sep := splitTokens(name)
if len(tokens) >= 3 && sep != 0 {
s := string(sep)
for pos := 1; pos < len(tokens)-1; pos++ {
tmpl := strings.Join(tokens[:pos], s) + s + "*" + s + strings.Join(tokens[pos+1:], s)
candidates = append(candidates, tmpl)
}
}
return candidates
}
func splitTokens(name string) ([]string, byte) {
if idx := strings.IndexByte(name, '_'); idx >= 0 {
return strings.Split(name, "_"), '_'
}
if idx := strings.IndexByte(name, '-'); idx >= 0 {
return strings.Split(name, "-"), '-'
}
return []string{name}, 0
}
func globMatch(pattern, name string) bool {
star := strings.Index(pattern, "*")
if star < 0 {
return pattern == name
}
prefix := pattern[:star]
suffix := pattern[star+1:]
return strings.HasPrefix(name, prefix) &&
strings.HasSuffix(name, suffix) &&
len(name) >= len(prefix)+len(suffix)
} }
func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource { func bestSource(patterns []*coredata.TrackerPattern) *coredata.CookieSource {
@@ -333,24 +367,24 @@ func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
return false, fmt.Errorf("cannot load uncategorised category: %w", err) return false, fmt.Errorf("cannot load uncategorised category: %w", err)
} }
var prefixPatterns coredata.TrackerPatterns var globPatterns coredata.TrackerPatterns
if err := prefixPatterns.LoadAllByCookieBannerID( if err := globPatterns.LoadAllByCookieBannerID(
ctx, ctx,
tx, tx,
scope, scope,
banner.ID, banner.ID,
coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypePrefix), nil, new(false)), coredata.NewTrackerPatternFilter(new(coredata.TrackerPatternMatchTypeGlob), nil, new(false)),
nil, nil,
); err != nil { ); err != nil {
return false, fmt.Errorf("cannot load prefix patterns: %w", err) return false, fmt.Errorf("cannot load glob patterns: %w", err)
} }
if len(prefixPatterns) == 0 { if len(globPatterns) == 0 {
return false, nil return false, nil
} }
sort.Slice(prefixPatterns, func(i, j int) bool { sort.Slice(globPatterns, func(i, j int) bool {
return len(prefixPatterns[i].Pattern) > len(prefixPatterns[j].Pattern) return len(globPatterns[i].Pattern) > len(globPatterns[j].Pattern)
}) })
exactMatchType := coredata.TrackerPatternMatchTypeExact exactMatchType := coredata.TrackerPatternMatchTypeExact
@@ -370,9 +404,9 @@ func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
for _, ep := range uncategorisedExact { for _, ep := range uncategorisedExact {
var match *coredata.TrackerPattern var match *coredata.TrackerPattern
epBucket := durationBucket(ep.MaxAgeSeconds) epBucket := durationBucket(ep.MaxAgeSeconds)
for _, pp := range prefixPatterns { for _, gp := range globPatterns {
if ep.TrackerType == pp.TrackerType && strings.HasPrefix(ep.Pattern, pp.Pattern) && durationBucket(pp.MaxAgeSeconds) == epBucket { if ep.TrackerType == gp.TrackerType && globMatch(gp.Pattern, ep.Pattern) && durationBucket(gp.MaxAgeSeconds) == epBucket {
match = pp match = gp
break break
} }
} }
@@ -393,9 +427,9 @@ func (h *patternAnalysisHandler) adoptUncategorisedPatterns(
adopted = true adopted = true
h.logger.InfoCtx( h.logger.InfoCtx(
ctx, ctx,
"adopted uncategorised exact pattern into prefix pattern", "adopted uncategorised exact pattern into glob pattern",
log.String("exact_pattern", ep.Pattern), log.String("exact_pattern", ep.Pattern),
log.String("prefix_pattern", match.Pattern), log.String("glob_pattern", match.Pattern),
log.String("banner_id", banner.ID.String()), log.String("banner_id", banner.ID.String()),
) )
} }