Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 7 additions & 3 deletions docs/operations/signup-attribution.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,14 @@ Which channel brings AnythingMCP Cloud sign-ups, and which of them verify and pa

## How it is recorded

1. **anythingmcp.com** notes, per visitor, the first and the last *touch*: UTM tags, Google Ads' `gad_source` / `gad_campaignid`, `paid` (a gclid, gbraid or wbraid was on the URL; the id itself is never kept), the referrer's host and the landing path. Kept in memory for the visit, and in localStorage for 30 days only once the visitor has allowed analytics in the cookie banner.
1. **anythingmcp.com** notes, per visitor, the first and the last *touch*: UTM tags, Google Ads' `gad_source` / `gad_campaignid`, `paid` (a gclid, gbraid or wbraid was on the URL), the referrer's host and the landing path. The click id itself travels only with `ad_consent: "granted"` (the visitor allowed marketing cookies). Kept in memory for the visit, and in localStorage for 30 days only once the visitor has allowed analytics in the cookie banner.
2. When the visitor clicks through to `cloud.anythingmcp.com`, the link gets `amcp_src=<base64url JSON first touch>` and, if different, `amcp_lt=<last touch>`.
3. **The cloud sign-up page** reads those, or, for a visitor who came straight to the cloud app, builds its own touch from its URL and the referrer's host (`captured_on: "cloud"`). It sends them with `POST /api/auth/register` as `attribution: { first_touch, last_touch }`.
4. **The backend** sanitizes them again and, for a **newly created account only**, writes a `signup_attributed` row to `product_events` (`user_id`, `organization_id` = the workspace created at sign-up). An address that already has an account records nothing, and the answer to the sign-up is the same either way.
3. **The cloud sign-up page** reads those, or, for a visitor who came straight to the cloud app, builds its own touch from its URL and the referrer's host (`captured_on: "cloud"`). For its own touch, a click id is kept only if the cloud's cookie banner has the *marketing* category accepted; if that banner says no, click ids handed over by the site are dropped too. It sends them with `POST /api/auth/register` as `attribution: { first_touch, last_touch }`.
4. **The backend** sanitizes them again and, for a **newly created account only**, writes a `signup_attributed` row to `product_events` (`user_id`, `organization_id` = the workspace created at sign-up). A click id (`gclid`, `gbraid`, `wbraid`: `[A-Za-z0-9_-]`, at most 150 characters) is stored only on a touch with `ad_consent: "granted"` and dropped otherwise. An address that already has an account records nothing, and the answer to the sign-up is the same either way.

## Purchases as Google Ads offline conversions

`GET /api/auth/attribution/click-ids` (signed in, cloud only; `{}` on self-hosted) returns the caller's own click id from their `signup_attributed` row, e.g. `{ "gclid": "…", "ad_consent": "granted", "captured_at": "…" }`: one id (last touch before first; gclid before gbraid before wbraid), and only if it was stored with consent. The cloud app appends it to its links to the pricing page (`?return_url=…&gclid=…`); the pricing page puts it into the Stripe checkout metadata and the site's Stripe webhook uploads the purchase to Google Ads.

Stored metadata (the channel is derived on the server, see `packages/backend/src/audit/signup-attribution.ts`):

Expand Down
3 changes: 2 additions & 1 deletion packages/backend/src/audit/audit.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { AuditController } from './audit.controller';
import { SecurityEventService } from './security-event.service';
import { ProductEventService } from './product-event.service';
import { ProductEventController } from './product-event.controller';
import { SignupAttributionController } from './signup-attribution.controller';

@Global()
@Module({
controllers: [AuditController, ProductEventController],
controllers: [AuditController, ProductEventController, SignupAttributionController],
providers: [AuditService, SecurityEventService, ProductEventService],
exports: [AuditService, SecurityEventService, ProductEventService],
})
Expand Down
26 changes: 23 additions & 3 deletions packages/backend/src/audit/product-event.service.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { Injectable, Logger } from '@nestjs/common';
import { PrismaService } from '../common/prisma.service';
import { sanitizeSignupAttribution } from './signup-attribution';
import { AttributionClickId, clickIdFromAttribution, sanitizeSignupAttribution } from './signup-attribution';

