Click Tracking Link
Overview
A click tracking link is an interstitial page that sits between the tap and the real destination so the system can count the click. It is used wherever a link needs to be measured — rich menu buttons, buttons inside broadcast messages, and similar surfaces.
The URL takes the form /{hash}/r/{id}, where id is a tracking token issued
by the platform. When a user opens it, the page attempts to identify them through LIFF,
exchanges the token for the real destination URL, and navigates onward. The click log
itself is written on the API side.
The most important design principle here is that the page must never hang. Identification is a nice-to-have, not a blocker: if LIFF is not ready within four seconds, the page continues anonymously rather than leaving the user staring at a blank screen.
Business Flow
- The user opens
/{hash}/r/{id}, whereidis the tracking token. - The page loads OA information from the hash to obtain the
lineLiffId. This lookup is public and requires no authentication. - It then decides whether to wait for LIFF:
- No
lineLiffId— continue immediately as anonymous. lineLiffIdpresent and LIFF ready — continue with the identity token attached.lineLiffIdpresent but LIFF not ready — start a four-second timer (LIFF_WAIT_TIMEOUT_MS); when it fires, continue anonymously.
- No
- During navigation, the page races the ID token request against a four-second
timeout via
Promise.race, so waiting for a token can never block the hand-off. - It calls
GET /tracking/redirect/{token}(attaching thex-liff-tokenheader when available) and receives adestinationUrl. - Same-origin check before navigating — if this page is running inside a LIFF
window (indicated by the
?le=1query parameter added by cms-api) and the destination is a LIFF URL for the same LIFF ID, the page rewrites it to a same-origin path and navigates within the existing window. This prevents nested LIFF windows, which leave an orphaned window behind when the user closes one. Legacy links withoutle=1and genuinely external destinations keep the original URL. - Navigation uses history replacement (
window.location.replace) so pressing back does not return the user to the interstitial. - If any step fails, the page shows a message saying the link could not be opened and inviting the user to try again.
- A guard prevents duplicate navigation so the hand-off never fires twice.
Key Screens & Components
There is almost no interactive UI here — the page lives on screen for a fraction of a second. Users see only a loading state, plus an error message if the token exchange fails.
The moving parts are:
- Redirect page (
src/app/[hash]/r/[id]/page.tsx) — a client component that drives the whole flow, including the timeout and the duplicate-navigation guard. - Token exchange service (
src/service/tracking-redirect.service.ts) — takes the tracking token plus an optional LIFF token and asks the API for the destination URL. - LIFF URL helper (
src/lib/liff-same-origin.ts) — determines whether the destination belongs to the same LIFF app and, if so, converts it to an internal path. - Constant
LIFF_WAIT_TIMEOUT_MScaps the LIFF wait at 4,000 milliseconds.
Endpoint used: GET /tracking/redirect/{token}, which returns a single
destinationUrl value.
Dependencies
- LIFF authentication is used on a best-effort basis — identification is optional, and a failure simply results in an anonymous click record (see liff-authentication).
- The CMS side generates these tracking links and is what appends the
le=1parameter this page relies on for its same-origin decision. - Campaign link proxy plays a different role: this page is a client component that waits on LIFF, whereas the campaign proxy is a pure server route with no UI (see campaign-redirect-proxy).
Backend Details (Client API)
The endpoint this page calls is GET /api/tracking/redirect/:token, which accepts an
optional x-liff-token header. The backend's guiding principle mirrors the page's
"never hang" rule: this endpoint always returns a destinationUrl. Recording the
click is a side task that swallows every error. The single failure case is a token that
cannot be found, which answers 404 "Tracking token not found".
Two token resolution paths
- Built-in path (never touches the database) — the token is an AES-256-GCM
encrypted blob issued from a keyring (
CAMPAIGN_LINK_KEYS) that carries the destination URL and tracking context inside itself. If it decrypts and has not expired, the destination is returned immediately. - Legacy path (reads a table) — if decryption fails, or the keyring is not
configured, the service silently falls through to a
tracking_tokenlookup by literal token value. No row means 404. - On the legacy path, the click fields are assembled from both table columns and the
jsonb
metadatablob following a fixed fallback order (for example, the label prefers thetracking_labelcolumn, then metadata, then an empty string).
Conditions a click must satisfy to be counted
- The token must not be expired.
- The content type must be
rich_menu—lead_gentokens are deliberately not counted as clicks. - The legacy path adds one more condition: the row must have
status = 'active'. - When these conditions fail, the service still returns the destination normally — it simply skips the statistics write.
What gets written, and the side effects
- Identify the user first — using the same LIFF verification machinery as other
features (auto-provisioning a guest row when none exists, so the foreign key
resolves). If no token arrives, the verifier is not configured, or verification
fails, the click is stored as an anonymous row (
line_uidNULL) rather than erroring. Server logs distinguish the two causes ("no token sent" versus "token sent but verification failed") so an unexpected spike in anonymous rows can be diagnosed. - Insert into
tracking_logwithservice='redirect',action_type='click',type='uri',content_type='rich_menu'. One quirk worth knowing: thetitlecolumn doubles as the tracking label store, because it is NOT NULL. - Upsert
tracking_line_usersto deduplicate "this user has already tapped this spot". This happens only when the user is known and a rich menu id is present, keyed asrichMenuId:archiveId:actionIndex— this key format must match the worker that computes statistics; changing one side alone makes the two count different sets. A key collision is a no-op, not an error. - Set a Redis flag named
RICH_MENU_STAT_DIRTY:plus the rich menu id, with a 7,200-second TTL, telling a cron job to recompute that rich menu's statistics. The numbers the CMS displays therefore do not come from a live count on every request but from the cron pass this flag triggers.
Notable points
- Namespace separation via AAD — tracking tokens and campaign tokens share the same
key set (
CAMPAIGN_LINK_KEYS) but bind different additional authenticated data (tracking-link-v1). A campaign token therefore cannot be decrypted as a tracking token, and vice versa. The cross-type isolation is intentional. - An empty or failing keyring fails closed, silently — no error reaches the user; the request falls through to the legacy path instead. So a missing keyring in the environment turns every built-in link into a 404 even though the tokens are valid.
- A
richMenuIdof 0 never sets the dirty flag (the guard is a truthy check), unlike thetracking_line_usersupsert which uses a not-null check. The asymmetry is preserved deliberately to match the behaviour of the previous system.