Surface common tracker pattern link across APIs

Expose the existing tracker_patterns.common_tracker_pattern_id foreign
key on the TrackerPattern type so it is possible to tell whether a
pattern is linked to the global common-tracker catalog (its description
likely came from the seed or the mapping/enrichment agents) or has no
link (added manually or inherited). This is a read-only debugging aid
for agent-generated descriptions; no migration or write path changes.

The field is added in sync across all four API surfaces (GraphQL, MCP,
CLI, n8n) plus the console UI, and covered by e2e assertions for both
the linked and unlinked cases.

Signed-off-by: Émile Ré <emile@probo.com>
This commit is contained in:
Émile Ré
2026-06-02 17:31:54 +02:00
parent 9ac71f948f
commit aebb2a1ed0
12 changed files with 208 additions and 53 deletions

View File

@@ -30,6 +30,7 @@ const trackerPatternPropertiesSectionFragment = graphql`
excluded excluded
detectedCount detectedCount
lastMatchedAt lastMatchedAt
commonTrackerPatternId
cookieCategory { cookieCategory {
name name
} }
@@ -105,6 +106,16 @@ export function TrackerPatternPropertiesSection({
<span className="text-sm">{pattern.description}</span> <span className="text-sm">{pattern.description}</span>
</PropertyRow> </PropertyRow>
)} )}
<PropertyRow label={__("Description source")}>
{pattern.commonTrackerPatternId
? (
<div className="flex items-center gap-2">
<Badge variant="info">{__("Common catalog")}</Badge>
<span className="font-mono text-xs text-txt-tertiary">{pattern.commonTrackerPatternId}</span>
</div>
)
: <Badge variant="neutral">{__("Manual")}</Badge>}
</PropertyRow>
<PropertyRow label={__("Excluded")}> <PropertyRow label={__("Excluded")}>
<span className="text-sm">{pattern.excluded ? __("Yes") : __("No")}</span> <span className="text-sm">{pattern.excluded ? __("Yes") : __("No")}</span>
</PropertyRow> </PropertyRow>

View File

@@ -48,6 +48,7 @@ const trackerPatternFragment = graphql`
maxAgeSeconds maxAgeSeconds
excluded excluded
lastMatchedAt lastMatchedAt
commonTrackerPatternId
cookieCategory { cookieCategory {
id id
name name
@@ -287,6 +288,7 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
const typeBadge = getTrackerTypeBadge(pattern.trackerType, __); const typeBadge = getTrackerTypeBadge(pattern.trackerType, __);
const srcBadge = pattern.source ? getTrackerSourceBadge(pattern.source, __) : null; const srcBadge = pattern.source ? getTrackerSourceBadge(pattern.source, __) : null;
const commonTrackerPatternId = pattern.commonTrackerPatternId;
return ( return (
<Tr to={pattern.id} className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}> <Tr to={pattern.id} className={pattern.excluded ? "bg-txt-quaternary opacity-80 line-through" : undefined}>
@@ -294,8 +296,15 @@ export function TrackerPatternRow({ patternKey, connectionId }: TrackerPatternRo
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge> <Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
</Td> </Td>
<Td> <Td>
<div className="flex flex-col min-w-0 max-w-xs"> <div className="flex flex-col min-w-0 max-w-xs gap-1">
<span className={pattern.excluded ? undefined : "font-medium"}>{pattern.displayName}</span> <span className={pattern.excluded ? undefined : "font-medium"}>{pattern.displayName}</span>
{commonTrackerPatternId
? (
<span className="text-xs font-mono text-txt-tertiary truncate" title={commonTrackerPatternId}>
{commonTrackerPatternId}
</span>
)
: <span className="text-xs text-txt-tertiary">{__("Manual")}</span>}
{pattern.description && ( {pattern.description && (
<span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1"> <span className="text-xs text-txt-tertiary wrap-break-word line-clamp-1">
{pattern.description} {pattern.description}

View File

@@ -15,12 +15,16 @@
package console_test package console_test
import ( import (
"context"
"testing" "testing"
"time"
"github.com/stretchr/testify/assert" "github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require" "github.com/stretchr/testify/require"
"go.probo.inc/probo/e2e/internal/factory" "go.probo.inc/probo/e2e/internal/factory"
"go.probo.inc/probo/e2e/internal/testutil" "go.probo.inc/probo/e2e/internal/testutil"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
) )
func TestTrackerPattern_Create(t *testing.T) { func TestTrackerPattern_Create(t *testing.T) {
@@ -45,6 +49,7 @@ func TestTrackerPattern_Create(t *testing.T) {
displayName displayName
maxAgeSeconds maxAgeSeconds
description description
commonTrackerPatternId
createdAt createdAt
updatedAt updatedAt
} }
@@ -60,15 +65,16 @@ func TestTrackerPattern_Create(t *testing.T) {
CreateTrackerPattern struct { CreateTrackerPattern struct {
TrackerPatternEdge struct { TrackerPatternEdge struct {
Node struct { Node struct {
ID string `json:"id"` ID string `json:"id"`
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
MatchType string `json:"matchType"` MatchType string `json:"matchType"`
TrackerType string `json:"trackerType"` TrackerType string `json:"trackerType"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
MaxAgeSeconds *int `json:"maxAgeSeconds"` MaxAgeSeconds *int `json:"maxAgeSeconds"`
Description string `json:"description"` Description string `json:"description"`
CreatedAt string `json:"createdAt"` CommonTrackerPatternID *string `json:"commonTrackerPatternId"`
UpdatedAt string `json:"updatedAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
} `json:"node"` } `json:"node"`
} `json:"trackerPatternEdge"` } `json:"trackerPatternEdge"`
CookieBanner struct { CookieBanner struct {
@@ -101,6 +107,7 @@ func TestTrackerPattern_Create(t *testing.T) {
require.NotNil(t, node.MaxAgeSeconds) require.NotNil(t, node.MaxAgeSeconds)
assert.Equal(t, maxAge, *node.MaxAgeSeconds) assert.Equal(t, maxAge, *node.MaxAgeSeconds)
assert.Equal(t, "Google Analytics tracking cookie", node.Description) assert.Equal(t, "Google Analytics tracking cookie", node.Description)
assert.Nil(t, node.CommonTrackerPatternID, "a manually created pattern is not linked to the common catalog")
assert.Equal(t, bannerID, result.CreateTrackerPattern.CookieBanner.ID) assert.Equal(t, bannerID, result.CreateTrackerPattern.CookieBanner.ID)
}) })
@@ -607,6 +614,93 @@ func TestTrackerPattern_List(t *testing.T) {
}) })
} }
func TestTrackerPattern_CommonTrackerPatternID(t *testing.T) {
t.Parallel()
t.Run("reflects the common catalog link", func(t *testing.T) {
t.Parallel()
owner := testutil.NewClient(t, testutil.RoleOwner)
bannerID := factory.CreateCookieBanner(owner)
categoryID := factory.CreateCookieCategory(owner, bannerID)
patternID := factory.CreateTrackerPattern(owner, categoryID)
const query = `
query($id: ID!) {
node(id: $id) {
... on TrackerPattern {
id
commonTrackerPatternId
}
}
}
`
var result struct {
Node struct {
ID string `json:"id"`
CommonTrackerPatternID *string `json:"commonTrackerPatternId"`
} `json:"node"`
}
require.NoError(t, owner.Execute(query, map[string]any{"id": patternID}, &result))
assert.Nil(t, result.Node.CommonTrackerPatternID, "a freshly created pattern has no catalog link")
commonID := seedCommonTrackerPattern(t)
linkTrackerPatternToCommon(t, patternID, commonID)
require.NoError(t, owner.Execute(query, map[string]any{"id": patternID}, &result))
require.NotNil(t, result.Node.CommonTrackerPatternID, "the catalog link must surface once set")
assert.Equal(t, commonID.String(), *result.Node.CommonTrackerPatternID)
})
}
func seedCommonTrackerPattern(t *testing.T) gid.GID {
t.Helper()
ctx := context.Background()
conn := dialTestPg(t, ctx)
t.Cleanup(func() { _ = conn.Close(ctx) })
id := gid.New(gid.NilTenant, coredata.CommonTrackerPatternEntityType)
now := time.Now().UTC()
_, err := conn.Exec(ctx, `
INSERT INTO common_tracker_patterns (
id, tracker_type, pattern, match_type, description, confidence, created_at, updated_at
) VALUES (
$1, $2, $3, $4, $5, $6, $7, $8
)
`, id, "COOKIE", "e2e_common_"+id.String(), "EXACT", "Seeded catalog description", 1.0, now, now)
require.NoError(t, err)
t.Cleanup(func() {
cleanupCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
cleanupConn := dialTestPg(t, cleanupCtx)
defer func() { _ = cleanupConn.Close(cleanupCtx) }()
_, err := cleanupConn.Exec(cleanupCtx, `DELETE FROM common_tracker_patterns WHERE id = $1`, id)
assert.NoError(t, err, "cleanup: cannot delete seeded common tracker pattern %s", id)
})
return id
}
func linkTrackerPatternToCommon(t *testing.T, patternID string, commonID gid.GID) {
t.Helper()
ctx := context.Background()
conn := dialTestPg(t, ctx)
t.Cleanup(func() { _ = conn.Close(ctx) })
_, err := conn.Exec(ctx, `
UPDATE tracker_patterns SET common_tracker_pattern_id = $1 WHERE id = $2
`, commonID, patternID)
require.NoError(t, err)
}
func TestTrackerPattern_RBAC(t *testing.T) { func TestTrackerPattern_RBAC(t *testing.T) {
t.Parallel() t.Parallel()

View File

@@ -4,6 +4,10 @@ All notable changes to the `@probo/n8n-nodes-probo` package will be documented i
## Unreleased ## Unreleased
### Added
- Expose `commonTrackerPatternId` on tracker pattern `get`/`getAll` operations to indicate whether a pattern is linked to the common tracker catalog
## [0.191.0] - 2026-06-02 ## [0.191.0] - 2026-06-02
### Added ### Added

View File

@@ -51,6 +51,7 @@ export async function execute(
source source
excluded excluded
lastMatchedAt lastMatchedAt
commonTrackerPatternId
createdAt createdAt
updatedAt updatedAt
} }

View File

@@ -86,6 +86,7 @@ export async function execute(
source source
excluded excluded
lastMatchedAt lastMatchedAt
commonTrackerPatternId
createdAt createdAt
updatedAt updatedAt
} }

View File

@@ -40,6 +40,7 @@ query($id: ID!, $first: Int, $after: CursorKey) {
source source
excluded excluded
lastMatchedAt lastMatchedAt
commonTrackerPatternId
} }
} }
pageInfo { pageInfo {
@@ -53,14 +54,15 @@ query($id: ID!, $first: Int, $after: CursorKey) {
` `
type trackerPattern struct { type trackerPattern struct {
ID string `json:"id"` ID string `json:"id"`
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
MatchType string `json:"matchType"` MatchType string `json:"matchType"`
TrackerType string `json:"trackerType"` TrackerType string `json:"trackerType"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
Source *string `json:"source"` Source *string `json:"source"`
Excluded bool `json:"excluded"` Excluded bool `json:"excluded"`
LastMatchedAt *string `json:"lastMatchedAt"` LastMatchedAt *string `json:"lastMatchedAt"`
CommonTrackerPatternID *string `json:"commonTrackerPatternId"`
} }
func NewCmdList(f *cmdutil.Factory) *cobra.Command { func NewCmdList(f *cmdutil.Factory) *cobra.Command {
@@ -157,10 +159,15 @@ func NewCmdList(f *cmdutil.Factory) *cobra.Command {
lastMatched = cmdutil.FormatTime(*p.LastMatchedAt) lastMatched = cmdutil.FormatTime(*p.LastMatchedAt)
} }
rows = append(rows, []string{p.ID, p.Pattern, p.MatchType, p.TrackerType, p.DisplayName, source, excluded, lastMatched}) commonPatternID := ""
if p.CommonTrackerPatternID != nil {
commonPatternID = *p.CommonTrackerPatternID
}
rows = append(rows, []string{p.ID, p.Pattern, p.MatchType, p.TrackerType, p.DisplayName, source, excluded, lastMatched, commonPatternID})
} }
t := cmdutil.NewTable("ID", "PATTERN", "MATCH TYPE", "TRACKER TYPE", "DISPLAY NAME", "SOURCE", "EXCLUDED", "LAST MATCHED").Rows(rows...) t := cmdutil.NewTable("ID", "PATTERN", "MATCH TYPE", "TRACKER TYPE", "DISPLAY NAME", "SOURCE", "EXCLUDED", "LAST MATCHED", "COMMON PATTERN ID").Rows(rows...)
_, _ = fmt.Fprintln(f.IOStreams.Out, t) _, _ = fmt.Fprintln(f.IOStreams.Out, t)
if totalCount > len(patterns) { if totalCount > len(patterns) {

View File

@@ -39,6 +39,7 @@ query($id: ID!) {
source source
excluded excluded
lastMatchedAt lastMatchedAt
commonTrackerPatternId
createdAt createdAt
updatedAt updatedAt
} }
@@ -48,19 +49,20 @@ query($id: ID!) {
type viewResponse struct { type viewResponse struct {
Node *struct { Node *struct {
Typename string `json:"__typename"` Typename string `json:"__typename"`
ID string `json:"id"` ID string `json:"id"`
Pattern string `json:"pattern"` Pattern string `json:"pattern"`
MatchType string `json:"matchType"` MatchType string `json:"matchType"`
TrackerType string `json:"trackerType"` TrackerType string `json:"trackerType"`
DisplayName string `json:"displayName"` DisplayName string `json:"displayName"`
MaxAgeSeconds *int `json:"maxAgeSeconds"` MaxAgeSeconds *int `json:"maxAgeSeconds"`
Description *string `json:"description"` Description *string `json:"description"`
Source string `json:"source"` Source string `json:"source"`
Excluded bool `json:"excluded"` Excluded bool `json:"excluded"`
LastMatchedAt *string `json:"lastMatchedAt"` LastMatchedAt *string `json:"lastMatchedAt"`
CreatedAt string `json:"createdAt"` CommonTrackerPatternID *string `json:"commonTrackerPatternId"`
UpdatedAt string `json:"updatedAt"` CreatedAt string `json:"createdAt"`
UpdatedAt string `json:"updatedAt"`
} `json:"node"` } `json:"node"`
} }
@@ -138,6 +140,12 @@ func NewCmdView(f *cmdutil.Factory) *cobra.Command {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Last Matched:"), cmdutil.FormatTime(*v.LastMatchedAt)) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Last Matched:"), cmdutil.FormatTime(*v.LastMatchedAt))
} }
if v.CommonTrackerPatternID != nil && *v.CommonTrackerPatternID != "" {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Common Pattern:"), *v.CommonTrackerPatternID)
} else {
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Origin:"), "Manual (no catalog link)")
}
_, _ = fmt.Fprintln(out) _, _ = fmt.Fprintln(out)
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(v.CreatedAt)) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Created:"), cmdutil.FormatTime(v.CreatedAt))
_, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(v.UpdatedAt)) _, _ = fmt.Fprintf(out, "%s%s\n", label.Render("Updated:"), cmdutil.FormatTime(v.UpdatedAt))

View File

@@ -313,6 +313,16 @@ type TrackerPattern implements Node
""" """
commonThirdParty: CommonThirdParty @goField(forceResolver: true) commonThirdParty: CommonThirdParty @goField(forceResolver: true)
"""
The common tracker-pattern catalog entry this pattern is linked to,
if any. Non-null means the pattern was matched to the global catalog
(so its description may originate from the Open Cookie Database seed
or the mapping/enrichment agents); null means no catalog link
(description was added manually or inherited). Exposed primarily for
debugging agent-generated descriptions.
"""
commonTrackerPatternId: ID
detectedTrackers( detectedTrackers(
first: Int first: Int
after: CursorKey after: CursorKey

View File

@@ -31,10 +31,14 @@ type (
// (cookieCategory, detectedTrackers, thirdParty, commonThirdParty, // (cookieCategory, detectedTrackers, thirdParty, commonThirdParty,
// detectedCount, permission) are populated by the resolver. // detectedCount, permission) are populated by the resolver.
// //
// ThirdPartyID and CommonTrackerPatternID are not exposed in // ThirdPartyID is not exposed in GraphQL — it is a foreign-key
// GraphQL — they are foreign-key handles the resolver uses to load // handle the resolver uses to load the linked org-scoped third
// the linked third party (org-scoped or via the common catalog) // party without re-querying coredata.
// without re-querying coredata. //
// CommonTrackerPatternID is exposed directly as the
// commonTrackerPatternId field: a non-null value indicates the
// pattern is linked to the common tracker-pattern catalog, which is
// used to debug the provenance of agent-generated descriptions.
TrackerPattern struct { TrackerPattern struct {
ID gid.GID `json:"id"` ID gid.GID `json:"id"`
TrackerType coredata.TrackerType `json:"trackerType"` TrackerType coredata.TrackerType `json:"trackerType"`
@@ -55,7 +59,7 @@ type (
Permission bool `json:"permission"` Permission bool `json:"permission"`
ThirdPartyID *gid.GID `json:"-"` ThirdPartyID *gid.GID `json:"-"`
CommonTrackerPatternID *gid.GID `json:"-"` CommonTrackerPatternID *gid.GID `json:"commonTrackerPatternId,omitempty"`
} }
TrackerPatternConnection struct { TrackerPatternConnection struct {

View File

@@ -9560,6 +9560,11 @@ components:
- "null" - "null"
format: date-time format: date-time
description: Timestamp when a cookie last matched this pattern description: Timestamp when a cookie last matched this pattern
common_tracker_pattern_id:
anyOf:
- $ref: "#/components/schemas/GID"
- type: "null"
description: Linked common tracker-pattern catalog ID, if any. Non-null indicates the pattern was matched to the global catalog (description may originate from the seed or agents); null means no catalog link.
created_at: created_at:
type: string type: string
format: date-time format: date-time

View File

@@ -26,21 +26,22 @@ func NewTrackerPattern(p *coredata.TrackerPattern) *TrackerPattern {
} }
return &TrackerPattern{ return &TrackerPattern{
ID: p.ID, ID: p.ID,
OrganizationID: p.OrganizationID, OrganizationID: p.OrganizationID,
CookieBannerID: p.CookieBannerID, CookieBannerID: p.CookieBannerID,
CookieCategoryID: p.CookieCategoryID, CookieCategoryID: p.CookieCategoryID,
TrackerType: TrackerPatternTrackerType(p.TrackerType), TrackerType: TrackerPatternTrackerType(p.TrackerType),
Pattern: p.Pattern, Pattern: p.Pattern,
MatchType: TrackerPatternMatchType(p.MatchType), MatchType: TrackerPatternMatchType(p.MatchType),
DisplayName: p.DisplayName, DisplayName: p.DisplayName,
MaxAgeSeconds: p.MaxAgeSeconds, MaxAgeSeconds: p.MaxAgeSeconds,
Description: p.Description, Description: p.Description,
Source: source, Source: source,
Excluded: p.Excluded, Excluded: p.Excluded,
LastMatchedAt: p.LastMatchedAt, LastMatchedAt: p.LastMatchedAt,
CreatedAt: p.CreatedAt, CommonTrackerPatternID: p.CommonTrackerPatternID,
UpdatedAt: p.UpdatedAt, CreatedAt: p.CreatedAt,
UpdatedAt: p.UpdatedAt,
} }
} }