Repository navigation
feat(attribution): track agentic signup and workflow outcomes #8558
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Closed
Closed
Changes from all commits
Commits
Show all changes
3 commits
Select commit
Hold shift + click to select a range
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { sealFreebuffAttribution, storeFreebuffHandoff } from '@/lib/analytics/freebuff-agentic' | ||
| import { | ||
| type FreebuffHandoffBody, | ||
| freebuffHandoffContract, | ||
| } from '@/lib/api/contracts/freebuff-attribution' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { enforceIpRateLimit } from '@/lib/core/rate-limiter' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
|
|
||
| /** Public device-flow initialization; holds no account authority and never issues credentials. */ | ||
| export const POST = withRouteHandler(async (request: NextRequest) => { | ||
| const limited = await enforceIpRateLimit('freebuff-handoff', request) | ||
| if (limited) return limited | ||
| const parsed = await parseRequest(freebuffHandoffContract, request, {}) | ||
| if (!parsed.success) return parsed.response | ||
| try { | ||
| const body: FreebuffHandoffBody = parsed.data.body | ||
| const sealed = await sealFreebuffAttribution(body.conversionToken) | ||
| await storeFreebuffHandoff(body.request, body.challenge, sealed) | ||
| return new NextResponse(null, { status: 204, headers: { 'Cache-Control': 'no-store' } }) | ||
| } catch { | ||
| return NextResponse.json({ error: 'Attribution handoff unavailable' }, { status: 503 }) | ||
| } | ||
| }) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,97 @@ | ||
| import { createLogger } from '@sim/logger' | ||
| import { type NextRequest, NextResponse } from 'next/server' | ||
| import { | ||
| FREEBUFF_AGENTIC_COOKIE, | ||
| FREEBUFF_ATTRIBUTION_TTL_SECONDS, | ||
| readFreebuffAttribution, | ||
| scopeFreebuffAttribution, | ||
| sealFreebuffAttribution, | ||
| } from '@/lib/analytics/freebuff-agentic' | ||
| import { | ||
| type FreebuffLandingQuery, | ||
| freebuffLandingContract, | ||
| } from '@/lib/api/contracts/freebuff-attribution' | ||
| import { parseRequest } from '@/lib/api/server' | ||
| import { getSession } from '@/lib/auth' | ||
| import { enforceIpRateLimit } from '@/lib/core/rate-limiter' | ||
| import { getBaseUrl } from '@/lib/core/utils/urls' | ||
| import { withRouteHandler } from '@/lib/core/utils/with-route-handler' | ||
| import { bindAccountAttribution } from '@/lib/users/application/attribution' | ||
|
|
||
| const logger = createLogger('FreebuffLanding') | ||
|
|
||
| /** Capture before rendering any page or loading analytics; the redirect URL never carries bfcid. */ | ||
| export const GET = withRouteHandler(async (request: NextRequest) => { | ||
| const destination = new URL('/signup', getBaseUrl()) | ||
| let sealed = request.cookies.get(FREEBUFF_AGENTIC_COOKIE)?.value | ||
| let clearCookie = false | ||
| let captureFailed = false | ||
| const limited = await enforceIpRateLimit('freebuff-landing', request) | ||
| if (limited) { | ||
| limited.headers.set('Cache-Control', 'no-store') | ||
| limited.headers.set('Referrer-Policy', 'no-referrer') | ||
| return limited | ||
| } | ||
| const parsed = await parseRequest(freebuffLandingContract, request, {}) | ||
| if (parsed.success) { | ||
| const query: FreebuffLandingQuery = parsed.data.query | ||
| try { | ||
| const session = await getSession() | ||
| if (query.request && query.challenge && query.pairing) { | ||
| destination.pathname = '/cli/auth' | ||
| destination.search = new URLSearchParams({ | ||
| request: query.request, | ||
| challenge: query.challenge, | ||
| pairing: query.pairing, | ||
| scope: 'platform', | ||
| ...(query.workspace ? { workspace: query.workspace } : {}), | ||
| }).toString() | ||
| } else if (query.bfcid) { | ||
| sealed = await sealFreebuffAttribution(query.bfcid) | ||
| } | ||
| const captured = await readFreebuffAttribution(sealed) | ||
| if (captured) { | ||
| if (session?.user.id && sealed) { | ||
| if ( | ||
| !('impersonatedBy' in session.session && session.session.impersonatedBy) && | ||
| (!captured.boundUserId || captured.boundUserId === session.user.id) | ||
| ) { | ||
| sealed = await scopeFreebuffAttribution(sealed, session.user.id) | ||
| await bindAccountAttribution.execute({ | ||
| principal: { | ||
| kind: 'session', | ||
| userId: session.user.id, | ||
| sessionId: session.session.id, | ||
| }, | ||
| input: { sealed }, | ||
| }) | ||
| } | ||
| sealed = undefined | ||
| clearCookie = true | ||
| } | ||
| } else { | ||
| sealed = undefined | ||
| } | ||
| } catch { | ||
| logger.warn('Attribution capture unavailable') | ||
| captureFailed = true | ||
| } | ||
| } | ||
| const response = captureFailed | ||
| ? NextResponse.json( | ||
| { error: 'Attribution capture unavailable; retry this request' }, | ||
| { status: 503, headers: { 'Retry-After': '5' } } | ||
| ) | ||
| : NextResponse.redirect(destination, 303) | ||
| response.headers.set('Cache-Control', 'no-store') | ||
| response.headers.set('Referrer-Policy', 'no-referrer') | ||
| if (sealed || clearCookie) | ||
| response.cookies.set(FREEBUFF_AGENTIC_COOKIE, sealed ?? '', { | ||
| httpOnly: true, | ||
| secure: true, | ||
| sameSite: 'lax', | ||
| path: '/', | ||
| maxAge: clearCookie ? 0 : FREEBUFF_ATTRIBUTION_TTL_SECONDS, | ||
| }) | ||
| return response | ||
| }) | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| # Freebuff agentic attribution | ||
|
|
||
| This flow implements https://freebuff.com/docs/advertisers/agentic-attribution and is separate from the existing display conversion API. | ||
|
|
||
| ## Rollout | ||
|
|
||
| 1. Deploy the additive attribution migration and application together through the normal pipeline. The old application ignores the new table. | ||
| 2. Publish the CLI containing this change. The proposed command-scoped carrier is `SIM_FREEBUFF_CONVERSION_TOKEN`; it is supported only for `sim login --method api-key`. It is removed from the CLI environment before browser launch and is never written into CLI configuration. The browser URL only opens the existing approval page; attribution is claimed by the authenticated approval action, pinned to that account, and deleted from Redis only after persistence succeeds. | ||
| 3. Have Freebuff review the exact runtime carrier and login procedure before placing it into a sponsored procedure. This repository change does not approve or alter an existing reviewed procedure or consent hash. | ||
| 4. For browser-only handoffs, use `/api/attribution/freebuff` as the landing destination and let Freebuff append its signed `bfcid`. This dedicated endpoint captures the opaque token and redirects to signup before client scripts run. Do not send agentic tokens through the display tag's `bfcid` cookie. | ||
| 5. Verify the canonical HTTPS app origin before activation. The CLI sends tokens only to `https://www.sim.ai`, with redirects disabled. The browser cookie is Secure, HttpOnly, SameSite=Lax, host-only, and encrypted. | ||
| 6. Obtain a Freebuff-issued signed test token. Exercise the real new-account and returning-account flows, complete a deployed workflow run, verify accepted and deduplicated postbacks, and confirm test classification and zero charge with Freebuff. Then verify a real accepted-proposal handoff; a generic test token cannot prove the proposal/run join. | ||
|
|
||
| No advertiser API key is used by the agentic endpoint. Existing display signup reporting stays separate and still requires its configured server key. | ||
|
|
||
| ## Outcomes and delivery | ||
|
|
||
| - `account_created:<user-id>` records a new human account created after token capture. Returning users retain attribution for product use without becoming new signups. | ||
| - `tool_used:<execution-id>` records completed deployed workflow executions by their execution actor. Failed, pending, cancelled, and undeployed preview executions are excluded. Each execution can be reported once; recurring executions have distinct IDs. | ||
| - Attribution is scoped to the human actor, not every member of their workspace. Latest captured attribution wins; replaying an older cookie cannot replace it. | ||
| - The outbox stores encrypted token snapshots with original event IDs and timestamps. One request is sent per lease, with at most three attempts. Network, 429, and 5xx failures retry; other HTTP failures are terminal. Delivery status and HTTP status remain available in the outbox payload. No raw response or token is logged. | ||
| - The association expires 30 days after capture; Freebuff enforces its authoritative 30-day window after Accept. Expiry cleanup removes the stored association. Terminal deliveries erase the encrypted token from their outbox payload. | ||
| - Authentication attribution failures emit a token-free warning without blocking sign-in. Failed signed-in browser captures retain an account-bound encrypted cookie and return a retryable response. Workflow completion isolates attribution writes in a savepoint; on failure it commits a durable marker with the completed log. The outbox worker drains that indexed marker queue in bounded batches, atomically enqueuing events and clearing their markers. | ||
|
|
||
| ## Verification | ||
|
|
||
| `bun run test:integration freebuff-agentic completion-ledger-order` uses disposable PostgreSQL and Redis and writes `apps/sim/test-results/integration.json`. It verifies encryption, expiry, token-free redirects, authenticated CLI approval, auth hooks, billed completion during attribution failure, recovery, idempotency, retry identity, and terminal rejection. The auth session in the landing fixture is simulated; this is not evidence that deployed OAuth callbacks or the Freebuff partner handoff have been verified. | ||
|
|
||
| Do not enable the broad v2 site pixel as part of this rollout. It is a separate telemetry decision and is unnecessary for these server events. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.