┌─────────────────────────────────────────────────────────────────┐
│ CHAOS OF MULTIPLE PATHS │
├──────────────────────────────┬──────────────────────────────────┤
│ Team A (Search) │ Team B (Cart) │
│ Uses satellite.track │ Uses window.digitaldata mutation │
│ │ │
│ analytics.track("event", {}) │ window.digitaldata.events = {} │
│ ↓ │ ↓ │
│ Adobe Launch Rules │ Adobe Launch Rules │
│ (Team A custom logic) │ (Team B custom logic) │
│ ↓ │ ↓ │
│ (may conflict) │ (may override) │
└──────────────────────────────┴──────────────────────────────────┘
↓ ↓
└──────────────┬───────────────┘
↓
Adobe Analytics
↓
"Why did event X break in ZA?"
"Who owns this implementation?"
"How do we add GA4 support?"
Result: Regressions, confusion, manual firefighting.
┌─────────────────────────────────────────────────────────────────┐
│ ALL PATHS THROUGH ONE DOOR │
├─────────────────────────────────────────────────────────────────┤
│ │
│ Team A Team B Team C Team D │
│ (Search) (Cart) (Checkout) (Financing) │
│ │ │ │ │ │
│ └────────────┴──────────────┴────────────┘ │
│ │ │
│ App Analytics Builder (owned by feature team) │
│ │ │
│ analytics.track() │
│ │ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ Validate Route by Policy │
│ (Schema) (satellite vs dataLayer) │
│ │ │ │
│ └──────────┬──────────┘ │
│ ↓ │
│ ┌──────────┴──────────┐ │
│ ↓ ↓ │
│ satellite.track() window.digitaldata │
│ (policy driven) (policy driven) │
│ │ │ │
│ └──────────┬──────────┘ │
│ ↓ │
│ Adobe Launch │
│ Adobe Analytics │
│ │
└─────────────────────────────────────────────────────────────────┘
Result: App teams own business meaning. SDK stays small and stable.
User Input:
──────────
"Add analytics for search filters in ZA and CA markets.
Events: apply, clear, reset.
Payload: filterId, filterValue, resultCount, market."
Agent Internal Processing:
─────────────────────────
Intent Extraction:
App: @nvc/search
Events: ["FILTER_APPLIED", "FILTER_CLEARED", "FILTER_RESET"]
Markets: ["za-en", "ca-en"]
Base Markets: ["us-en"] (default)
Payload Fields: {
filterId: string,
filterValue: string,
resultCount: number,
market: enum,
}
Agent Asks:
──────────
1. Should these events fire on every interaction or
debounced (to avoid duplicate events)?
2. Should ZA have different field names or same as US?
(e.g., "filterId" vs "filtro_id")
3. Which dispatch method for ZA?
- satellite.track (current)
- window.digitaldata injection (server-side)
- both
You Answer:
──────────
1. Debounce 500ms
2. Same field names
3. satellite.track for ZA, dataLayer for CA
File: apps/@nvc/search/analytics/contracts/search-filters.ts
// Generated by agent from your prompt + answers
import { z } from "zod";
export const FILTER_APPLIED = {
name: "SEARCH_FILTER_APPLIED",
version: "1.0.0",
schema: z.object({
filterId: z.string().min(1),
filterValue: z.string().min(1),
resultCount: z.number().int().min(0),
market: z.enum(["us-en", "za-en", "ca-en"]),
timestamp: z.number(),
}),
};
export const FILTER_CLEARED = { /* ... */ };
export const FILTER_RESET = { /* ... */ };
// Market-specific adjustments
export const MARKET_POLICY = {
"za-en": {
dispatchMethod: "satellite.track",
requiredFields: ["filterId", "filterValue", "market"],
},
"ca-en": {
dispatchMethod: "dataLayer",
requiredFields: ["filterId", "filterValue", "market"],
},
};
Versioning behavior:
File: apps/@nvc/search/components/filterPanel.tsx
// Generated by agent from contract
import { analytics } from "@analytics/sdk";
import {
FILTER_APPLIED,
FILTER_CLEARED,
FILTER_RESET,
} from "../analytics/contracts/search-filters";
let lastFilterEventTime = 0;
const DEBOUNCE_MS = 500;
export function FilterPanel({ market }) {
const fireEvent = (eventName, eventData) => {
const now = Date.now();
if (now - lastFilterEventTime < DEBOUNCE_MS) return;
lastFilterEventTime = now;
// App owns business mapping and payload creation
// SDK only validates, routes, dispatches
analytics.track(eventName, {
...eventData,
market,
timestamp: now,
});
};
const handleApply = (filter) => {
fireEvent(FILTER_APPLIED.name, {
filterId: filter.id,
filterValue: filter.value,
resultCount: getResultCount(),
});
};
const handleClear = () => {
fireEvent(FILTER_CLEARED.name, {
filterId: "all",
filterValue: "none",
resultCount: getResultCount(),
});
};
const handleReset = () => {
fireEvent(FILTER_RESET.name, {
filterId: "reset",
filterValue: "none",
resultCount: getResultCount(),
});
};
return (
<div>
<button data-removed={handleApply}>Apply Filter</button>
<button data-removed={handleClear}>Clear</button>
<button data-removed={handleReset}>Reset</button>
</div>
);
}
File: apps/@nvc/search/__tests__/filterPanel.analytics.test.tsx
// Generated by agent
import { render, fireEvent } from "@testing-library/react";
import { analytics } from "@analytics/sdk";
import {
FILTER_APPLIED,
FILTER_CLEARED,
} from "../../analytics/contracts/search-filters";
import { FilterPanel } from "../filterPanel";
jest.mock("@analytics/sdk");
describe("FilterPanel Analytics", () => {
describe("Positive Scenarios", () => {
it("should track FILTER_APPLIED event with correct payload", () => {
const { getByText } = render(<FilterPanel market="za-en" />);
fireEvent.click(getByText("Apply Filter"));
expect(analytics.track).toHaveBeenCalledWith(
FILTER_APPLIED.name,
expect.objectContaining({
filterId: expect.any(String),
filterValue: expect.any(String),
resultCount: expect.any(Number),
market: "za-en",
timestamp: expect.any(Number),
})
);
});
it("should track FILTER_CLEARED event", () => {
const { getByText } = render(<FilterPanel market="ca-en" />);
fireEvent.click(getByText("Clear"));
expect(analytics.track).toHaveBeenCalledWith(
FILTER_CLEARED.name,
expect.any(Object)
);
});
it("should respect debounce and fire only once", () => {
const { getByText } = render(<FilterPanel market="us-en" />);
fireEvent.click(getByText("Apply Filter"));
fireEvent.click(getByText("Apply Filter"));
fireEvent.click(getByText("Apply Filter"));
// Only first click fires event
expect(analytics.track).toHaveBeenCalledTimes(1);
});
});
describe("Negative Scenarios", () => {
it("should handle missing required fields gracefully", () => {
// Test that invalid payload is rejected
expect(() => {
analytics.track(FILTER_APPLIED.name, {
filterId: "",
filterValue: "",
resultCount: -1,
market: "za-en",
});
}).toThrow();
});
it("should reject unknown market", () => {
expect(() => {
analytics.track(FILTER_APPLIED.name, {
filterId: "123",
filterValue: "sedan",
resultCount: 45,
market: "xx-yy", // invalid
});
}).toThrow();
});
});
});
File: apps/@nvc/search-e2e/tests/analytics/search-filters.e2e.ts
// Generated by agent
import { test, expect } from "@playwright/test";
import type { SearchResultsPage } from "../../pageObjects/searchResultsPage";
test.describe("Search Filter Analytics", () => {
let expectedPayloads: Record<string, any> = {};
test("should emit correct payloads for filter events", async ({ page }) => {
// Intercept analytics calls
const capturedEvents: any[] = [];
page.on("console", (msg) => {
if (msg.type() === "log" && msg.text().includes("ANALYTICS_EVENT")) {
capturedEvents.push(JSON.parse(msg.text()));
}
});
// Navigate and interact
await page.goto("/search?market=za-en");
const searchPage = new SearchResultsPage(page);
// Apply filter
await searchPage.applyFilter("type", "sedan");
await page.waitForTimeout(1000);
// Verify payload
expect(capturedEvents).toContainEqual(
expect.objectContaining({
eventName: "SEARCH_FILTER_APPLIED",
payload: expect.objectContaining({
filterId: "type",
filterValue: "sedan",
market: "za-en",
}),
})
);
// Snapshot comparison
expect(capturedEvents).toMatchSnapshot(
"filter-events-za-en-snapshot.json"
);
});
});
File: packages/@analytics/config/dispatch-policy.json
{
"SEARCH_FILTER_APPLIED": {
"default": "satellite.track",
"za-en": "satellite.track",
"ca-en": "dataLayer"
},
"SEARCH_FILTER_CLEARED": {
"default": "satellite.track",
"za-en": "satellite.track",
"ca-en": "dataLayer"
},
"SEARCH_FILTER_RESET": {
"default": "satellite.track",
"za-en": "satellite.track",
"ca-en": "dataLayer"
}
}
PR Title:
────────
feat(analytics): add search filter tracking for ZA and CA markets
PR Description:
───────────────
## What Changed
- Added 3 event contracts (FILTER_APPLIED, FILTER_CLEARED, FILTER_RESET)
- Generated SDK integration in @nvc/search
- Added market-specific dispatch policies
- Generated unit tests + E2E analytics snapshot tests
## Events Added
- SEARCH_FILTER_APPLIED (v1.0.0)
- SEARCH_FILTER_CLEARED (v1.0.0)
- SEARCH_FILTER_RESET (v1.0.0)
## Markets Supported
- za-en (dispatch: satellite.track)
- ca-en (dispatch: dataLayer)
- us-en (dispatch: satellite.track, default)
## Quality Evidence
✓ All contracts pass schema validation
✓ Unit tests: 12 passed
✓ E2E analytics snapshot test: passed
✓ Type safety: no errors
✓ Lint: no issues
✓ Version compatibility check: passed
✓ Canary plan: attached
## Rollback Plan
If needed, revert commit + remove event definitions from catalog
## Test Evidence Attached
- Unit test output
- E2E snapshot comparison
- Payload samples for each market
Files Changed:
apps/@nvc/search/analytics/contracts/search-filters.ts
packages/@analytics/config/dispatch-policy.json
apps/@nvc/search/components/filterPanel.tsx
apps/@nvc/search/__tests__/filterPanel.analytics.test.tsx
apps/@nvc/search-e2e/tests/analytics/search-filters.e2e.ts
Your Review (2 minutes):
──────────────────────
✓ Check contract matches business requirement
✓ Verify markets are correct
✓ Scan code for any manual mistakes
✓ Approve
System:
──────
✓ Merge PR
✓ Deploy to dev
✓ Run production monitoring
✓ Payload quality dashboard updates
✓ Drift detection armed
Day 1: Read PV doc (30 min)
Day 2: Plan implementation (1 hour)
Day 3: Code in 3-4 places (2 hours)
Day 4: Write tests (2 hours)
Day 5: Debug cross-market issues (2 hours)
Day 6: Review and merge (1 hour)
Total: 4-5 days per event
Minute 1: Give prompt
Minute 2-5: Answer 2-3 questions
Minute 6-10: Review PR
Minute 11: Approve
Total: 10 minutes per event
Reduction: 95% less time and manual coding.
Your workflow:
Agent does: Everything else.
Result: No manual analytics coding ever again.