From 8cb67d52c0cc56fcfe5e41bcbf8770a55898f691 Mon Sep 17 00:00:00 2001 From: "Beomhee.Lee" Date: Mon, 3 Aug 2026 16:22:51 +0900 Subject: [PATCH] =?UTF-8?q?=EB=94=94=ED=8D=BC=EB=93=9C=20=EB=94=A5?= =?UTF-8?q?=EB=A7=81=ED=81=AC=EC=97=90=EC=84=9C=20=EB=A7=A4=EC=B9=AD?= =?UTF-8?q?=EC=9D=B4=200=EC=9C=BC=EB=A1=9C=20=EB=82=98=EC=98=A4=EB=8A=94?= =?UTF-8?q?=20=EB=AC=B8=EC=A0=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/lib/fingerprint.test.ts | 32 ++++ src/lib/fingerprint.ts | 310 ++++++++++++++++++++++++++++-------- src/routes/redirect.ts | 6 +- src/routes/sdk.ts | 6 +- 4 files changed, 281 insertions(+), 73 deletions(-) diff --git a/src/lib/fingerprint.test.ts b/src/lib/fingerprint.test.ts index 89c8133..30e7c39 100644 --- a/src/lib/fingerprint.test.ts +++ b/src/lib/fingerprint.test.ts @@ -147,6 +147,38 @@ describe('calculateConfidenceScore', () => { expect(score).toBe(20); expect(matchedFactors.sort()).toEqual(['screen', 'timezone'].sort()); }); + + it('matches a mobile browser click to a native-app install on platform + device signals', () => { + const click = { + ipAddress: '100.64.0.5', + userAgent: + 'Mozilla/5.0 (iPhone; CPU iPhone OS 17_4 like Mac OS X) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/17.4 Mobile/15E148 Safari/604.1', + timezone: 'Asia/Seoul', + language: 'ko-KR', + screenWidth: 1179, + screenHeight: 2556, + platform: 'ios', + platformVersion: '17.4', + }; + + const install = { + ipAddress: '100.64.0.9', + userAgent: 'LinkFortySDK/1.4.0 (iOS 17.4; iPhone15,3)', + timezone: 'Asia/Seoul', + language: 'ko-KR', + screenWidth: 1179, + screenHeight: 2556, + platform: 'iOS', + platformVersion: '17.4', + }; + + const { score, matchedFactors } = fingerprint.calculateConfidenceScore(click, install); + + expect(score).toBe(80); + expect(matchedFactors).toEqual( + expect.arrayContaining(['user_agent', 'platform', 'timezone', 'language', 'screen']) + ); + }); }); describe('isAttributableIp', () => { diff --git a/src/lib/fingerprint.ts b/src/lib/fingerprint.ts index 4704513..07ff665 100644 --- a/src/lib/fingerprint.ts +++ b/src/lib/fingerprint.ts @@ -26,6 +26,42 @@ export interface FingerprintMatch { clickedAt: Date; } +type FingerprintFactorDebug = { + ip: { + matched: boolean; + installAttributable: boolean; + clickAttributable: boolean; + installValue: string; + clickValue: string; + }; + userAgent: { + matched: boolean; + installValue: string; + clickValue: string; + mobilePlatformBridge: boolean; + }; + platform: { + matched: boolean; + installValue: string; + clickValue: string; + }; + timezone: { + matched: boolean; + installValue: string; + clickValue: string; + }; + language: { + matched: boolean; + installValue: string; + clickValue: string; + }; + screen: { + matched: boolean; + installValue: string; + clickValue: string; + }; +}; + /** * Scoring weights for probabilistic matching * Total should equal 100 for percentage-based confidence @@ -36,6 +72,7 @@ const FINGERPRINT_WEIGHTS = { TIMEZONE: 10, LANGUAGE: 10, SCREEN_RESOLUTION: 10, + PLATFORM: 20, }; /** @@ -47,6 +84,7 @@ export const DEFAULT_ATTRIBUTION_WINDOW_HOURS = 168; * Minimum confidence threshold for attribution (70%) */ export const CONFIDENCE_THRESHOLD = 70; +const ATTRIBUTION_DEBUG_ENV = 'LINKFORTY_DEBUG_ATTRIBUTION'; /** * Generate a fingerprint hash from device data @@ -175,6 +213,157 @@ function normalizeUserAgent(ua: string): string { return `${platform}|${browser}`.toLowerCase(); } +function extractPlatformFromUserAgent(ua: string): string { + if (!ua) return ''; + + const platformMatch = ua.match(/(iPhone|iPad|iOS|Android|Windows|Macintosh|Linux)/i); + return platformMatch ? normalizePlatform(platformMatch[1]) : ''; +} + +function normalizePlatform(platform: string | undefined): string { + if (!platform) return ''; + + const normalized = platform.trim().toLowerCase(); + if (!normalized) return ''; + if (['iphone', 'ipad', 'ios'].includes(normalized)) return 'ios'; + if (normalized === 'android') return 'android'; + if (['mac', 'macos', 'macintosh'].includes(normalized)) return 'mac'; + if (['win', 'windows'].includes(normalized)) return 'windows'; + return normalized; +} + +function resolvePlatformFingerprint(data: FingerprintData): string { + return normalizePlatform(data.platform) || extractPlatformFromUserAgent(data.userAgent); +} + +function isMobilePlatform(platform: string): boolean { + return platform === 'ios' || platform === 'android'; +} + +function attributionDebugEnabled(): boolean { + const value = process.env[ATTRIBUTION_DEBUG_ENV]?.trim().toLowerCase(); + return value === '1' || value === 'true' || value === 'yes'; +} + +function logAttributionDebug(event: string, payload: Record): void { + if (!attributionDebugEnabled()) return; + console.info(`[linkforty][attribution] ${event} ${JSON.stringify(payload)}`); +} + +function scoreFingerprintMatch( + fingerprint1: FingerprintData, + fingerprint2: FingerprintData +): { score: number; matchedFactors: string[]; debug: FingerprintFactorDebug } { + let score = 0; + const matchedFactors: string[] = []; + const platform1 = resolvePlatformFingerprint(fingerprint1); + const platform2 = resolvePlatformFingerprint(fingerprint2); + const installIpAttributable = isAttributableIp(fingerprint1.ipAddress); + const clickIpAttributable = isAttributableIp(fingerprint2.ipAddress); + const ip1 = fingerprint1.ipAddress ? normalizeIP(fingerprint1.ipAddress) : ''; + const ip2 = fingerprint2.ipAddress ? normalizeIP(fingerprint2.ipAddress) : ''; + const ua1 = fingerprint1.userAgent ? normalizeUserAgent(fingerprint1.userAgent) : ''; + const ua2 = fingerprint2.userAgent ? normalizeUserAgent(fingerprint2.userAgent) : ''; + const mobilePlatformBridge = platform1 !== '' && platform1 === platform2 && isMobilePlatform(platform1); + const language1 = fingerprint1.language?.substring(0, 2).toLowerCase() || ''; + const language2 = fingerprint2.language?.substring(0, 2).toLowerCase() || ''; + const screen1 = + fingerprint1.screenWidth && fingerprint1.screenHeight + ? `${fingerprint1.screenWidth}x${fingerprint1.screenHeight}` + : ''; + const screen2 = + fingerprint2.screenWidth && fingerprint2.screenHeight + ? `${fingerprint2.screenWidth}x${fingerprint2.screenHeight}` + : ''; + + const debug: FingerprintFactorDebug = { + ip: { + matched: false, + installAttributable: installIpAttributable, + clickAttributable: clickIpAttributable, + installValue: ip1, + clickValue: ip2, + }, + userAgent: { + matched: false, + installValue: ua1, + clickValue: ua2, + mobilePlatformBridge, + }, + platform: { + matched: false, + installValue: platform1, + clickValue: platform2, + }, + timezone: { + matched: false, + installValue: fingerprint1.timezone || '', + clickValue: fingerprint2.timezone || '', + }, + language: { + matched: false, + installValue: language1, + clickValue: language2, + }, + screen: { + matched: false, + installValue: screen1, + clickValue: screen2, + }, + }; + + if ( + fingerprint1.ipAddress && + fingerprint2.ipAddress && + installIpAttributable && + clickIpAttributable && + ip1 === ip2 + ) { + score += FINGERPRINT_WEIGHTS.IP_ADDRESS; + matchedFactors.push('ip'); + debug.ip.matched = true; + } + + if (fingerprint1.userAgent && fingerprint2.userAgent && (ua1 === ua2 || mobilePlatformBridge)) { + score += FINGERPRINT_WEIGHTS.USER_AGENT; + matchedFactors.push('user_agent'); + debug.userAgent.matched = true; + } + + if (mobilePlatformBridge) { + score += FINGERPRINT_WEIGHTS.PLATFORM; + matchedFactors.push('platform'); + debug.platform.matched = true; + } + + if (fingerprint1.timezone && fingerprint2.timezone && fingerprint1.timezone === fingerprint2.timezone) { + score += FINGERPRINT_WEIGHTS.TIMEZONE; + matchedFactors.push('timezone'); + debug.timezone.matched = true; + } + + if (language1 !== '' && language1 === language2) { + score += FINGERPRINT_WEIGHTS.LANGUAGE; + matchedFactors.push('language'); + debug.language.matched = true; + } + + if ( + fingerprint1.screenWidth && + fingerprint1.screenHeight && + fingerprint2.screenWidth && + fingerprint2.screenHeight && + fingerprint1.screenWidth === fingerprint2.screenWidth && + fingerprint1.screenHeight === fingerprint2.screenHeight + ) { + score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION; + matchedFactors.push('screen'); + debug.screen.matched = true; + } + + return { score: Math.min(score, 100), matchedFactors, debug }; +} + /** * Calculate confidence score by comparing two fingerprints * Returns a score from 0-100 based on matched components @@ -183,74 +372,7 @@ export function calculateConfidenceScore( fingerprint1: FingerprintData, fingerprint2: FingerprintData ): { score: number; matchedFactors: string[] } { - let score = 0; - const matchedFactors: string[] = []; - - // Compare IP addresses (normalized to /24 subnet for IPv4). Only count IPs - // that actually identify a device — shared/NAT ranges (CGNAT, RFC1918, etc.) - // are skipped so unrelated users behind the same NAT can't match on IP. - if ( - fingerprint1.ipAddress && - fingerprint2.ipAddress && - isAttributableIp(fingerprint1.ipAddress) && - isAttributableIp(fingerprint2.ipAddress) - ) { - const ip1 = normalizeIP(fingerprint1.ipAddress); - const ip2 = normalizeIP(fingerprint2.ipAddress); - - if (ip1 === ip2) { - score += FINGERPRINT_WEIGHTS.IP_ADDRESS; - matchedFactors.push('ip'); - } - } - - // Compare user agents (normalized to platform + browser) - if (fingerprint1.userAgent && fingerprint2.userAgent) { - const ua1 = normalizeUserAgent(fingerprint1.userAgent); - const ua2 = normalizeUserAgent(fingerprint2.userAgent); - - if (ua1 === ua2) { - score += FINGERPRINT_WEIGHTS.USER_AGENT; - matchedFactors.push('user_agent'); - } - } - - // Compare timezone - if (fingerprint1.timezone && fingerprint2.timezone) { - if (fingerprint1.timezone === fingerprint2.timezone) { - score += FINGERPRINT_WEIGHTS.TIMEZONE; - matchedFactors.push('timezone'); - } - } - - // Compare language - if (fingerprint1.language && fingerprint2.language) { - // Match first 2 characters (e.g., "en-US" matches "en-GB") - const lang1 = fingerprint1.language.substring(0, 2).toLowerCase(); - const lang2 = fingerprint2.language.substring(0, 2).toLowerCase(); - - if (lang1 === lang2) { - score += FINGERPRINT_WEIGHTS.LANGUAGE; - matchedFactors.push('language'); - } - } - - // Compare screen resolution - if ( - fingerprint1.screenWidth && - fingerprint1.screenHeight && - fingerprint2.screenWidth && - fingerprint2.screenHeight - ) { - if ( - fingerprint1.screenWidth === fingerprint2.screenWidth && - fingerprint1.screenHeight === fingerprint2.screenHeight - ) { - score += FINGERPRINT_WEIGHTS.SCREEN_RESOLUTION; - matchedFactors.push('screen'); - } - } - + const { score, matchedFactors } = scoreFingerprintMatch(fingerprint1, fingerprint2); return { score, matchedFactors }; } @@ -324,11 +446,38 @@ export async function matchInstallToClick( platformVersion: row.platform_version, }; - const { score, matchedFactors } = calculateConfidenceScore( + const { score, matchedFactors, debug } = scoreFingerprintMatch( installFingerprint, clickFingerprint ); + logAttributionDebug('candidate_scored', { + clickId: row.click_id, + linkId: row.link_id, + score, + threshold: CONFIDENCE_THRESHOLD, + matchedFactors, + timeDiffHours, + attributionWindowHours: linkWindowHours, + installFingerprint: { + ipAddress: installFingerprint.ipAddress, + platform: installFingerprint.platform || null, + timezone: installFingerprint.timezone || null, + language: installFingerprint.language || null, + screenWidth: installFingerprint.screenWidth || null, + screenHeight: installFingerprint.screenHeight || null, + }, + clickFingerprint: { + ipAddress: clickFingerprint.ipAddress, + platform: clickFingerprint.platform || null, + timezone: clickFingerprint.timezone || null, + language: clickFingerprint.language || null, + screenWidth: clickFingerprint.screenWidth || null, + screenHeight: clickFingerprint.screenHeight || null, + }, + factors: debug, + }); + // Track the best match if (score > highestScore && score >= CONFIDENCE_THRESHOLD) { highestScore = score; @@ -342,6 +491,29 @@ export async function matchInstallToClick( } } + logAttributionDebug('install_match_result', { + matched: bestMatch !== null, + highestScore, + threshold: CONFIDENCE_THRESHOLD, + installFingerprint: { + ipAddress: installFingerprint.ipAddress, + platform: installFingerprint.platform || null, + timezone: installFingerprint.timezone || null, + language: installFingerprint.language || null, + screenWidth: installFingerprint.screenWidth || null, + screenHeight: installFingerprint.screenHeight || null, + }, + bestMatch: bestMatch + ? { + clickId: bestMatch.clickId, + linkId: bestMatch.linkId, + confidenceScore: bestMatch.confidenceScore, + matchedFactors: bestMatch.matchedFactors, + clickedAt: bestMatch.clickedAt.toISOString(), + } + : null, + }); + return bestMatch; } diff --git a/src/routes/redirect.ts b/src/routes/redirect.ts index 5b05632..d4e556b 100644 --- a/src/routes/redirect.ts +++ b/src/routes/redirect.ts @@ -287,6 +287,8 @@ export async function redirectRoutes(fastify: FastifyInstance) { const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined; const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined; const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined; + const fpPlatform = query?.fp_platform || deviceType; + const fpPlatformVersion = query?.fp_pv || platformVersion; // Insert click event with the pre-generated id (see above) so the row // matches the lf_click value already placed on the redirect URL. @@ -327,8 +329,8 @@ export async function redirectRoutes(fastify: FastifyInstance) { language: fpLanguage, screenWidth: fpScreenWidth, screenHeight: fpScreenHeight, - platform: deviceType, - platformVersion, + platform: fpPlatform, + platformVersion: fpPlatformVersion, }; await storeFingerprintForClick(clickId, fingerprintData); diff --git a/src/routes/sdk.ts b/src/routes/sdk.ts index 27e7880..42b9f81 100644 --- a/src/routes/sdk.ts +++ b/src/routes/sdk.ts @@ -609,6 +609,8 @@ export async function sdkRoutes(fastify: FastifyInstance) { const fpLanguage = query?.fp_lang || acceptLanguage.split(',')[0]?.split(';')[0] || undefined; const fpScreenWidth = query?.fp_sw ? parseInt(query.fp_sw, 10) : undefined; const fpScreenHeight = query?.fp_sh ? parseInt(query.fp_sh, 10) : undefined; + const fpPlatform = query?.fp_platform || deviceType; + const fpPlatformVersion = query?.fp_pv || platformVersion; // Insert click event const clickResult = await db.query( @@ -650,8 +652,8 @@ export async function sdkRoutes(fastify: FastifyInstance) { language: fpLanguage, screenWidth: fpScreenWidth, screenHeight: fpScreenHeight, - platform: deviceType, - platformVersion, + platform: fpPlatform, + platformVersion: fpPlatformVersion, }; await storeFingerprintForClick(clickId, fingerprintData);