/**
* Product-usage events the UI reports so the activation funnel can be read
Expand Down Expand Up @@ -48,8 +48,8 @@ const CLIENT_REPORTABLE = new Set<string>(
Object.values(ProductEvents).filter((e) => !SERVER_ONLY.has(e)),
);
const MAX_METADATA_BYTES = 1024;
/** Two touches of up to eleven capped fields each. */
const MAX_ATTRIBUTION_BYTES = 4096;
/** Two touches of up to fifteen capped fields each, three of them click ids of up to 150 chars. */
const MAX_ATTRIBUTION_BYTES = 5120;

@Injectable()
export class ProductEventService {
Expand Down Expand Up @@ -86,6 +86,26 @@ export class ProductEventService {
this.logger.warn(`product event ${input.event} not recorded: ${err?.message ?? err}`);
}
}

/**
* The Google Ads click id this user signed up through, if they granted ad
* consent: read from their own `signup_attributed` event only, keyed by the
* user id alone. Null when there is none.
*/
async clickIdForUser(userId: string | null | undefined): Promise<AttributionClickId | null> {
if (!userId) return null;
const rows = await this.prisma.productEvent.findMany({
where: { userId, event: ProductEvents.SIGNUP_ATTRIBUTED },
orderBy: { createdAt: 'desc' },
take: 5,
select: { metadata: true },
});
for (const row of rows) {
const found = clickIdFromAttribution(row.metadata);
if (found) return found;
}
return null;
}
}

/**
Expand Down
121 changes: 121 additions & 0 deletions packages/backend/src/audit/signup-attribution.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
import { GUARDS_METADATA } from '@nestjs/common/constants';
import { SignupAttributionController } from './signup-attribution.controller';
import { ProductEventService, ProductEvents } from './product-event.service';

/**
* GET /api/auth/attribution/click-ids: the caller's own click id, stored with
* ad consent, on AnythingMCP Cloud only.
*/

type Row = { event: string; userId: string | null; organizationId: string | null; createdAt: Date; metadata: unknown };

const ROWS: Row[] = [
{
event: ProductEvents.SIGNUP_ATTRIBUTED,
userId: 'alice',
organizationId: 'org-a',
createdAt: new Date('2026-09-20T10:00:00Z'),
metadata: {
first_touch: { utm_source: 'google', gclid: 'alice-gclid', ad_consent: 'granted', paid: true, channel: 'google_ads' },
first_channel: 'google_ads',
},
},
{
// Same organization, another user: never Alice's answer.
event: ProductEvents.SIGNUP_ATTRIBUTED,
userId: 'bob',
organizationId: 'org-a',
createdAt: new Date('2026-09-25T10:00:00Z'),
metadata: { first_touch: { gclid: 'bob-gclid', ad_consent: 'granted', channel: 'google_ads' } },
},
{
// A client-reportable event that happens to carry a click id-shaped field.
event: ProductEvents.MCP_URL_COPIED,
userId: 'alice',
organizationId: 'org-a',
createdAt: new Date('2026-09-26T10:00:00Z'),
metadata: { first_touch: { gclid: 'forged', ad_consent: 'granted' } },
},
{
event: ProductEvents.SIGNUP_ATTRIBUTED,
userId: 'carol',
organizationId: 'org-c',
createdAt: new Date('2026-09-21T10:00:00Z'),
// Stored without consent (or before the rule existed): no id comes back.
metadata: { first_touch: { gclid: 'carol-gclid', ad_consent: 'denied', channel: 'google_ads' } },
},
{
event: ProductEvents.SIGNUP_ATTRIBUTED,
userId: 'dave',
organizationId: 'org-d',
createdAt: new Date('2026-09-22T10:00:00Z'),
metadata: { first_touch: { referrer_host: 'github.com', channel: 'github' } },
},
];

function fakePrisma() {
const findMany = jest.fn(async ({ where, orderBy, take }: any) => {
let rows = ROWS.filter(
(r) => Object.entries(where).every(([k, v]) => (r as Record<string, unknown>)[k] === v),
);
if (orderBy?.createdAt === 'desc') rows = [...rows].sort((a, b) => +b.createdAt - +a.createdAt);
return rows.slice(0, take ?? rows.length).map((r) => ({ metadata: r.metadata }));
});
return { prisma: { productEvent: { findMany } }, findMany };
}

function makeController(mode: 'cloud' | 'self-hosted') {
const { prisma, findMany } = fakePrisma();
const controller = new SignupAttributionController(new ProductEventService(prisma as any), {
isCloud: () => mode === 'cloud',
} as any);
return { controller, findMany };
}

