diff --git a/docs/analytics/ga4-operations.md b/docs/analytics/ga4-operations.md index ccba7cf..935daf1 100644 --- a/docs/analytics/ga4-operations.md +++ b/docs/analytics/ga4-operations.md @@ -15,7 +15,7 @@ - 세션 확인 중 경로가 여러 번 바뀌면 준비 완료 시점의 최신 경로만 전송하고 기술적 중간 화면은 집계하지 않는다. - 현재 페이지와 같은 출처의 리퍼러는 정규화된 내부 경로를 보존하고, 외부 HTTP(S) 리퍼러는 `https://host[:port]/` 형식의 출처 루트만 보존한다. 외부 사용자 정보, 경로, 쿼리, 해시는 보내지 않는다. - 비 HTTP(S) 또는 파싱할 수 없는 리퍼러는 `page_referrer`에서 생략한다. -- UTM을 포함한 모든 쿼리와 해시는 `page_path`, `page_location`, `page_referrer`에서 제거한다. +- `page_location`에는 표준 UTM 캠페인 파라미터만 허용한다. 그 밖의 쿼리와 모든 해시는 제거하며, `page_path`와 `page_referrer`에는 쿼리를 포함하지 않는다. - 동적 경로는 다음과 같이 낮은 카디널리티의 템플릿으로 전송한다. | 실제 경로 | GA 전송 경로 | @@ -28,7 +28,9 @@ 도메인 기반 유입 출처는 외부 리퍼러의 origin으로 분석한다. 외부 페이지의 세부 경로는 유입 도메인 판정에 필요하지 않으므로 수집하지 않는다. -`utm_source`, `utm_medium`, `utm_campaign`을 포함한 모든 쿼리 파라미터는 현재 페이지 URL에서도 수집하지 않는다. 따라서 임의의 UTM 값에 개인정보가 섞이는 위험은 줄지만, GA4의 수동 캠페인별 획득 보고는 제한된다. 캠페인 분석이 필요해지면 허용할 키와 값, 동의 문구 및 개인정보 정책을 별도로 검토한 뒤 도입한다. +현재 페이지의 `page_location`에는 GA4 수동 캠페인 분석에 사용하는 `utm_id`, `utm_source`, `utm_medium`, `utm_campaign`, `utm_source_platform`, `utm_term`, `utm_content`, `utm_creative_format`, `utm_marketing_tactic`만 허용한다. 검색어, 초대 코드, 방 이름 등 그 밖의 쿼리와 모든 해시는 제거한다. `page_path`와 `page_referrer`는 캠페인 파라미터를 포함하지 않는다. + +운영자가 만드는 UTM 값에는 이름, 이메일, 전화번호, 검색어 등 개인을 식별하거나 사용자가 입력한 값을 넣지 않는다. 허용 키를 추가하려면 실제 분석 목적과 개인정보 처리방침의 수집 범위를 먼저 검토한다. ## 배포 환경 변수 @@ -148,12 +150,13 @@ NEXT_PUBLIC_GA_DEBUG_MODE=true 9. 비로그인 상태로 일반 경로에 진입해 세션 확인 뒤 `user_id: null → page_view` 순서로 한 번만 발생하는지 확인한다. 10. `/login`과 `/auth/callback`에서 세션 확인을 기다리지 않고 익명 `page_view`가 한 번만 발생하는지 확인한다. 11. 세션 확인 중 빠르게 리다이렉트되면 중간 경로는 없고 최종 경로의 `page_view`만 한 번 발생하는지 확인한다. -12. `/search?q=secret`, `/join/secret`, `/plan/123`, `/bookmark/456`을 이동해 GA 요청에 원문 값이 없는지 확인한다. -13. 스테이징 DebugView에서 `sign_up`, `create_plan`, `join_group`, `share`, `add_to_itinerary`, `view_search_results`, `tutorial_begin`, `tutorial_complete`, `tutorial_skip`과 파라미터를 확인한다. -14. 텍스트 검색과 지도 재검색을 각각 한 번 실행해 `view_search_results`가 실행당 한 번만 발생하는지 확인한다. -15. DebugView와 `google-analytics.com/g/collect` 요청에 `search_term`, 검색어 원문, `q=<원문>`이 없는지 확인한다. -16. 개발자·내부 트래픽 필터가 운영 보고서에서 의도대로 제외되는지 확인한다. -17. 미선택 또는 거부 상태에서 페이지뷰, User-ID, 일반 이벤트 호출 경로와 중앙 데이터 명령을 직접 실행해도 `dataLayer`에 데이터 명령이 추가되지 않고, 이후 허용해도 이전 명령이 되살아나지 않는지 확인한다. -18. 같은 출처 화면 이동의 `page_referrer`에는 정규화된 내부 경로가 남고, 외부 HTTP(S) 유입에는 출처 루트만 남는지 `page_view` 페이로드에서 확인한다. -19. 외부 리퍼러의 사용자 정보·경로·쿼리·해시와 `mailto:`, `ftp:` 등 비 HTTP(S) 리퍼러가 `page_view` 페이로드에 없는지 확인한다. -20. UTM이 포함된 URL로 진입해 `page_location`, `page_path`, `page_referrer`에 UTM을 포함한 어떤 쿼리도 없는지 확인한다. +12. `/?utm_source=newsletter&utm_medium=email&utm_campaign=launch`로 진입해 `page_location`에 세 값이 있고 획득 보고서에 반영되는지 확인한다. +13. `/search?q=secret`, `/join/secret`, `/plan/123`, `/bookmark/456`을 이동해 GA 요청에 원문 값이 없는지 확인한다. +14. 스테이징 DebugView에서 `sign_up`, `create_plan`, `join_group`, `share`, `add_to_itinerary`, `view_search_results`, `tutorial_begin`, `tutorial_complete`, `tutorial_skip`과 파라미터를 확인한다. +15. 텍스트 검색과 지도 재검색을 각각 한 번 실행해 `view_search_results`가 실행당 한 번만 발생하는지 확인한다. +16. DebugView와 `google-analytics.com/g/collect` 요청에 `search_term`, 검색어 원문, `q=<원문>`이 없는지 확인한다. +17. 개발자·내부 트래픽 필터가 운영 보고서에서 의도대로 제외되는지 확인한다. +18. 미선택 또는 거부 상태에서 페이지뷰, User-ID, 일반 이벤트 호출 경로와 중앙 데이터 명령을 직접 실행해도 `dataLayer`에 데이터 명령이 추가되지 않고, 이후 허용해도 이전 명령이 되살아나지 않는지 확인한다. +19. 같은 출처 화면 이동의 `page_referrer`에는 정규화된 내부 경로가 남고, 외부 HTTP(S) 유입에는 출처 루트만 남는지 `page_view` 페이로드에서 확인한다. +20. 외부 리퍼러의 사용자 정보·경로·쿼리·해시와 `mailto:`, `ftp:` 등 비 HTTP(S) 리퍼러가 `page_view` 페이로드에 없는지 확인한다. +21. UTM과 임의 쿼리가 함께 포함된 URL로 진입해 `page_location`에는 허용된 UTM만 있고 `page_path`, `page_referrer`에는 쿼리가 없는지 확인한다. diff --git a/src/components/analytics/AnalyticsRouteTracker.tsx b/src/components/analytics/AnalyticsRouteTracker.tsx index b49362d..ac74804 100644 --- a/src/components/analytics/AnalyticsRouteTracker.tsx +++ b/src/components/analytics/AnalyticsRouteTracker.tsx @@ -26,6 +26,7 @@ export function AnalyticsRouteTracker() { origin: window.location.origin, pathname, referrer: previousPageLocation.current ?? document.referrer, + search: window.location.search, title: document.title, lastTrackedPathname: lastTrackedPathname.current, queryStatus, diff --git a/src/components/analytics/AnonymousAnalyticsRouteTracker.tsx b/src/components/analytics/AnonymousAnalyticsRouteTracker.tsx index f42fff6..1133758 100644 --- a/src/components/analytics/AnonymousAnalyticsRouteTracker.tsx +++ b/src/components/analytics/AnonymousAnalyticsRouteTracker.tsx @@ -19,6 +19,7 @@ export function AnonymousAnalyticsRouteTracker() { origin: window.location.origin, pathname, referrer: previousPageLocation.current ?? document.referrer, + search: window.location.search, title: document.title, }); diff --git a/src/lib/analytics/context.test.ts b/src/lib/analytics/context.test.ts index 332d942..41827e1 100644 --- a/src/lib/analytics/context.test.ts +++ b/src/lib/analytics/context.test.ts @@ -130,19 +130,21 @@ describe("analytics context", () => { ).not.toHaveProperty("page_referrer"); }); - it("removes every query parameter from the current page and referrer", () => { + it("keeps standard campaign parameters and removes other query data", () => { expect( buildAnalyticsPageView({ origin: "https://uttae.example", - pathname: - "/search?q=raw-search&utm_source=newsletter&utm_campaign=summer", + pathname: "/search", referrer: "https://campaign.example/landing/private?utm_medium=email&q=other-secret#offer", + search: + "?q=raw-search&utm_id=summer-2026&utm_source=newsletter&utm_medium=email&utm_campaign=summer&utm_source_platform=mailchimp&utm_term=travel&utm_content=hero&utm_creative_format=banner&utm_marketing_tactic=prospecting#details", title: "검색", }), ).toEqual({ page_path: "/search", - page_location: "https://uttae.example/search", + page_location: + "https://uttae.example/search?utm_id=summer-2026&utm_source=newsletter&utm_medium=email&utm_campaign=summer&utm_source_platform=mailchimp&utm_term=travel&utm_content=hero&utm_creative_format=banner&utm_marketing_tactic=prospecting", page_referrer: "https://campaign.example/", page_title: "검색", }); diff --git a/src/lib/analytics/context.ts b/src/lib/analytics/context.ts index 1020aeb..c70f441 100644 --- a/src/lib/analytics/context.ts +++ b/src/lib/analytics/context.ts @@ -10,6 +10,7 @@ export type AnalyticsPageViewInput = { origin: string; pathname: string; referrer: string; + search?: string; title: string; }; @@ -26,6 +27,18 @@ const dynamicPagePaths: ReadonlyArray = [ [/^\/bookmark\/[^/]+$/, "/bookmark/[folderId]"], ]; +const analyticsCampaignParameterNames = [ + "utm_id", + "utm_source", + "utm_medium", + "utm_campaign", + "utm_source_platform", + "utm_term", + "utm_content", + "utm_creative_format", + "utm_marketing_tactic", +] as const; + function nonNegativeInteger(value: number): number { return Number.isFinite(value) ? Math.max(0, Math.floor(value)) : 0; } @@ -112,12 +125,33 @@ export function analyticsPagePath(pathname: string): string { return dynamicPath?.[1] ?? normalizedPath; } -function analyticsPageLocation(origin: string, pathname: string): string { +function analyticsCampaignSearch(search: string): string { + const input = search.split("#", 1)[0]; + const source = new URLSearchParams( + input.startsWith("?") ? input.slice(1) : input, + ); + const campaign = new URLSearchParams(); + + for (const name of analyticsCampaignParameterNames) { + const value = source.get(name)?.trim(); + if (value) campaign.set(name, value); + } + + const query = campaign.toString(); + return query ? `?${query}` : ""; +} + +function analyticsPageLocation( + origin: string, + pathname: string, + search = "", +): string { const pagePath = analyticsPagePath(pathname); + const campaignSearch = analyticsCampaignSearch(search); try { - return `${new URL(origin).origin}${pagePath}`; + return `${new URL(origin).origin}${pagePath}${campaignSearch}`; } catch { - return pagePath; + return `${pagePath}${campaignSearch}`; } } @@ -160,12 +194,13 @@ export function buildAnalyticsPageView({ origin, pathname, referrer, + search, title, }: AnalyticsPageViewInput): AnalyticsPageViewParams { const pageReferrer = analyticsPageReferrer(origin, referrer); return { page_path: analyticsPagePath(pathname), - page_location: analyticsPageLocation(origin, pathname), + page_location: analyticsPageLocation(origin, pathname, search), ...(pageReferrer ? { page_referrer: pageReferrer } : {}), page_title: title, };