-
Notifications
You must be signed in to change notification settings - Fork 176
feat: track captcha analytics #7822
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
base: develop
Are you sure you want to change the base?
Changes from all commits
4daa045
00cd8f2
024868d
50a137e
e675478
60b2a0f
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -1,20 +1,23 @@ | ||
| import { useAtom, useAtomValue } from 'jotai' | ||
| import { ReactNode, useEffect, useRef, useState } from 'react' | ||
|
|
||
| import { createCowTracker } from '@cowprotocol/analytics' | ||
| import { useTheme } from '@cowprotocol/common-hooks' | ||
| import { getJwtTtl } from '@cowprotocol/common-utils' | ||
|
|
||
| import { Turnstile, type TurnstileInstance } from '@marsidev/react-turnstile' | ||
| import { setBearerToken } from 'cowSdk' | ||
| import { captchaJwtAtom } from 'entities/captcha/state/captchaJwtAtom' | ||
|
|
||
| import { CowSwapAnalyticsCategory } from 'common/analytics/types' | ||
| import { featureFlagsAtom } from 'common/state/featureFlagsState' | ||
|
|
||
| import { exchangeTurnstileToken } from '../api/captchaApi' | ||
| import { TURNSTILE_SITE_KEY } from '../config/captcha.const' | ||
| import { TURNSTILE_DEMO_INTERACTIVE_SITE_KEY, TURNSTILE_SITE_KEY } from '../config/captcha.const' | ||
| import { useCaptchaDebugControls } from '../hooks/useCaptchaDebugControls' | ||
| import { logCaptcha } from '../logger' | ||
|
|
||
| /* eslint-disable max-lines-per-function */ | ||
| export function CaptchaWidget(): ReactNode { | ||
| const [captchaJwt, setCaptchaJwt] = useAtom(captchaJwtAtom) | ||
| const { isCaptchaEnabled } = useAtomValue(featureFlagsAtom) | ||
|
|
@@ -23,6 +26,10 @@ export function CaptchaWidget(): ReactNode { | |
| const [siteKey, setSiteKey] = useState(TURNSTILE_SITE_KEY) | ||
| const theme = useTheme() | ||
|
|
||
| const trackCaptcha = createCowTracker(CowSwapAnalyticsCategory.CAPTCHA, { | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
|
||
| enabled: siteKey !== TURNSTILE_DEMO_INTERACTIVE_SITE_KEY, | ||
| }) | ||
|
|
||
| useEffect(() => { | ||
| if (!isCaptchaEnabled) { | ||
| logCaptcha.debug('Disabled by feature flag') | ||
|
|
@@ -82,13 +89,16 @@ export function CaptchaWidget(): ReactNode { | |
| }} | ||
| onWidgetLoad={(widgetId) => { | ||
| logCaptcha.debug('Challenge starting', { widgetId }) | ||
| trackCaptcha({ action: 'captcha_challenge_started' }) | ||
| captchaRef.current?.execute() | ||
| }} | ||
| onBeforeInteractive={() => { | ||
| logCaptcha.debug('Challenge requires interaction') | ||
| trackCaptcha({ action: 'captcha_interaction_required' }) | ||
| }} | ||
| onAfterInteractive={() => { | ||
| logCaptcha.debug('Challenge interaction completed') | ||
| trackCaptcha({ action: 'captcha_interaction_completed' }) | ||
| }} | ||
| onSuccess={async (token: string) => { | ||
| const requestId = exchangeRequestIdRef.current + 1 | ||
|
|
@@ -107,30 +117,37 @@ export function CaptchaWidget(): ReactNode { | |
| } | ||
|
|
||
| logCaptcha.info('JWT received', { requestId }) | ||
| trackCaptcha({ action: 'captcha_challenge_solved' }) | ||
| setCaptchaJwt(jwt) | ||
| } catch (error) { | ||
| if (exchangeRequestIdRef.current !== requestId) { | ||
| return | ||
| } | ||
|
|
||
| logCaptcha.error('JWT exchange failed', { requestId, error }) | ||
| trackCaptcha({ action: 'captcha_challenge_failed', reason: 'jwtExchangeFailed' }) | ||
| setCaptchaJwt(null) | ||
| } | ||
| }} | ||
| onExpire={() => { | ||
| exchangeRequestIdRef.current += 1 | ||
|
|
||
| logCaptcha.warn('Challenge expired') | ||
| trackCaptcha({ action: 'captcha_challenge_failed', reason: 'turnstileExpired' }) | ||
| setCaptchaJwt(null) | ||
| logCaptcha.debug('Challenge re-starting') | ||
| captchaRef.current?.reset() | ||
| }} | ||
| onError={(errorCode) => { | ||
| exchangeRequestIdRef.current += 1 | ||
|
|
||
| logCaptcha.error('Challenge errored', { errorCode, hostname: window.location.hostname }) | ||
| trackCaptcha({ action: 'captcha_challenge_failed', reason: 'turnstileError' }) | ||
| setCaptchaJwt(null) | ||
| }} | ||
| onUnsupported={() => { | ||
| logCaptcha.warn('Challenge unsupported by browser') | ||
| trackCaptcha({ action: 'captcha_challenge_failed', reason: 'browserUnsupported' }) | ||
| }} | ||
| /> | ||
| ) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -67,6 +67,8 @@ const csp = buildCsp([ | |
| ['upgrade-insecure-requests', []], | ||
| ]) | ||
|
|
||
| const crossOriginOpenerPolicy = process.env.VERCEL_ENV === 'production' ? 'same-origin-allow-popups' : 'unsafe-none' | ||
|
|
||
| // --------------------------------------------------------------------------- | ||
| // Vercel config | ||
| // --------------------------------------------------------------------------- | ||
|
|
@@ -87,9 +89,8 @@ export const config: VercelConfig = { | |
| routes.header('/(.*)', [ | ||
| // Controls which resources the browser is allowed to load; prevents XSS and data injection attacks. | ||
| { key: 'Content-Security-Policy', value: csp }, | ||
| // Isolates the browsing context so cross-origin pages cannot access window.opener, | ||
| // while still allowing this page to open popups (needed for wallet connections). | ||
| { key: 'Cross-Origin-Opener-Policy', value: 'same-origin-allow-popups' }, | ||
| // Preview deploys use unsafe-none so Tag Assistant can keep its cross-origin debug connection. | ||
|
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. It might be a bit dangerous in case if something depends on Cross-Origin-Opener-Policy and we don't notice it locally because it's |
||
| { key: 'Cross-Origin-Opener-Policy', value: crossOriginOpenerPolicy }, | ||
| // Allows any origin to load this page as an iframe, required for the embeddable widget. | ||
| { key: 'Cross-Origin-Resource-Policy', value: 'cross-origin' }, | ||
| // Prevents browsers from MIME-sniffing a response away from the declared Content-Type. | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| import { getCowAnalytics } from './utils' | ||
|
|
||
| import type { EventOptions } from './CowAnalytics' | ||
|
|
||
| type CowTrackerEvent = Omit<EventOptions, 'category'> & Record<string, unknown> | ||
| type CowTracker = (event: CowTrackerEvent) => void | ||
|
|
||
| type CowTrackerOptions = { | ||
| enabled?: boolean | ||
| } | ||
|
|
||
| export function createCowTracker(category: string, options: CowTrackerOptions = {}): CowTracker { | ||
| const { enabled = true } = options | ||
|
|
||
| return (event) => { | ||
| if (!enabled) return | ||
|
|
||
| getCowAnalytics()?.sendEvent({ category, ...event }) | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🗄️ Data Integrity & Integration | 🟡 Minor | ⚡ Quick win
as GtmEventcast bypasseschainIdtype mismatch.TrackAffiliateEventParams.chainIdis typedSupportedChainId | number, butEventOptions.chainIdexpectsSupportedChainId. The cast silently widens invalid numeric values through to the analytics payload. Either narrow the parameter type or validate at the boundary.🛡️ Narrow chainId type instead of casting
interface TrackAffiliateEventParams { analytics: CowAnalytics action: AffiliateAnalyticsAction - chainId?: SupportedChainId | number + chainId?: SupportedChainId [key: string]: unknown }If raw
numbersupport is intentional, validate before sending:export function trackAffiliateEvent({ analytics, action, chainId, ...customParams }: TrackAffiliateEventParams): void { + if (chainId !== undefined && !Object.values(SupportedChainId).includes(chainId as SupportedChainId)) { + return + } analytics.sendEvent({ category: CowSwapAnalyticsCategory.AFFILIATE, action, chainId, ...customParams, } as GtmEvent<CowSwapAnalyticsCategory.AFFILIATE>) }🤖 Prompt for AI Agents