Skip to content

Commit 05a0358

Browse files
f-trycuaCopybara Sync
authored andcommitted
feat(growth): restore daily reporting and campaign attribution (#7832)
* feat(growth): restore core reporting and attribution * test(growth): bound cyclops receiver contract * fix(posthog): combine growth rollout scopes --------- Co-authored-by: f-trycua <195596869+f-trycua@users.noreply.github.com> CloudCyclopsCs-RevId: e8886a4b693af319d7c6f933f68df5ba48522270
1 parent 1c554ed commit 05a0358

6 files changed

Lines changed: 58 additions & 12 deletions

File tree

libs/fleet/backend/handlers/analytics_attribution.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ const (
1919
var fleetAttributionProperties = map[string]string{
2020
"campaign_id": productanalytics.FirstTouchCampaignIDProperty,
2121
"content_id": productanalytics.FirstTouchContentIDProperty,
22+
"utm_content": productanalytics.FirstTouchContentIDProperty,
2223
"utm_source": productanalytics.FirstTouchUTMSourceProperty,
2324
"utm_medium": productanalytics.FirstTouchUTMMediumProperty,
2425
"utm_campaign": productanalytics.FirstTouchUTMCampaignProperty,
@@ -102,6 +103,9 @@ func validateFleetAttribution(record fleetAttributionRecord, now time.Time) (map
102103
if !ok || !validFleetAttributionValue(value) {
103104
return nil, false
104105
}
106+
if existing, exists := setOnce[property]; exists && existing != value {
107+
return nil, false
108+
}
105109
setOnce[property] = value
106110
}
107111
return setOnce, true

libs/fleet/backend/handlers/analytics_attribution_test.go

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ func TestRecordFleetAttributionBindsExternalFirstTouch(t *testing.T) {
3333
Values: map[string]string{
3434
"utm_source": "x",
3535
"utm_medium": "organic-social",
36-
"utm_campaign": "openclaw-2-launch",
36+
"utm_campaign": "cursor-cloud-fleets",
37+
"utm_content": "thread-post-5",
3738
},
3839
}, &auth.User{ID: "subject-1", Email: "person@example.test", EmailVerified: true, AZP: "cyclops-cs-spa", PrincipalType: auth.PrincipalTypeUser})
3940
response := httptest.NewRecorder()
@@ -49,11 +50,32 @@ func TestRecordFleetAttributionBindsExternalFirstTouch(t *testing.T) {
4950
}
5051
if event.SetOnce[productanalytics.FirstTouchUTMSourceProperty] != "x" ||
5152
event.SetOnce[productanalytics.FirstTouchUTMMediumProperty] != "organic-social" ||
52-
event.SetOnce[productanalytics.FirstTouchUTMCampaignProperty] != "openclaw-2-launch" {
53+
event.SetOnce[productanalytics.FirstTouchUTMCampaignProperty] != "cursor-cloud-fleets" ||
54+
event.SetOnce[productanalytics.FirstTouchContentIDProperty] != "thread-post-5" {
5355
t.Fatalf("set once = %#v", event.SetOnce)
5456
}
5557
}
5658

59+
func TestValidateFleetAttributionContentAliases(t *testing.T) {
60+
now := time.Now()
61+
for _, values := range []map[string]string{
62+
{"content_id": "thread-post-5"},
63+
{"utm_content": "thread-post-5"},
64+
{"content_id": "thread-post-5", "utm_content": "thread-post-5"},
65+
} {
66+
setOnce, valid := validateFleetAttribution(fleetAttributionRecord{Version: 1, CapturedAt: now.UnixMilli(), Values: values}, now)
67+
if !valid || setOnce[productanalytics.FirstTouchContentIDProperty] != "thread-post-5" {
68+
t.Fatalf("values/setOnce/valid = %#v/%#v/%v", values, setOnce, valid)
69+
}
70+
}
71+
72+
if _, valid := validateFleetAttribution(fleetAttributionRecord{
73+
Version: 1, CapturedAt: now.UnixMilli(), Values: map[string]string{"content_id": "legacy", "utm_content": "standard"},
74+
}, now); valid {
75+
t.Fatal("conflicting content aliases must be rejected")
76+
}
77+
}
78+
5779
func TestRecordFleetAttributionBindsMissingEmailIdentity(t *testing.T) {
5880
capture := &analyticsCapture{}
5981
h := Handlers{Analytics: capture, AuthCfg: configAuthForAnalytics()}

libs/fleet/docs/fleet-attribution-phase1.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -4,11 +4,11 @@ This public contract defines bounded first-touch campaign binding and workload q
44

55
## Browser capture
66

7-
Before Keycloak bootstrap, the SPA captures first-touch values from the current URL: `campaign_id`, `content_id`, `utm_source`, `utm_medium`, and `utm_campaign`. Values use a conservative ASCII set (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `-`), are non-empty, and are at most 128 characters. The serialized version-1 record is limited to 2048 UTF-8 bytes and expires after 7 days. Unknown query keys are ignored; repeated allowed keys, malformed values, and oversized records are rejected. The first valid record is write-once in browser `sessionStorage` and is removed after a successful bind.
7+
Before Keycloak bootstrap, the SPA captures first-touch values from the current URL: `campaign_id`, `content_id`, `utm_source`, `utm_medium`, `utm_campaign`, and the standard `utm_content` alias. `utm_content` is stored and bound under the existing canonical `content_id` field, preserving compatibility with older `content_id` links and the `fleet_first_touch_content_id` person property. If both aliases are present they must have the same value. Values use a conservative ASCII set (`A-Z`, `a-z`, `0-9`, `.`, `_`, `~`, `-`), are non-empty, and are at most 128 characters. The serialized version-1 record is limited to 2048 UTF-8 bytes and expires after 7 days. Unknown query keys are ignored; repeated allowed keys, conflicting aliases, malformed values, and oversized records are rejected. The first valid record is write-once in browser `sessionStorage` and is removed after a successful bind.
88

9-
The five stable fields are acquisition-channel neutral. They can represent email, SEO, partners, future channels, and organic or paid social traffic without provider-specific behavior. For example, both `utm_source=x&utm_medium=organic-social` and `utm_source=x&utm_medium=paid-social` are ordinary valid values. Provider SDKs, pixels, credentials, arbitrary provider payloads, and provider-specific success semantics are outside this contract. Pixels are optional downstream adapters and are not part of Phase 1.
9+
The five canonical fields are acquisition-channel neutral. They can represent email, SEO, partners, future channels, and organic or paid social traffic without provider-specific behavior. For example, both `utm_source=x&utm_medium=organic-social` and `utm_source=x&utm_medium=paid-social` are ordinary valid values. Provider SDKs, pixels, credentials, arbitrary provider payloads, and provider-specific success semantics are outside this contract. Pixels are optional downstream adapters and are not part of Phase 1.
1010

11-
Use `content_id` for placement detail, not `utm_content` (an ignored unknown key). For the GitHub README CTA, use this capture-compatible URL:
11+
New links may use standard `utm_content`; existing `content_id` links remain supported. For the GitHub README CTA, this existing capture-compatible URL remains valid:
1212

1313
```text
1414
https://run.cua.ai/?utm_source=github&utm_medium=referral&utm_campaign=fleet_activation&content_id=repo_readme

libs/fleet/src/auth/fleet-attribution.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ export const ATTRIBUTION_MAX_VALUE_LENGTH = 128
44
export const ATTRIBUTION_MAX_RECORD_BYTES = 2048
55

66
const allowedKeys = ["campaign_id", "content_id", "utm_source", "utm_medium", "utm_campaign"] as const
7+
const queryKeys = [...allowedKeys, "utm_content"] as const
78
type AttributionKey = (typeof allowedKeys)[number]
89
export type AttributionRecord = { version: 1; capturedAt: number; values: Partial<Record<AttributionKey, string>> }
910
export interface StorageLike { getItem(key: string): string | null; setItem(key: string, value: string): void; removeItem?(key: string): void }
@@ -20,10 +21,14 @@ export function captureFleetAttribution(href: string, options: Options = {}): vo
2021
let params: URLSearchParams
2122
try { params = new URL(href).searchParams } catch { return }
2223
const values: Partial<Record<AttributionKey, string>> = {}
23-
for (const key of allowedKeys) {
24+
for (const key of queryKeys) {
2425
const all = params.getAll(key)
2526
if (all.length > 1 || (all.length === 1 && !validValue(all[0]!))) return
26-
if (all.length === 1) values[key] = all[0]!
27+
if (all.length === 1) {
28+
const canonicalKey: AttributionKey = key === "utm_content" ? "content_id" : key
29+
if (values[canonicalKey] !== undefined && values[canonicalKey] !== all[0]) return
30+
values[canonicalKey] = all[0]!
31+
}
2732
}
2833
if (Object.keys(values).length === 0) return
2934
const record: AttributionRecord = { version: 1, capturedAt: (options.now ?? Date.now)(), values }

libs/fleet/tests/fleet-attribution-doc.test.mjs

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@ test("public contract is channel-neutral and says unknown query keys are ignored
1818
assert.match(contract, /utm_source=x.*utm_medium=organic-social/s)
1919
assert.match(contract, /utm_source=x.*utm_medium=paid-social/s)
2020
assert.match(contract, /unknown query keys are ignored/i)
21+
assert.match(contract, /standard `utm_content` alias/i)
22+
assert.match(contract, /stored and bound under the existing canonical `content_id` field/i)
23+
assert.match(contract, /both aliases are present they must have the same value/i)
2124
assert.match(contract, /pixels.*optional downstream adapters/i)
2225
assert.match(contract, /keyed-HMAC pseudonym/i)
2326
assert.match(contract, /internal and unknown identities are discarded/i)

libs/fleet/tests/fleet-attribution.test.ts

Lines changed: 17 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -76,10 +76,7 @@ test("GitHub README CTA preserves all four fields through a simulated Keycloak b
7676
},
7777
}
7878

79-
for (const href of [
80-
cta,
81-
`${cta}&utm_content=ignored&email=person%40example.test&identity=raw-subject&url=https%3A%2F%2Fexample.test`,
82-
]) {
79+
for (const href of [cta, `${cta}&email=person%40example.test&identity=raw-subject&url=https%3A%2F%2Fexample.test`]) {
8380
const storage = memoryStorage()
8481
captureFleetAttribution(href, { storage, now: () => firstTouch })
8582
assert.deepEqual(JSON.parse(storage.getItem(ATTRIBUTION_STORAGE_KEY) ?? "null"), expected)
@@ -102,12 +99,25 @@ test("GitHub README CTA preserves all four fields through a simulated Keycloak b
10299
getAccessToken: async () => "access-token",
103100
})
104101

105-
// Exact records exclude the raw URL, email, identity, and unknown utm_content.
102+
// Exact records exclude the raw URL, email, identity, and unknown keys.
106103
assert.deepEqual(bound, [expected])
107104
assert.equal(storage.getItem(ATTRIBUTION_STORAGE_KEY), null)
108105
}
109106
})
110107

108+
test("standard utm_content is stored under canonical content_id without loss", () => {
109+
for (const href of [
110+
"https://run.cua.ai/?utm_source=x&utm_medium=organic-social&utm_campaign=cursor-cloud-fleets&utm_content=thread-post-5",
111+
"https://run.cua.ai/?utm_content=thread-post-5&content_id=thread-post-5",
112+
]) {
113+
const storage = memoryStorage()
114+
captureFleetAttribution(href, { storage, now: () => 50 })
115+
const record = JSON.parse(storage.getItem(ATTRIBUTION_STORAGE_KEY) ?? "null")
116+
assert.equal(record.values.content_id, "thread-post-5")
117+
assert.equal(record.values.utm_content, undefined)
118+
}
119+
})
120+
111121
test("invalid, malformed, empty, oversized, and expired records never reach a binder", async () => {
112122
const now = 1_700_000_000_000
113123
const invalidStoredRecords = [
@@ -133,6 +143,8 @@ test("capture rejects repeated, empty, invalid, and oversized values without tru
133143
const invalidQueries = [
134144
"?campaign_id=",
135145
"?campaign_id=one&campaign_id=two",
146+
"?utm_content=one&utm_content=two",
147+
"?content_id=legacy&utm_content=standard",
136148
"?campaign_id=has%20space",
137149
`?campaign_id=${"x".repeat(ATTRIBUTION_MAX_VALUE_LENGTH + 1)}`,
138150
]

0 commit comments

Comments
 (0)