diff --git a/pkg/cookiebanner/prompts/tracker_identification.txt b/pkg/cookiebanner/prompts/tracker_identification.txt
new file mode 100644
index 000000000..b0a36b0db
--- /dev/null
+++ b/pkg/cookiebanner/prompts/tracker_identification.txt
@@ -0,0 +1,45 @@
+
+You are a cookie and web tracker identification expert. Your job is to identify which company or service sets a given cookie or tracker, based on its name, type, and the domains it was observed on.
+
+
+
+Given a tracker pattern (cookie name, local storage key, etc.), its type, max-age, and the domains where it was observed, identify the company or service responsible for setting it.
+
+Return a structured JSON response with:
+- third_party_name: the canonical company/service name (e.g. "Google Analytics", not "google" or "GA")
+- category: the business category of the third party
+- description: a one-sentence description of what this tracker does
+- confidence: how confident you are in the identification (0.0 to 1.0)
+
+
+
+1. First use the search_tracker_patterns tool to look for similar known patterns in the database. Strip variable parts (IDs, UUIDs, timestamps) from the pattern name and search for the fixed prefix or root (e.g. for "_gat_UA-12345678-1", search for "_gat").
+
+2. If search_tracker_patterns returns results with a third party name, use the search_third_parties tool to confirm the exact name in the database and get its category.
+
+3. Only use web_search as a last resort if the internal tools return nothing useful. Search for "what is cookie [name]" or "[name] cookie tracker".
+
+4. Common cookie naming conventions to recognize:
+ - _ga*, _gid, _gat*: Google Analytics
+ - _fbp, _fbc, fr: Meta / Facebook
+ - _pk_*: Matomo (formerly Piwik)
+ - _hj*: Hotjar
+ - _gcl_*: Google Ads
+ - __cf*: Cloudflare
+ - _tt_*: TikTok
+ - hubspot*: HubSpot
+ - _cls_*: Clarity (Microsoft)
+
+5. The observed domains are strong signals. If the cookie comes from a well-known tracking domain (e.g. doubleclick.net, facebook.com, analytics.google.com), that is strong evidence of the third party.
+
+6. Be conservative with confidence:
+ - 0.9-1.0: exact pattern match found in database or unmistakable naming convention + matching domain
+ - 0.7-0.8: strong signal from naming convention or domain, but not a database match
+ - 0.5-0.6: reasonable guess based on partial naming patterns
+ - Below 0.5: uncertain, speculative
+
+7. If you truly cannot identify the tracker, set third_party_name to an empty string and confidence below 0.3.
+
+8. For the category field, use one of: ANALYTICS, ADVERTISING, CLOUD_MONITORING, CLOUD_PROVIDER, COLLABORATION, CUSTOMER_SUPPORT, DATA_STORAGE_AND_PROCESSING, DOCUMENT_MANAGEMENT, EMPLOYEE_MANAGEMENT, ENGINEERING, FINANCE, IDENTITY_PROVIDER, IT, MARKETING, OFFICE_OPERATIONS, OTHER, PASSWORD_MANAGEMENT, PRODUCT_AND_DESIGN, PROFESSIONAL_SERVICES, RECRUITING, SALES, SECURITY, VERSION_CONTROL.
+ Most cookies fall under ANALYTICS, ADVERTISING, or MARKETING.
+
diff --git a/pkg/cookiebanner/tools_tracker_mapping.go b/pkg/cookiebanner/tools_tracker_mapping.go
new file mode 100644
index 000000000..cbba74170
--- /dev/null
+++ b/pkg/cookiebanner/tools_tracker_mapping.go
@@ -0,0 +1,136 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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.
+
+package cookiebanner
+
+import (
+ "context"
+
+ "go.gearno.de/kit/pg"
+ "go.probo.inc/probo/pkg/agent"
+ "go.probo.inc/probo/pkg/coredata"
+)
+
+type (
+ searchPatternsParams struct {
+ Query string `json:"query" jsonschema:"Search fragment to match against known cookie/tracker pattern names and descriptions (e.g. '_ga', 'matomo', 'facebook')"`
+ }
+
+ searchPatternsResult struct {
+ Pattern string `json:"pattern"`
+ Description string `json:"description"`
+ TrackerType string `json:"tracker_type"`
+ ThirdPartyName string `json:"third_party_name,omitempty"`
+ Confidence float32 `json:"confidence"`
+ }
+
+ searchThirdPartiesParams struct {
+ Query string `json:"query" jsonschema:"Search fragment to match against known third party names (e.g. 'Google', 'Meta', 'Hotjar')"`
+ }
+
+ searchThirdPartiesResult struct {
+ Name string `json:"name"`
+ Category string `json:"category"`
+ WebsiteURL string `json:"website_url,omitempty"`
+ }
+)
+
+func searchTrackerPatternsTool(pgClient *pg.Client) agent.Tool {
+ return agent.FunctionTool(
+ "search_tracker_patterns",
+ "Search the internal database of known cookie and tracker patterns by name fragment or description keyword. Returns matching patterns with their linked third party name and confidence score. Use this first to find similar known patterns before resorting to web search.",
+ func(ctx context.Context, p searchPatternsParams) (agent.ToolResult, error) {
+ if p.Query == "" {
+ return agent.ResultError("query is required"), nil
+ }
+
+ var out []searchPatternsResult
+
+ if err := pgClient.WithConn(
+ ctx,
+ func(ctx context.Context, conn pg.Querier) error {
+ var patterns coredata.CommonTrackerPatterns
+ results, err := patterns.FindByKeyword(ctx, conn, p.Query, 10)
+ if err != nil {
+ return err
+ }
+
+ out = make([]searchPatternsResult, len(results))
+ for i, r := range results {
+ out[i] = searchPatternsResult{
+ Pattern: r.Pattern,
+ Description: r.Description,
+ TrackerType: string(r.TrackerType),
+ Confidence: r.Confidence,
+ }
+ if r.ThirdPartyName != nil {
+ out[i].ThirdPartyName = *r.ThirdPartyName
+ }
+ }
+
+ return nil
+ },
+ ); err != nil {
+ return agent.ResultErrorf("search failed: %s", err), nil
+ }
+
+ return agent.ResultJSON(out), nil
+ },
+ )
+}
+
+func searchThirdPartiesTool(pgClient *pg.Client) agent.Tool {
+ return agent.FunctionTool(
+ "search_third_parties",
+ "Search the internal database of known third parties (companies/services) by name fragment. Returns matching third party names, categories, and website URLs. Use this to find the exact name of a known third party to link the tracker to.",
+ func(ctx context.Context, p searchThirdPartiesParams) (agent.ToolResult, error) {
+ if p.Query == "" {
+ return agent.ResultError("query is required"), nil
+ }
+
+ var out []searchThirdPartiesResult
+
+ if err := pgClient.WithConn(
+ ctx,
+ func(ctx context.Context, conn pg.Querier) error {
+ var parties coredata.CommonThirdParties
+ if err := parties.LoadAll(
+ ctx,
+ conn,
+ coredata.NewCommonThirdPartyFilter(&p.Query),
+ ); err != nil {
+ return err
+ }
+
+ out = make([]searchThirdPartiesResult, len(parties))
+ for i, tp := range parties {
+ out[i] = searchThirdPartiesResult{
+ Name: tp.Name,
+ Category: string(tp.Category),
+ }
+ if tp.WebsiteURL != nil {
+ out[i].WebsiteURL = *tp.WebsiteURL
+ }
+ }
+
+ return nil
+ },
+ ); err != nil {
+ return agent.ResultErrorf("search failed: %s", err), nil
+ }
+
+ return agent.ResultJSON(out), nil
+ },
+ )
+}
diff --git a/pkg/cookiebanner/tracker_identification.go b/pkg/cookiebanner/tracker_identification.go
new file mode 100644
index 000000000..9eaf53da4
--- /dev/null
+++ b/pkg/cookiebanner/tracker_identification.go
@@ -0,0 +1,26 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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.
+
+package cookiebanner
+
+// TrackerIdentification is the structured output the tracker-mapping
+// agent returns. The Category field uses the same values as the
+// third_party_category PostgreSQL enum so auto-created CommonThirdParty
+// rows get a valid category without mapping.
+type TrackerIdentification struct {
+ ThirdPartyName string `json:"third_party_name" jsonschema:"Name of the company or service that sets this tracker (e.g. 'Google Analytics', 'Meta Pixel'). Empty string if truly unknown."`
+ Category string `json:"category" jsonschema:"Third party category. One of: ANALYTICS, ADVERTISING, CLOUD_MONITORING, CLOUD_PROVIDER, COLLABORATION, CUSTOMER_SUPPORT, DATA_STORAGE_AND_PROCESSING, DOCUMENT_MANAGEMENT, EMPLOYEE_MANAGEMENT, ENGINEERING, FINANCE, IDENTITY_PROVIDER, IT, MARKETING, OFFICE_OPERATIONS, OTHER, PASSWORD_MANAGEMENT, PRODUCT_AND_DESIGN, PROFESSIONAL_SERVICES, RECRUITING, SALES, SECURITY, VERSION_CONTROL"`
+ Description string `json:"description" jsonschema:"What this tracker does in one sentence"`
+ Confidence float64 `json:"confidence" jsonschema:"Confidence level from 0.0 to 1.0. Set below 0.5 if unsure."`
+}
diff --git a/pkg/cookiebanner/tracker_mapping_worker.go b/pkg/cookiebanner/tracker_mapping_worker.go
index eeeeec2e5..33ca05c29 100644
--- a/pkg/cookiebanner/tracker_mapping_worker.go
+++ b/pkg/cookiebanner/tracker_mapping_worker.go
@@ -16,25 +16,50 @@ package cookiebanner
import (
"context"
+ _ "embed"
+ "encoding/json"
"errors"
"fmt"
+ "strings"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.gearno.de/kit/worker"
+ "go.probo.inc/probo/pkg/agent"
+ "go.probo.inc/probo/pkg/agent/tools/search"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
+ "go.probo.inc/probo/pkg/llm"
+ "go.probo.inc/probo/pkg/slug"
)
+const (
+ agentTimeout = 60 * time.Second
+ agentMaxTurns = 5
+ agentConfidenceThreshold = 0.6
+ agentMaxPatternConfidence = 0.8
+)
+
+//go:embed prompts/tracker_identification.txt
+var trackerIdentificationPrompt string
+
type trackerMappingHandler struct {
pg *pg.Client
logger *log.Logger
+ agent *agent.Agent
+}
+
+type TrackerMappingConfig struct {
+ LLMClient *llm.Client
+ Model string
+ SearchEndpoint string
}
func NewTrackerMappingWorker(
pgClient *pg.Client,
logger *log.Logger,
+ cfg TrackerMappingConfig,
opts ...worker.Option,
) *worker.Worker[coredata.TrackerPattern] {
h := &trackerMappingHandler{
@@ -42,6 +67,16 @@ func NewTrackerMappingWorker(
logger: logger,
}
+ if cfg.LLMClient != nil {
+ h.agent = buildTrackerMappingAgent(
+ cfg.LLMClient,
+ cfg.Model,
+ cfg.SearchEndpoint,
+ pgClient,
+ logger,
+ )
+ }
+
return worker.New(
"tracker-mapping-worker",
h,
@@ -50,6 +85,39 @@ func NewTrackerMappingWorker(
)
}
+func buildTrackerMappingAgent(
+ llmClient *llm.Client,
+ model string,
+ searchEndpoint string,
+ pgClient *pg.Client,
+ logger *log.Logger,
+) *agent.Agent {
+ tools := []agent.Tool{
+ searchTrackerPatternsTool(pgClient),
+ searchThirdPartiesTool(pgClient),
+ }
+
+ if searchEndpoint != "" {
+ tools = append(tools, search.WebSearchTool(searchEndpoint))
+ }
+
+ outputType, err := agent.NewOutputType[TrackerIdentification]("tracker_identification")
+ if err != nil {
+ panic(fmt.Sprintf("cookiebanner: cannot build tracker identification output type: %s", err))
+ }
+
+ return agent.New(
+ "tracker-mapping",
+ llmClient,
+ agent.WithInstructions(trackerIdentificationPrompt),
+ agent.WithModel(model),
+ agent.WithTools(tools...),
+ agent.WithOutputType(outputType),
+ agent.WithMaxTurns(agentMaxTurns),
+ agent.WithLogger(logger),
+ )
+}
+
func (h *trackerMappingHandler) Claim(ctx context.Context) (coredata.TrackerPattern, error) {
var tp coredata.TrackerPattern
@@ -85,6 +153,10 @@ func (h *trackerMappingHandler) Process(ctx context.Context, tp coredata.Tracker
commonPatternID, thirdPartyID = h.matchByDomain(ctx, tx, tp)
}
+ if commonPatternID == nil && h.agent != nil {
+ commonPatternID, thirdPartyID = h.identifyWithAgent(ctx, tx, tp)
+ }
+
if commonPatternID == nil {
commonPatternID = h.createUnmatchedPattern(ctx, tx, tp)
}
@@ -183,6 +255,195 @@ func (h *trackerMappingHandler) matchByDomain(
return &commonPattern.ID, thirdPartyID
}
+func (h *trackerMappingHandler) identifyWithAgent(
+ ctx context.Context,
+ tx pg.Tx,
+ tp coredata.TrackerPattern,
+) (*gid.GID, *gid.GID) {
+ var trackers coredata.DetectedTrackers
+ domains, err := trackers.LoadInitiatorDomainsByTrackerPatternID(ctx, tx, tp.ID)
+ if err != nil {
+ h.logger.WarnCtx(ctx, "cannot load initiator domains for agent", log.Error(err))
+ }
+
+ prompt := buildAgentPrompt(tp, domains)
+
+ agentCtx, cancel := context.WithTimeout(ctx, agentTimeout)
+ defer cancel()
+
+ result, err := h.agent.Run(
+ agentCtx,
+ []llm.Message{
+ {
+ Role: llm.RoleUser,
+ Parts: []llm.Part{llm.TextPart{Text: prompt}},
+ },
+ },
+ )
+ if err != nil {
+ h.logger.WarnCtx(
+ ctx,
+ "agent identification failed",
+ log.Error(err),
+ log.String("pattern", tp.Pattern),
+ )
+ return nil, nil
+ }
+
+ var identification TrackerIdentification
+ if err := json.Unmarshal([]byte(result.FinalMessage().Text()), &identification); err != nil {
+ h.logger.WarnCtx(
+ ctx,
+ "cannot parse agent identification output",
+ log.Error(err),
+ log.String("pattern", tp.Pattern),
+ )
+ return nil, nil
+ }
+
+ if identification.Confidence < agentConfidenceThreshold {
+ h.logger.InfoCtx(
+ ctx,
+ "agent identification below confidence threshold",
+ log.String("pattern", tp.Pattern),
+ log.Float64("confidence", identification.Confidence),
+ )
+ return nil, nil
+ }
+
+ confidence := float32(identification.Confidence)
+ if confidence > agentMaxPatternConfidence {
+ confidence = agentMaxPatternConfidence
+ }
+
+ var commonThirdPartyID *gid.GID
+ if identification.ThirdPartyName != "" {
+ commonThirdPartyID = h.resolveOrCreateCommonThirdParty(
+ ctx,
+ tx,
+ identification,
+ domains,
+ )
+ }
+
+ now := time.Now()
+ commonPattern := coredata.CommonTrackerPattern{
+ ID: gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType),
+ CommonThirdPartyID: commonThirdPartyID,
+ TrackerType: tp.TrackerType,
+ Pattern: tp.Pattern,
+ MatchType: tp.MatchType,
+ Description: identification.Description,
+ MaxAgeSeconds: tp.MaxAgeSeconds,
+ Confidence: confidence,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ actualID, _, err := commonPattern.Upsert(ctx, tx)
+ if err != nil {
+ h.logger.ErrorCtx(
+ ctx,
+ "cannot upsert common tracker pattern from agent",
+ log.Error(err),
+ log.String("pattern", tp.Pattern),
+ )
+ return nil, nil
+ }
+
+ commonPattern.ID = actualID
+ thirdPartyID, err := h.resolveThirdParty(ctx, tx, tp, &commonPattern)
+ if err != nil {
+ h.logger.ErrorCtx(ctx, "cannot resolve third party from agent match", log.Error(err))
+ return &commonPattern.ID, nil
+ }
+
+ h.logger.InfoCtx(
+ ctx,
+ "agent identified tracker pattern",
+ log.String("pattern", tp.Pattern),
+ log.String("third_party", identification.ThirdPartyName),
+ log.Float64("confidence", identification.Confidence),
+ )
+
+ return &commonPattern.ID, thirdPartyID
+}
+
+func (h *trackerMappingHandler) resolveOrCreateCommonThirdParty(
+ ctx context.Context,
+ tx pg.Tx,
+ identification TrackerIdentification,
+ domains []string,
+) *gid.GID {
+ var party coredata.CommonThirdParty
+ if err := party.LoadByName(ctx, tx, identification.ThirdPartyName); err == nil {
+ return &party.ID
+ }
+
+ partySlug := slug.Make(identification.ThirdPartyName)
+ if partySlug == "" {
+ return nil
+ }
+
+ if err := party.LoadBySlug(ctx, tx, partySlug); err == nil {
+ return &party.ID
+ }
+
+ category := coredata.ThirdPartyCategoryOther
+ if parsed := parseThirdPartyCategory(identification.Category); parsed != "" {
+ category = parsed
+ }
+
+ now := time.Now()
+ party = coredata.CommonThirdParty{
+ ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyEntityType),
+ Name: identification.ThirdPartyName,
+ Slug: partySlug,
+ Category: category,
+ Certifications: []string{},
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if err := party.Insert(ctx, tx); err != nil {
+ h.logger.WarnCtx(
+ ctx,
+ "cannot create common third party from agent",
+ log.Error(err),
+ log.String("name", identification.ThirdPartyName),
+ )
+ return nil
+ }
+
+ for _, domain := range domains {
+ domainRecord := coredata.CommonThirdPartyDomain{
+ ID: gid.New(gid.NilTenant, coredata.CommonThirdPartyDomainEntityType),
+ CommonThirdPartyID: party.ID,
+ Domain: domain,
+ CreatedAt: now,
+ UpdatedAt: now,
+ }
+
+ if _, err := domainRecord.Upsert(ctx, tx); err != nil {
+ h.logger.WarnCtx(
+ ctx,
+ "cannot create common third party domain from agent",
+ log.Error(err),
+ log.String("domain", domain),
+ )
+ }
+ }
+
+ h.logger.InfoCtx(
+ ctx,
+ "created common third party from agent identification",
+ log.String("name", identification.ThirdPartyName),
+ log.String("category", string(category)),
+ )
+
+ return &party.ID
+}
+
func (h *trackerMappingHandler) createUnmatchedPattern(
ctx context.Context,
tx pg.Tx,
@@ -242,3 +503,77 @@ func (h *trackerMappingHandler) resolveThirdParty(
return &t.ID, nil
}
+
+func buildAgentPrompt(tp coredata.TrackerPattern, domains []string) string {
+ var b strings.Builder
+
+ fmt.Fprintf(&b, "Identify the following tracker:\n\n")
+ fmt.Fprintf(&b, "- Pattern: %s\n", tp.Pattern)
+ fmt.Fprintf(&b, "- Type: %s\n", tp.TrackerType)
+ fmt.Fprintf(&b, "- Match type: %s\n", tp.MatchType)
+
+ if tp.MaxAgeSeconds != nil {
+ fmt.Fprintf(&b, "- Max age: %d seconds\n", *tp.MaxAgeSeconds)
+ } else {
+ fmt.Fprintf(&b, "- Max age: session\n")
+ }
+
+ if len(domains) > 0 {
+ fmt.Fprintf(&b, "- Observed on domains: %s\n", strings.Join(domains, ", "))
+ }
+
+ return b.String()
+}
+
+func parseThirdPartyCategory(s string) coredata.ThirdPartyCategory {
+ switch s {
+ case "ANALYTICS":
+ return coredata.ThirdPartyCategoryAnalytics
+ case "ADVERTISING":
+ return coredata.ThirdPartyCategoryMarketing
+ case "CLOUD_MONITORING":
+ return coredata.ThirdPartyCategoryCloudMonitoring
+ case "CLOUD_PROVIDER":
+ return coredata.ThirdPartyCategoryCloudProvider
+ case "COLLABORATION":
+ return coredata.ThirdPartyCategoryCollaboration
+ case "CUSTOMER_SUPPORT":
+ return coredata.ThirdPartyCategoryCustomerSupport
+ case "DATA_STORAGE_AND_PROCESSING":
+ return coredata.ThirdPartyCategoryDataStorageAndProcessing
+ case "DOCUMENT_MANAGEMENT":
+ return coredata.ThirdPartyCategoryDocumentManagement
+ case "EMPLOYEE_MANAGEMENT":
+ return coredata.ThirdPartyCategoryEmployeeManagement
+ case "ENGINEERING":
+ return coredata.ThirdPartyCategoryEngineering
+ case "FINANCE":
+ return coredata.ThirdPartyCategoryFinance
+ case "IDENTITY_PROVIDER":
+ return coredata.ThirdPartyCategoryIdentityProvider
+ case "IT":
+ return coredata.ThirdPartyCategoryIT
+ case "MARKETING":
+ return coredata.ThirdPartyCategoryMarketing
+ case "OFFICE_OPERATIONS":
+ return coredata.ThirdPartyCategoryOfficeOperations
+ case "OTHER":
+ return coredata.ThirdPartyCategoryOther
+ case "PASSWORD_MANAGEMENT":
+ return coredata.ThirdPartyCategoryPasswordManagement
+ case "PRODUCT_AND_DESIGN":
+ return coredata.ThirdPartyCategoryProductAndDesign
+ case "PROFESSIONAL_SERVICES":
+ return coredata.ThirdPartyCategoryProfessionalServices
+ case "RECRUITING":
+ return coredata.ThirdPartyCategoryRecruiting
+ case "SALES":
+ return coredata.ThirdPartyCategorySales
+ case "SECURITY":
+ return coredata.ThirdPartyCategorySecurity
+ case "VERSION_CONTROL":
+ return coredata.ThirdPartyCategoryVersionControl
+ default:
+ return ""
+ }
+}
diff --git a/pkg/coredata/common_tracker_pattern.go b/pkg/coredata/common_tracker_pattern.go
index 29b04881c..29a5f6ba3 100644
--- a/pkg/coredata/common_tracker_pattern.go
+++ b/pkg/coredata/common_tracker_pattern.go
@@ -344,6 +344,60 @@ LIMIT 1;
return &pattern, nil
}
+type CommonTrackerPatternSearchResult struct {
+ Pattern string `db:"pattern"`
+ Description string `db:"description"`
+ TrackerType TrackerType `db:"tracker_type"`
+ ThirdPartyName *string `db:"third_party_name"`
+ Confidence float32 `db:"confidence"`
+}
+
+func (ps *CommonTrackerPatterns) FindByKeyword(
+ ctx context.Context,
+ conn pg.Querier,
+ fragment string,
+ limit int,
+) ([]CommonTrackerPatternSearchResult, error) {
+ if limit <= 0 || limit > 20 {
+ limit = 10
+ }
+
+ q := `
+SELECT
+ ctp.pattern,
+ ctp.description,
+ ctp.tracker_type,
+ ct.name AS third_party_name,
+ ctp.confidence
+FROM
+ common_tracker_patterns ctp
+LEFT JOIN common_third_parties ct ON ct.id = ctp.common_third_party_id
+WHERE
+ ctp.pattern ILIKE '%' || @fragment || '%'
+ OR ctp.description ILIKE '%' || @fragment || '%'
+ORDER BY
+ ctp.confidence DESC
+LIMIT @limit;
+`
+
+ args := pgx.StrictNamedArgs{
+ "fragment": fragment,
+ "limit": limit,
+ }
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return nil, fmt.Errorf("cannot search common tracker patterns: %w", err)
+ }
+
+ results, err := pgx.CollectRows(rows, pgx.RowToStructByName[CommonTrackerPatternSearchResult])
+ if err != nil {
+ return nil, fmt.Errorf("cannot collect common tracker pattern search results: %w", err)
+ }
+
+ return results, nil
+}
+
func (ps *CommonTrackerPatterns) LoadByCommonThirdPartyID(
ctx context.Context,
conn pg.Querier,
diff --git a/pkg/coredata/detected_tracker.go b/pkg/coredata/detected_tracker.go
index f3e1d7975..98f713e7f 100644
--- a/pkg/coredata/detected_tracker.go
+++ b/pkg/coredata/detected_tracker.go
@@ -179,6 +179,34 @@ LIMIT 1;
return &commonThirdPartyID, nil
}
+func (dts *DetectedTrackers) LoadInitiatorDomainsByTrackerPatternID(
+ ctx context.Context,
+ conn pg.Querier,
+ trackerPatternID gid.GID,
+) ([]string, error) {
+ q := `
+SELECT DISTINCT initiator_domain
+FROM detected_trackers
+WHERE tracker_pattern_id = @tracker_pattern_id
+ AND initiator_domain IS NOT NULL
+LIMIT 5;
+`
+
+ args := pgx.StrictNamedArgs{"tracker_pattern_id": trackerPatternID}
+
+ rows, err := conn.Query(ctx, q, args)
+ if err != nil {
+ return nil, fmt.Errorf("cannot load initiator domains: %w", err)
+ }
+
+ domains, err := pgx.CollectRows(rows, pgx.RowTo[string])
+ if err != nil {
+ return nil, fmt.Errorf("cannot collect initiator domains: %w", err)
+ }
+
+ return domains, nil
+}
+
func (dts *DetectedTrackers) RelinkByTrackerPatternID(
ctx context.Context,
tx pg.Tx,
diff --git a/pkg/probod/probod.go b/pkg/probod/probod.go
index 907e1d31b..555ced30f 100644
--- a/pkg/probod/probod.go
+++ b/pkg/probod/probod.go
@@ -304,6 +304,11 @@ func (impl *Implm) Run(
return err
}
+ trackerMappingCfg, err := impl.buildTrackerMappingConfig(l, tp, r)
+ if err != nil {
+ return err
+ }
+
fileManagerService := filemanager.NewService(s3Client)
var samlCert *x509.Certificate
@@ -683,7 +688,7 @@ func (impl *Implm) Run(
},
)
- trackerMappingWorker := cookiebanner.NewTrackerMappingWorker(pgClient, l)
+ trackerMappingWorker := cookiebanner.NewTrackerMappingWorker(pgClient, l, trackerMappingCfg)
trackerMappingWorkerCtx, stopTrackerMappingWorker := context.WithCancel(context.Background())
wg.Go(
func() {
diff --git a/pkg/probod/tracker_mapping.go b/pkg/probod/tracker_mapping.go
new file mode 100644
index 000000000..d0960825d
--- /dev/null
+++ b/pkg/probod/tracker_mapping.go
@@ -0,0 +1,52 @@
+// Copyright (c) 2026 Probo Inc .
+//
+// 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.
+
+package probod
+
+import (
+ "github.com/prometheus/client_golang/prometheus"
+ "go.gearno.de/kit/log"
+ "go.opentelemetry.io/otel/trace"
+ "go.probo.inc/probo/pkg/cookiebanner"
+)
+
+// buildTrackerMappingConfig wires the tracker-mapping agent. It is opt-in:
+// deployments that do not set `llm.tracker-mapping.provider` get a zero
+// config (nil LLM client) so the worker runs without agent fallback.
+func (impl *Implm) buildTrackerMappingConfig(
+ l *log.Logger,
+ tp trace.TracerProvider,
+ r prometheus.Registerer,
+) (cookiebanner.TrackerMappingConfig, error) {
+ if impl.cfg.Agents.TrackerMapping.Provider == "" {
+ return cookiebanner.TrackerMappingConfig{}, nil
+ }
+
+ agentCfg, llmClient, err := impl.resolveAgentClient(
+ "tracker-mapping",
+ impl.cfg.Agents.TrackerMapping,
+ l,
+ tp,
+ r,
+ )
+ if err != nil {
+ return cookiebanner.TrackerMappingConfig{}, err
+ }
+
+ return cookiebanner.TrackerMappingConfig{
+ LLMClient: llmClient,
+ Model: agentCfg.ModelName,
+ SearchEndpoint: impl.cfg.SearchEndpoint,
+ }, nil
+}
diff --git a/pkg/probodconfig/llm_config.go b/pkg/probodconfig/llm_config.go
index 5612b114d..1f05020d9 100644
--- a/pkg/probodconfig/llm_config.go
+++ b/pkg/probodconfig/llm_config.go
@@ -49,6 +49,7 @@ type (
Probo LLMAgentConfig `json:"probo"`
EvidenceDescriber LLMAgentConfig `json:"evidence-describer"`
ThirdPartyAssessor LLMAgentConfig `json:"third-party-assessor"`
+ TrackerMapping LLMAgentConfig `json:"tracker-mapping"`
}
)