const asUser = (sub: string, organizationId = 'org-a') => ({ user: { sub, organizationId } });

describe('SignupAttributionController — GET click-ids', () => {
it('sits behind the JWT guard', () => {
const guards = Reflect.getMetadata(GUARDS_METADATA, SignupAttributionController) ?? [];
expect(guards).toHaveLength(1);
});

it("returns the caller's own click id and consent, nothing else", async () => {
const { controller, findMany } = makeController('cloud');
await expect(controller.clickIds(asUser('alice'))).resolves.toEqual({
gclid: 'alice-gclid',
ad_consent: 'granted',
});
// Keyed by the session's user id and the server-written event only.
expect(findMany).toHaveBeenCalledWith(
expect.objectContaining({ where: { userId: 'alice', event: ProductEvents.SIGNUP_ATTRIBUTED } }),
);
});

it("never answers with another user's click id, even in the same organization", async () => {
const { controller } = makeController('cloud');
const bob = await controller.clickIds(asUser('bob'));
expect(bob).toEqual({ gclid: 'bob-gclid', ad_consent: 'granted' });
expect(JSON.stringify(bob)).not.toContain('alice');
await expect(controller.clickIds(asUser('eve', 'org-a'))).resolves.toEqual({});
});

it('returns nothing for an id stored without granted consent, or no id at all', async () => {
const { controller } = makeController('cloud');
await expect(controller.clickIds(asUser('carol', 'org-c'))).resolves.toEqual({});
await expect(controller.clickIds(asUser('dave', 'org-d'))).resolves.toEqual({});
});

it('returns nothing without a user id, and does not query', async () => {
const { controller, findMany } = makeController('cloud');
await expect(controller.clickIds({ user: {} })).resolves.toEqual({});
await expect(controller.clickIds({})).resolves.toEqual({});
expect(findMany).not.toHaveBeenCalled();
});

it('is empty on a self-hosted instance, and does not query', async () => {
const { controller, findMany } = makeController('self-hosted');
await expect(controller.clickIds(asUser('alice'))).resolves.toEqual({});
expect(findMany).not.toHaveBeenCalled();
});
});
36 changes: 36 additions & 0 deletions packages/backend/src/audit/signup-attribution.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
import { Controller, Get, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { AuthGuard } from '@nestjs/passport';
import { DeploymentService } from '../common/deployment.service';
import { ProductEventService } from './product-event.service';
import { AttributionClickId } from './signup-attribution';

/**
* GET /api/auth/attribution/click-ids — the signed-in user's own Google Ads
* click id, so the cloud app can pass it on to the pricing page and a
* purchase can be uploaded to Google Ads as an offline conversion.
*
* Only ever the caller's own sign-up (the user id comes from the session,
* never from the request), only an id stored with ad consent granted, and
* only on AnythingMCP Cloud: a self-hosted instance records no attribution
* and answers an empty object.
*/
@ApiTags('Auth')
@ApiBearerAuth()
@UseGuards(AuthGuard('jwt'))
@Controller('api/auth/attribution')
export class SignupAttributionController {
constructor(
private readonly events: ProductEventService,
private readonly deployment: DeploymentService,
) {}

@Get('click-ids')
@ApiOperation({
summary: "The signed-in user's own sign-up click id, if it was stored with ad consent (cloud only)",
})
async clickIds(@Req() req: any): Promise<AttributionClickId | Record<string, never>> {
if (!this.deployment.isCloud()) return {};
return (await this.events.clickIdForUser(req.user?.sub)) ?? {};
}
}
91 changes: 89 additions & 2 deletions packages/backend/src/audit/signup-attribution.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { classifyTouch, sanitizeSignupAttribution } from './signup-attribution';
import { classifyTouch, clickIdFromAttribution, sanitizeSignupAttribution } from './signup-attribution';

describe('classifyTouch', () => {
it.each([
Expand Down Expand Up @@ -47,9 +47,96 @@ describe('sanitizeSignupAttribution', () => {

it('is idempotent: sanitizing stored metadata changes nothing', () => {
const once = sanitizeSignupAttribution(
{ first_touch: { utm_source: 'google', paid: true, ts: NOW - 1000 }, last_touch: { referrer_host: 'github.com' } },
{
first_touch: { utm_source: 'google', paid: true, ts: NOW - 1000, gclid: 'Cj0-a_b', ad_consent: 'granted' },
last_touch: { referrer_host: 'github.com', ad_consent: 'denied' },
},
NOW,
);
expect(once?.first_touch?.gclid).toBe('Cj0-a_b');
expect(sanitizeSignupAttribution(once, NOW)).toEqual(once);
});
});

describe('click ids and ad consent', () => {
const NOW = Date.parse('2026-09-27T10:00:00Z');
const IDS = { gclid: 'Cj0KCQjw-abc_DEF', gbraid: 'Gb-1_x', wbraid: 'Wb_2-y' };

it('keeps click ids only when the touch says ad consent was granted', () => {
const out = sanitizeSignupAttribution({ first_touch: { ...IDS, ad_consent: 'granted' } }, NOW);
expect(out?.first_touch).toEqual({ ...IDS, ad_consent: 'granted', paid: true, channel: 'google_ads' });
});

it.each([['denied'], ['unknown'], [undefined], ['GRANTED'], [true]])(
'drops them when ad consent is %p',
(adConsent) => {
const out = sanitizeSignupAttribution(
{ first_touch: { utm_source: 'google', ...IDS, ad_consent: adConsent } },
NOW,
);
expect(out?.first_touch?.gclid).toBeUndefined();
expect(out?.first_touch?.gbraid).toBeUndefined();
expect(out?.first_touch?.wbraid).toBeUndefined();
expect(JSON.stringify(out)).not.toContain('Cj0KCQ');
},
);

it('decides per touch: consent on the first touch does not carry over to the last', () => {
const out = sanitizeSignupAttribution(
{
first_touch: { gclid: 'first-id', ad_consent: 'granted' },
last_touch: { gclid: 'last-id', utm_source: 'google' },
},
NOW,
);
expect(out?.first_touch?.gclid).toBe('first-id');
expect(out?.last_touch).toEqual({ utm_source: 'google', channel: 'referral' });
});

it.each([
['a dot', 'Cj0.KCQ'],
['a space', 'Cj0 KCQ'],
['an address', 'jane@example.com'],
['a query string', 'abc&utm_source=x'],
['151 characters', 'a'.repeat(151)],
['an empty string', ' '],
])('drops a click id with %s even with consent', (_label, gclid) => {
const out = sanitizeSignupAttribution({ first_touch: { gclid, ad_consent: 'granted', utm_source: 'google' } }, NOW);
expect(out?.first_touch?.gclid).toBeUndefined();
expect(out?.first_touch?.paid).toBeUndefined();
});

it('keeps a 150-character click id', () => {
const gclid = 'a'.repeat(150);
const out = sanitizeSignupAttribution({ first_touch: { gclid, ad_consent: 'granted' } }, NOW);
expect(out?.first_touch?.gclid).toBe(gclid);
});
});

describe('clickIdFromAttribution', () => {
it('prefers the last touch, and returns a single id: gclid before gbraid before wbraid', () => {
expect(
clickIdFromAttribution({
first_touch: { gclid: 'first', ad_consent: 'granted', ts: '2026-09-20T10:00:00.000Z' },
last_touch: { wbraid: 'w-last', gbraid: 'g-last', ad_consent: 'granted', ts: '2026-09-21T10:00:00.000Z' },
}),
).toEqual({ gbraid: 'g-last', ad_consent: 'granted', captured_at: '2026-09-21T10:00:00.000Z' });
});

it('falls back to the first touch when the last carries none', () => {
expect(
clickIdFromAttribution({
first_touch: { gclid: 'first', ad_consent: 'granted' },
last_touch: { referrer_host: 'chatgpt.com' },
}),
).toEqual({ gclid: 'first', ad_consent: 'granted' });
});

it('never returns an id from a stored touch without granted consent', () => {
expect(clickIdFromAttribution({ first_touch: { gclid: 'legacy-row' } })).toBeNull();
expect(clickIdFromAttribution({ last_touch: { gclid: 'x', ad_consent: 'denied' } })).toBeNull();
expect(clickIdFromAttribution({ first_touch: { gclid: 'bad.id', ad_consent: 'granted' } })).toBeNull();
expect(clickIdFromAttribution(null)).toBeNull();
expect(clickIdFromAttribution([{ gclid: 'x', ad_consent: 'granted' }])).toBeNull();
});
});
Loading
Loading