Scope reset-trackers by keyword and report progress

The reset-trackers operator command reset every uncategorised,
non-excluded pattern of a banner and printed only a single summary
line once the transaction committed, giving no feedback during long
rebuilds.

Add a --keyword flag that scopes both the glob decomposition and the
mapping reset to patterns whose pattern or display name contains the
substring. The match lives in a new TrackerPatternFilter.WithPatternKeyword
field so it runs in SQL and is shared by the glob load and the
ResetAndRequestMappingByCookieCategoryID update, keeping the two in
lockstep. The banner-wide pattern-analysis re-arm is left unscoped.

Thread an optional progress callback through ResetBannerTrackers so the
command streams per-phase updates (category load, per-glob decomposition,
mapping reset, analysis re-arm) as the work runs.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-09 19:40:16 +02:00
parent 5415b2d438
commit faca86a022
5 changed files with 95 additions and 10 deletions

View File

@@ -33,6 +33,21 @@ type ResetTrackersResult struct {
AnalysisRequested bool
}
// ResetProgressFunc receives human-readable progress messages emitted as
// a banner reset advances through its phases. It is optional: pass nil to
// run silently. Messages are emitted inside the reset transaction, so a
// later failure that rolls the transaction back may leave already-printed
// progress describing work that did not commit.
type ResetProgressFunc func(message string)
func (p ResetProgressFunc) report(format string, args ...any) {
if p == nil {
return
}
p(fmt.Sprintf(format, args...))
}
// ResetBannerTrackers re-arms the tracker pipeline for a banner's
// uncategorised, non-excluded patterns. It is an operator action
// (proboctl), tenant-scoped via the provided Scoper.
@@ -50,39 +65,57 @@ type ResetTrackersResult struct {
// every detection it covers becomes (or rejoins) an exact pattern keyed
// by its identifier - and the now-empty glob is deleted. User-categorised
// and excluded patterns are never touched.
//
// When keyword is non-nil and non-empty, the reset is scoped to patterns
// whose pattern or display name contains it (case-insensitive): only
// matching globs are decomposed and only matching patterns are re-armed
// for mapping. The banner-wide pattern-analysis re-arm is unaffected.
//
// progress receives human-readable phase updates as the reset runs; pass
// nil to run silently.
func ResetBannerTrackers(
ctx context.Context,
pgClient *pg.Client,
scope coredata.Scoper,
bannerID gid.GID,
mappingOnly bool,
keyword *string,
progress ResetProgressFunc,
) (ResetTrackersResult, error) {
var result ResetTrackersResult
err := pgClient.WithTx(
ctx,
func(ctx context.Context, tx pg.Tx) error {
progress.report("Loading uncategorised category for banner %s...", bannerID)
var uncategorised coredata.CookieCategory
if err := uncategorised.LoadUncategorisedByCookieBannerID(ctx, tx, scope, bannerID); err != nil {
return fmt.Errorf("cannot load uncategorised category: %w", err)
}
if !mappingOnly {
if err := decomposeGlobs(ctx, tx, scope, bannerID, uncategorised.ID, &result); err != nil {
if err := decomposeGlobs(ctx, tx, scope, bannerID, uncategorised.ID, keyword, &result, progress); err != nil {
return err
}
}
progress.report("Resetting links and re-arming mapping on matching patterns...")
var patterns coredata.TrackerPatterns
reset, err := patterns.ResetAndRequestMappingByCookieCategoryID(ctx, tx, scope, uncategorised.ID)
reset, err := patterns.ResetAndRequestMappingByCookieCategoryID(ctx, tx, scope, uncategorised.ID, keyword)
if err != nil {
return fmt.Errorf("cannot reset and request mapping: %w", err)
}
result.PatternsReset = reset
progress.report("Reset %d pattern(s) and re-armed mapping.", reset)
if !mappingOnly {
progress.report("Re-arming pattern analysis on banner %s...", bannerID)
banner := coredata.CookieBanner{ID: bannerID}
if err := banner.SetPatternAnalysisRequested(ctx, tx); err != nil {
return fmt.Errorf("cannot request pattern analysis: %w", err)
@@ -110,7 +143,9 @@ func decomposeGlobs(
scope coredata.Scoper,
bannerID gid.GID,
uncategorisedID gid.GID,
keyword *string,
result *ResetTrackersResult,
progress ResetProgressFunc,
) error {
globMatchType := coredata.TrackerPatternMatchTypeGlob
notExcluded := false
@@ -121,18 +156,22 @@ func decomposeGlobs(
tx,
scope,
bannerID,
coredata.NewTrackerPatternFilter(&globMatchType, &uncategorisedID, &notExcluded),
coredata.NewTrackerPatternFilter(&globMatchType, &uncategorisedID, &notExcluded).WithPatternKeyword(keyword),
nil,
); err != nil {
return fmt.Errorf("cannot load glob patterns: %w", err)
}
for _, glob := range globs {
progress.report("Decomposing %d glob pattern(s) into exact patterns...", len(globs))
for i, glob := range globs {
var detections coredata.DetectedTrackers
if err := detections.LoadAllByTrackerPatternID(ctx, tx, scope, glob.ID); err != nil {
return fmt.Errorf("cannot load detections for glob %q: %w", glob.Pattern, err)
}
progress.report("[%d/%d] Decomposing glob %q (%d detection(s))...", i+1, len(globs), glob.Pattern, len(detections))
for _, detection := range detections {
exactID, created, err := ensureExactPattern(ctx, tx, scope, glob, uncategorisedID, detection)
if err != nil {

View File

@@ -85,7 +85,7 @@ func TestResetBannerTrackers_FullRebuild(t *testing.T) {
return nil
}))
result, err := ResetBannerTrackers(ctx, client, fx.scope, fx.banner.ID, false)
result, err := ResetBannerTrackers(ctx, client, fx.scope, fx.banner.ID, false, nil, nil)
require.NoError(t, err)
require.Equal(t, 1, result.GlobsDecomposed)

View File

@@ -1390,14 +1390,19 @@ WHERE
// iterating on the mapping agent. Excluded patterns are left untouched -
// exclusion is a deliberate suppression. The cookie_category_id key
// scopes the reset to the uncategorised category the caller resolves;
// the Scoper keeps it tenant-isolated. Returns the number of patterns
// reset.
// the Scoper keeps it tenant-isolated. When keyword is non-nil and
// non-empty, the reset is further restricted to patterns whose pattern or
// display name contains it (case-insensitive). Returns the number of
// patterns reset.
func (tps *TrackerPatterns) ResetAndRequestMappingByCookieCategoryID(
ctx context.Context,
tx pg.Tx,
scope Scoper,
cookieCategoryID gid.GID,
keyword *string,
) (int64, error) {
filter := NewTrackerPatternFilter(nil, nil, nil).WithPatternKeyword(keyword)
q := `
UPDATE tracker_patterns
SET
@@ -1410,12 +1415,14 @@ WHERE
%s
AND cookie_category_id = @cookie_category_id
AND excluded = false
AND %s
`
q = fmt.Sprintf(q, scope.SQLFragment())
q = fmt.Sprintf(q, scope.SQLFragment(), filter.SQLFragment())
args := pgx.StrictNamedArgs{"cookie_category_id": cookieCategoryID}
maps.Copy(args, scope.SQLArguments())
maps.Copy(args, filter.SQLArguments())
result, err := tx.Exec(ctx, q, args)
if err != nil {

View File

@@ -24,6 +24,7 @@ type TrackerPatternFilter struct {
cookieCategoryID *gid.GID
excluded *bool
query *string
patternKeyword *string
source *CookieSource
trackerType *TrackerType
thirdPartyID *gid.GID
@@ -47,6 +48,16 @@ func (f *TrackerPatternFilter) WithQuery(query *string) *TrackerPatternFilter {
return f
}
// WithPatternKeyword restricts the result to patterns whose pattern or
// display name contains keyword (case-insensitive). It differs from
// WithQuery, which matches display name or description: this targets the
// raw pattern so operators can select by the matched domain or cookie
// name. A nil or empty keyword disables the filter.
func (f *TrackerPatternFilter) WithPatternKeyword(keyword *string) *TrackerPatternFilter {
f.patternKeyword = keyword
return f
}
func (f *TrackerPatternFilter) WithSource(source *CookieSource) *TrackerPatternFilter {
f.source = source
return f
@@ -102,6 +113,13 @@ func (f *TrackerPatternFilter) SQLFragment() string {
ELSE TRUE
END
AND
CASE
WHEN @filter_pattern_keyword::text IS NOT NULL AND @filter_pattern_keyword::text != '' THEN
(pattern ILIKE '%' || @filter_pattern_keyword || '%'
OR display_name ILIKE '%' || @filter_pattern_keyword || '%')
ELSE TRUE
END
AND
CASE
WHEN @has_source_filter::boolean = false THEN TRUE
WHEN @has_source_filter::boolean = true THEN
@@ -148,6 +166,7 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
"has_excluded_filter": false,
"filter_excluded": nil,
"filter_query": nil,
"filter_pattern_keyword": nil,
"has_source_filter": false,
"filter_source": nil,
"has_tracker_type_filter": false,
@@ -177,6 +196,10 @@ func (f *TrackerPatternFilter) SQLArguments() pgx.StrictNamedArgs {
args["filter_query"] = *f.query
}
if f.patternKeyword != nil {
args["filter_pattern_keyword"] = *f.patternKeyword
}
if f.source != nil {
args["has_source_filter"] = true
args["filter_source"] = string(*f.source)

View File

@@ -27,6 +27,7 @@ import (
func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
var (
flagMappingOnly bool
flagKeyword string
flagDryRun bool
flagYes bool
)
@@ -38,11 +39,14 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
"non-excluded patterns it clears catalog/vendor links, rebuilds the raw exact " +
"patterns from detected_trackers (decomposing derived globs), and re-arms the " +
"pattern-analysis and mapping workers. User-categorised and excluded patterns are " +
"preserved. With --mapping-only it skips the rebuild and only re-arms mapping.",
"preserved. With --mapping-only it skips the rebuild and only re-arms mapping. " +
"With --keyword the rebuild and mapping reset are scoped to patterns whose pattern " +
"or display name contains the substring.",
Args: cobra.ExactArgs(1),
}
cmd.Flags().BoolVar(&flagMappingOnly, "mapping-only", false, "Only re-arm mapping (skip the detection rebuild and analysis)")
cmd.Flags().StringVar(&flagKeyword, "keyword", "", "Only reset patterns whose pattern or display name contains this substring")
cmd.Flags().BoolVar(&flagDryRun, "dry-run", false, "Print the target banner without writing")
cmd.Flags().BoolVar(&flagYes, "yes", false, "Skip confirmation")
@@ -68,6 +72,12 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
mode = "mapping-only reset"
}
var keyword *string
if flagKeyword != "" {
keyword = &flagKeyword
mode = fmt.Sprintf("%s scoped to keyword %q", mode, flagKeyword)
}
if flagDryRun {
_, _ = fmt.Fprintf(out, "Would run %s on banner %s.\n", mode, bannerID.String())
return nil
@@ -77,7 +87,13 @@ func newCmdResetTrackers(f *cmdutil.Factory) *cobra.Command {
return fmt.Errorf("about to run %s on banner %s; pass --yes to proceed or --dry-run to preview", mode, bannerID.String())
}
result, err := cookiebanner.ResetBannerTrackers(ctx, pgClient, scope, bannerID, flagMappingOnly)
_, _ = fmt.Fprintf(out, "Running %s on banner %s.\n", mode, bannerID.String())
progress := func(message string) {
_, _ = fmt.Fprintf(out, " %s\n", message)
}
result, err := cookiebanner.ResetBannerTrackers(ctx, pgClient, scope, bannerID, flagMappingOnly, keyword, progress)
if err != nil {
return fmt.Errorf("cannot reset banner %s: %w", bannerID, err)
}