Skip to main content

Click Tracking Link

Overview

This endpoint turns a tracking token into a destination URL while recording click statistics in the same pass. Its main use is rich menu buttons: the user taps a button, LIFF opens, the web app calls this endpoint for the URL and forwards the user on — and the system records who tapped which button.

The governing principle is that this endpoint always returns a destinationUrl. Click recording is a fire-through side effect that swallows every error; nothing can break the redirect except one case — an unknown token, which returns 404.

Business Flow

GET /api/tracking/redirect/:token (the x-liff-token header is optional)

A token can be resolved along two paths.

The built-in path (no database access at all)

  1. Decrypt the token with an AES-256-GCM keyring. Its layout is keyId(1) ‖ nonce(12) ‖ ciphertext ‖ tag(16), base64url-encoded without padding, with keys coming from the CAMPAIGN_LINK_KEYS config.
  2. The AAD here is "tracking-link-v1", deliberately different from the campaign redirect's. A campaign token therefore cannot decrypt as a tracking token even though both use the same key set.
  3. On successful decryption, if the token has not expired (p.E) and ct is rich_menu, a click is written on a best-effort basis (lead_gen is not counted, matching legacy parity), and p.D is returned as the destination.
  4. An empty keyring or a failed decryption fails closed silently and falls through to the legacy path.

The legacy path (reads the table)

  1. SELECT ... FROM tracking_token WHERE token = $1 LIMIT 1. No row returns 404 "Tracking token not found".
  2. A click is recorded only if the token has not expired and status is active and content_type is rich_menu. In every other case the destination is still returned as normal.
  3. Click fields are computed from the row and the jsonb metadata following the legacy fallback order precisely: richMenuId (from metadata only), richMenuActionIndex (from row.action_index, then metadata.richMenuActionIndex), richMenuArchiveId (from metadata.richMenuArchiveId), and trackingLabel (from row.tracking_label, then metadata.trackingLabel, then an empty string).

Writing the click (writeClick) — identical on both paths

  1. Identify the user through liff.VerifyAndGetLineUser, which auto-provisions a guest so the foreign key is always satisfiable. If there is no token, no configured verifier, a failed verification, or no resulting user, the click is recorded as anonymous with a NULL line_uid, and the log distinguishes the causes ("no token supplied" versus "token supplied but failed verification") so anonymous rows can be diagnosed later.
  2. Insert into tracking_log with service='redirect', action_type='click', type='uri', and content_type='rich_menu'. The title column doubles as the trackingLabel because it is NOT NULL.
    • raw_data is jsonb and must be passed as a string, not []byte: the pool uses the pgx simple query protocol, which text-encodes []byte as bytea, and the jsonb column rejects it. This was the bug that silently stopped tracking_log writes on both paths.
  3. Upsert tracking_line_users to dedupe per user and click point, only when the user is known and a richMenuId is present. The tracking_key format is {richMenuId}:{archiveId}:{actionIndex} (a missing archiveId becomes 0). This format must match the worker's. The upsert uses ON CONFLICT (tracking_key, line_user_id) DO NOTHING.
  4. Set the Redis flag RICH_MENU_STAT_DIRTY:{id} with a 7200-second TTL so the cron job recomputes the statistics. The legacy guard was a JS truthy check, so a richMenuId of 0 does not set the flag — unlike the upsert above, which uses a not-null condition.

Key Files & Functions

ItemValue
RouteGET /api/tracking/redirect/:token
Registerinternal/trackingredirect/register.goRegister(r, deps)
Handlerinternal/trackingredirect/handler.go(*Handler).Resolve
Serviceinternal/trackingredirect/service.goNew(...), ResolveAndTrack, recordClick, writeClick, fieldsFromBuiltin, fieldsFromRow; types Destination, clickFields
Token ringinternal/trackingtoken/token.goNewRing(env), (Ring).Decrypt, Payload, aad = "tracking-link-v1"
Helpersinternal/trackingredirect/util.goparseMetadata, jsonNumberToInt64, jsInt64, jsNullInt64, rawMeta, nowFn
Cacheinternal/cacheSet, used as the DirtyMarker

Note: this package uses raw SQL with no separate entity or repository layer, deliberately, to mirror the legacy dataSource.query one for one — the same $N placeholders, the same column set, and the same tracking_key format.

Connections to Other Services

  • Tables tracking_token, tracking_log, tracking_line_users
  • Redis via internal/cache for the RICH_MENU_STAT_DIRTY:* flags
  • The CAMPAIGN_LINK_KEYS config, shared with campaign-redirect but namespaced apart by AAD
  • liff-authentication via VerifyAndGetLineUser (which auto-provisions a guest)
  • Tokens are minted by cms-api-go (internal/core/trackinglink); the statistics are read by the CMS and by a worker-side cron job, both of which must use the same tracking_key format
  • Corresponding client-web feature: tracking-redirect (the /{hash}/r/{id} route and the logic for avoiding a nested LIFF window)