Skip to content

Commit bf1b345

Browse files
BrtqKrSikoraKam
andauthored
Universal links analytics handler for sdk (#56)
* feat:universal links analytics handler for sdk * feat:add check flag * fix:listener condition check * fix:remove import * refactor: removed unused function --------- Co-authored-by: Kamil Sikora <kamil.sikora@swmansion.com>
1 parent 42d492a commit bf1b345

3 files changed

Lines changed: 47 additions & 66 deletions

File tree

packages/react-native-detour/src/expo-router/nativeIntent.ts

Lines changed: 8 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import { OPENED_VIA_UNIVERSAL_LINK } from "../analytics/const/definedEvents";
2-
import { analyticsEmitter } from "../analytics/utils/analyticsEmitter";
3-
import { checkClickLimit } from "../links/api/checkClickLimit";
41
import { resolveShortLink } from "../links/api/resolveShortLink";
2+
import { sendUniversalLinkClick } from "../links/api/sendUniversalLinkClick";
53
import type { Config } from "../links/types";
64
import { getRouteFromDeepLink } from "../links/utils/urlHelpers";
75

@@ -289,28 +287,23 @@ export const createDetourNativeIntentHandler = (
289287
return path;
290288
}
291289

292-
analyticsEmitter.emit({
293-
eventName: OPENED_VIA_UNIVERSAL_LINK,
294-
data: { url: path },
295-
});
296-
297290
if (!options.config) {
298291
return fallbackPath;
299292
}
300293

301-
const clickLimitStatus = await checkClickLimit({
294+
const clickResult = await sendUniversalLinkClick({
302295
apiKey: options.config.apiKey,
303296
appID: options.config.appID,
297+
url: url.toString(),
304298
});
305299

306-
if (!clickLimitStatus.allowed) {
300+
if (!clickResult.allowed) {
307301
console.error("🔗[Detour:CLICK_LIMIT_ERROR] Native-intent routing blocked:", {
308302
path,
309-
status: clickLimitStatus.status,
310-
error: clickLimitStatus.error,
311-
code: clickLimitStatus.code,
312-
clicksInPeriod: clickLimitStatus.clicksInPeriod,
313-
effectiveLimit: clickLimitStatus.effectiveLimit,
303+
error: clickResult.error,
304+
code: clickResult.code,
305+
clicksInPeriod: clickResult.clicksInPeriod,
306+
effectiveLimit: clickResult.effectiveLimit,
314307
});
315308
return fallbackPath;
316309
}

packages/react-native-detour/src/links/api/checkClickLimit.ts renamed to packages/react-native-detour/src/links/api/sendUniversalLinkClick.ts

Lines changed: 18 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,40 +1,35 @@
11
import { SDK_HEADER_VALUE } from "../../version";
22
import type { RequiredConfig } from "../types";
33

4-
const API_URL = "https://godetour.dev/api/link/click-limit";
4+
const API_URL = "https://godetour.dev/api/link/universal-link-click";
55

6-
type ClickLimitResponseBody = {
6+
type UniversalLinkClickResponseBody = {
77
allowed?: boolean;
88
error?: string;
99
code?: string;
1010
clicksInPeriod?: number;
1111
effectiveLimit?: number;
12+
remainingClicks?: number;
13+
clickId?: string | null;
1214
};
1315

14-
export type ClickLimitCheckResult =
15-
| {
16-
allowed: true;
17-
}
16+
export type UniversalLinkClickResult =
17+
| { allowed: true; clickId: string | null }
1818
| {
1919
allowed: false;
20-
status: number;
2120
error: string;
2221
code?: string;
2322
clicksInPeriod?: number;
2423
effectiveLimit?: number;
2524
};
2625

27-
const getFallbackErrorMessage = (status: number) => {
28-
if (status === 402) {
29-
return "Click limit exceeded";
30-
}
31-
return "Click limit check failed";
32-
};
33-
34-
export const checkClickLimit = async ({
26+
export const sendUniversalLinkClick = async ({
3527
apiKey: API_KEY,
3628
appID,
37-
}: Pick<RequiredConfig, "apiKey" | "appID">): Promise<ClickLimitCheckResult> => {
29+
url,
30+
}: Pick<RequiredConfig, "apiKey" | "appID"> & {
31+
url: string;
32+
}): Promise<UniversalLinkClickResult> => {
3833
try {
3934
const response = await fetch(API_URL, {
4035
method: "POST",
@@ -44,11 +39,12 @@ export const checkClickLimit = async ({
4439
"X-App-ID": appID,
4540
"X-SDK": SDK_HEADER_VALUE,
4641
},
42+
body: JSON.stringify({ url, timestamp: Date.now() }),
4743
});
4844

49-
let body: ClickLimitResponseBody | null = null;
45+
let body: UniversalLinkClickResponseBody | null = null;
5046
try {
51-
body = (await response.json()) as ClickLimitResponseBody;
47+
body = (await response.json()) as UniversalLinkClickResponseBody;
5248
} catch {
5349
body = null;
5450
}
@@ -57,8 +53,7 @@ export const checkClickLimit = async ({
5753
if (isExplicitDeny) {
5854
return {
5955
allowed: false,
60-
status: response.status,
61-
error: body?.error || getFallbackErrorMessage(response.status),
56+
error: body?.error ?? "Click limit exceeded",
6257
code: body?.code,
6358
clicksInPeriod: body?.clicksInPeriod,
6459
effectiveLimit: body?.effectiveLimit,
@@ -67,12 +62,12 @@ export const checkClickLimit = async ({
6762

6863
if (!response.ok) {
6964
// Fail-open for temporary backend/network issues so apps keep working.
70-
return { allowed: true };
65+
return { allowed: true, clickId: null };
7166
}
7267

73-
return { allowed: true };
68+
return { allowed: true, clickId: body?.clickId ?? null };
7469
} catch {
7570
// Fail-open on transport errors; limit enforcement only happens on explicit deny.
76-
return { allowed: true };
71+
return { allowed: true, clickId: null };
7772
}
7873
};

packages/react-native-detour/src/links/hooks/useDetour.ts

Lines changed: 21 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,11 +2,9 @@ import { useCallback, useEffect, useState } from "react";
22

33
import { Linking } from "react-native";
44

5-
import { OPENED_VIA_UNIVERSAL_LINK } from "../../analytics/const/definedEvents";
6-
import { analyticsEmitter } from "../../analytics/utils/analyticsEmitter";
7-
import { checkClickLimit } from "../api/checkClickLimit";
85
import { getDeferredLink } from "../api/getDeferredLink";
96
import { resolveShortLink } from "../api/resolveShortLink";
7+
import { sendUniversalLinkClick } from "../api/sendUniversalLinkClick";
108
import type { DetourContextType, DetourLink, LinkType, RequiredConfig } from "../types";
119
import { checkIsFirstEntrance, markFirstEntrance } from "../utils/appEntrance";
1210
import {
@@ -41,7 +39,15 @@ export const useDetour = ({
4139
}, []);
4240

4341
const resolveLink = useCallback(
44-
async (rawLink: string, typeOverride?: LinkType): Promise<DetourLink> => {
42+
async ({
43+
rawLink,
44+
typeOverride,
45+
skipClickLimitCheck,
46+
}: {
47+
rawLink: string;
48+
typeOverride?: LinkType;
49+
skipClickLimitCheck?: boolean;
50+
}): Promise<DetourLink> => {
4551
if (isInfrastructureUrl(rawLink)) {
4652
console.log("🔗[Detour] Ignored infrastructure URL:", rawLink);
4753
return null;
@@ -77,16 +83,15 @@ export const useDetour = ({
7783
const detectedType: LinkType = isWeb ? "verified" : "scheme";
7884
const type = typeOverride ?? detectedType;
7985

80-
if (isWeb && type !== "deferred") {
81-
const clickLimitStatus = await checkClickLimit({ apiKey, appID });
82-
if (!clickLimitStatus.allowed) {
86+
if (!skipClickLimitCheck && isWeb && type !== "deferred") {
87+
const clickResult = await sendUniversalLinkClick({ apiKey, appID, url: rawLink });
88+
if (!clickResult.allowed) {
8389
console.error("🔗[Detour:CLICK_LIMIT_ERROR] Universal/App link blocked:", {
8490
url: rawLink,
85-
status: clickLimitStatus.status,
86-
error: clickLimitStatus.error,
87-
code: clickLimitStatus.code,
88-
clicksInPeriod: clickLimitStatus.clicksInPeriod,
89-
effectiveLimit: clickLimitStatus.effectiveLimit,
91+
error: clickResult.error,
92+
code: clickResult.code,
93+
clicksInPeriod: clickResult.clicksInPeriod,
94+
effectiveLimit: clickResult.effectiveLimit,
9095
});
9196
return null;
9297
}
@@ -105,7 +110,7 @@ export const useDetour = ({
105110
url: rawLink,
106111
});
107112
if (resolved?.link) {
108-
return resolveLink(resolved.link);
113+
return resolveLink({ rawLink: resolved.link, skipClickLimitCheck: true });
109114
}
110115
console.log("🔗[Detour] Not resolved, using original URL");
111116
}
@@ -159,14 +164,8 @@ export const useDetour = ({
159164
}
160165

161166
const subscription = Linking.addEventListener("url", async ({ url }) => {
162-
const resolved = await resolveLink(url);
167+
const resolved = await resolveLink({ rawLink: url });
163168
if (resolved) {
164-
if (resolved.type !== "scheme") {
165-
analyticsEmitter.emit({
166-
eventName: OPENED_VIA_UNIVERSAL_LINK,
167-
data: { url },
168-
});
169-
}
170169
setLink(resolved);
171170
}
172171
});
@@ -190,14 +189,8 @@ export const useDetour = ({
190189
const initialUrl = await Linking.getInitialURL();
191190
if (initialUrl && !isInfrastructureUrl(initialUrl)) {
192191
await markFirstEntrance(storage);
193-
const resolved = await resolveLink(initialUrl);
192+
const resolved = await resolveLink({ rawLink: initialUrl });
194193
if (resolved) {
195-
if (resolved.type !== "scheme") {
196-
analyticsEmitter.emit({
197-
eventName: OPENED_VIA_UNIVERSAL_LINK,
198-
data: { url: initialUrl },
199-
});
200-
}
201194
setLink(resolved);
202195
}
203196
return;
@@ -217,7 +210,7 @@ export const useDetour = ({
217210
});
218211

219212
if (apiLink) {
220-
const resolved = await resolveLink(apiLink, "deferred");
213+
const resolved = await resolveLink({ rawLink: apiLink, typeOverride: "deferred" });
221214
if (resolved) setLink(resolved);
222215
}
223216
} catch (error) {

0 commit comments

Comments
 (0)