From 4e7e12d8fe51ad16f64a5873a4378eb1330e2568 Mon Sep 17 00:00:00 2001 From: zenn Date: Wed, 29 Jul 2026 14:48:14 +0900 Subject: [PATCH] =?UTF-8?q?=ED=86=B5=EA=B3=84=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=84=B8=EC=85=98=20=EC=9B=90=EC=9E=A5=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../072_analytics_content_sessions.sql | 43 +++ docs/deploy.md | 7 +- docs/history.md | 4 + docs/map.md | 4 +- docs/spec.md | 15 +- docs/todo.md | 6 +- docs/update.md | 8 + lib/analytics.js | 10 + package-lock.json | 4 +- package.json | 2 +- pages/admin/index.vue | 58 +++- plugins/site-analytics.client.js | 56 +++- server/repositories/analytics-repository.js | 292 +++++++++++++++++- server/utils/analytics-heartbeat-input.js | 6 + server/utils/analytics-pageview-input.js | 13 + 15 files changed, 481 insertions(+), 47 deletions(-) create mode 100644 db/migrations/072_analytics_content_sessions.sql diff --git a/db/migrations/072_analytics_content_sessions.sql b/db/migrations/072_analytics_content_sessions.sql new file mode 100644 index 0000000..5051552 --- /dev/null +++ b/db/migrations/072_analytics_content_sessions.sql @@ -0,0 +1,43 @@ +CREATE TABLE IF NOT EXISTS analytics_content_sessions ( + content_session_hash TEXT PRIMARY KEY, + day DATE NOT NULL, + visitor_hash TEXT NOT NULL, + session_hash TEXT NOT NULL, + path TEXT NOT NULL, + post_id UUID REFERENCES posts(id) ON DELETE SET NULL, + page_id UUID REFERENCES pages(id) ON DELETE SET NULL, + source_group TEXT NOT NULL DEFAULT '', + source_name TEXT NOT NULL DEFAULT '', + device_type TEXT NOT NULL DEFAULT '', + os_name TEXT NOT NULL DEFAULT '', + keyword TEXT NOT NULL DEFAULT '', + view_recorded BOOLEAN NOT NULL DEFAULT false, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + duration_seconds INTEGER NOT NULL DEFAULT 0, + max_scroll_ratio REAL NOT NULL DEFAULT 0, + engaged_recorded BOOLEAN NOT NULL DEFAULT false, + scroll_25_recorded BOOLEAN NOT NULL DEFAULT false, + scroll_50_recorded BOOLEAN NOT NULL DEFAULT false, + scroll_75_recorded BOOLEAN NOT NULL DEFAULT false, + scroll_100_recorded BOOLEAN NOT NULL DEFAULT false, + read_recorded BOOLEAN NOT NULL DEFAULT false, + read_at TIMESTAMPTZ +); + +CREATE INDEX IF NOT EXISTS analytics_content_sessions_day_idx + ON analytics_content_sessions (day DESC); + +CREATE INDEX IF NOT EXISTS analytics_content_sessions_session_idx + ON analytics_content_sessions (session_hash); + +CREATE INDEX IF NOT EXISTS analytics_content_sessions_post_idx + ON analytics_content_sessions (post_id, day DESC) + WHERE post_id IS NOT NULL; + +CREATE INDEX IF NOT EXISTS analytics_content_sessions_page_idx + ON analytics_content_sessions (page_id, day DESC) + WHERE page_id IS NOT NULL; + +ALTER TABLE analytics_active_sessions + ADD COLUMN IF NOT EXISTS content_session_hash TEXT NOT NULL DEFAULT ''; diff --git a/docs/deploy.md b/docs/deploy.md index c100d90..1e8570b 100644 --- a/docs/deploy.md +++ b/docs/deploy.md @@ -1,7 +1,7 @@ # 배포 가이드 -> v1.12.2는 Google 로그인/계정 연동과 소유자 전용 연동 해제 관리를 추가한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. -> 로컬 기준 v1.12.2에서 `npm run lint`, `npm test`, `npm run build`, `npm audit` 검증을 통과했다. +> v1.12.4는 Google 로그인/계정 연동과 통계 콘텐츠 세션 원장을 포함한다. 운영 배포 전 `068_csp_violation_aggregates.sql`, `069_media_catalog.sql`, `070_post_import_jobs.sql`, `071_users_google_oauth.sql`, `072_analytics_content_sessions.sql`을 순서대로 적용하고 미디어 카탈로그는 reconcile 완료 후 `MEDIA_CATALOG_MODE=database` 고정 순서를 지킨다. 게시물 Export·Import·일반 업로드는 디스크·임시 디렉터리 스트리밍을 사용하므로 NAS 앱 컨테이너의 임시 저장 공간과 `public/uploads/exports`, `public/uploads/_import-jobs` 쓰기 권한을 확인한다. +> 로컬 기준 v1.12.4에서 `npm run lint`, `npm test`, `npm run build` 검증을 통과했다. ## 빌드 유형 @@ -14,7 +14,7 @@ > `npm run dev`는 프로젝트 전용 실행 스크립트를 통해 개발 서버, Admin, Tailwind Viewer 링크만 요약 출력한다. -## v1.12.2 배포 전 검증 +## v1.12.4 배포 전 검증 Playwright API 테스트도 브라우저 바이너리는 필요하지 않지만 `verify:ci`에는 Chromium E2E가 포함된다. 최초 1회 의존성과 Chromium을 설치하고 Docker daemon이 실행 중인지 확인한다. @@ -42,6 +42,7 @@ sh scripts/migrate-production-db.sh status - `069_media_catalog.sql`은 미디어 카탈로그·참조·상태 계약을 만든다. 최초 상태는 `ready=false`이므로 마이그레이션 직후 곧바로 `MEDIA_CATALOG_MODE=database`만 켠 채 운영하지 않는다. 반드시 reconcile을 먼저 끝낸다. - `070_post_import_jobs.sql`은 게시물 Import 작업/항목 상태 테이블을 만든다. - `071_users_google_oauth.sql`은 회원 Google 로그인·계정 연동용 식별자 컬럼과 고유 인덱스를 만든다. +- `072_analytics_content_sessions.sql`은 콘텐츠 단위 통계 세션 원장을 만들고 실시간 세션에 현재 콘텐츠 세션 해시를 추가한다. - 배포 직후(또는 `filesystem`) 상태에서 관리자 인증 후 `POST /admin/api/media/reconcile`을 1회 실행한다. 이 API는 파일을 삭제하거나 이동하지 않고 발견 파일 upsert, 참조 백필, 사라진 파일 `missing` 표시만 수행한다. - `media_catalog_state`의 `ready=true`, 파일시스템/카탈로그 `count`와 `bytes` 일치, `staging`·`error` 행 0을 확인한다. - 확인이 끝나면 운영 env를 `MEDIA_CATALOG_MODE=database`로 고정하고 재기동한다. 개발 환경도 동일하게 `database`로 고정한다. 준비 미완료·불일치·DB 조회 오류가 발생하면 애플리케이션은 자동으로 파일시스템 목록으로 fallback한다. diff --git a/docs/history.md b/docs/history.md index 144be11..98661a5 100644 --- a/docs/history.md +++ b/docs/history.md @@ -1,3 +1,7 @@ +## 2026-07-29 v1.12.4 — 통계 집계 기준을 콘텐츠 세션 원장으로 분리한다 + +기존 통계 수집은 탭 단위 실시간 세션을 현재 접속자 표시와 체류·스크롤 증분 계산에 함께 사용했다. 이 구조에서는 같은 탭에서 여러 게시물이나 페이지를 이동할 때 이전 콘텐츠의 체류시간과 스크롤 비율이 다음 콘텐츠 집계에 영향을 줄 수 있다. 따라서 클라이언트는 탭 세션과 별도로 라우트 진입 단위 `contentSessionId`를 보내고, 서버는 원문 ID 대신 해시된 `analytics_content_sessions` 원장을 기준으로 pageview/read 중복과 heartbeat 증분을 판단한다. `analytics_active_sessions`는 현재 접속자 표시용으로 남겨 실시간 UI 책임과 콘텐츠 분석 책임을 분리한다. + ## 2026-07-29 v1.12.2 — Google 계정 재연결은 소유자 관리로 제한한다 회원 본인이 Google 계정을 다시 연결할 수 있으면 실수로 다른 Google 계정에 바꾸거나 관리자 계정의 로그인 경로가 예상과 달라질 수 있다. 따라서 회원 설정 화면은 연결 전에는 연결 버튼만, 연결 후에는 읽기 전용 상태만 표시한다. 예외적으로 계정 이전이나 잘못된 연결을 정리해야 할 수 있으므로, 해제 권한은 관리자 멤버 상세 화면의 소유자 전용 작업으로 둔다. diff --git a/docs/map.md b/docs/map.md index 2ecdb8c..9636584 100644 --- a/docs/map.md +++ b/docs/map.md @@ -93,7 +93,7 @@ | lib/markdown-inline.js | 공개·관리자 공통 인라인 마크다운 파서, 링크·굵게·기울임·인라인 코드·첨자·인라인 색상 토큰(`::color[텍스트]`) 변환, 링크·굵게·기울임·색상 내부 중첩 인라인 문법과 `
` 줄바꿈 처리, 라이브 멀티라인 본문의 기본 불릿/번호 목록 HTML 변환과 contenteditable 목록 마커 기준 커서 offset 계산·직렬화 | | lib/post-tag-limit.js | 게시물별 태그 최대 개수 기본값·허용 범위·정규화 | | lib/analytics-shared.js | 통계 추적 경로 필터·체류/스크롤 상수(클라이언트·서버 공용) | -| lib/analytics.js | 서버 전용 visitor/session hash(`node:crypto`) | +| lib/analytics.js | 서버 전용 visitor/session/content session hash(`node:crypto`) | | lib/analytics-traffic.js | referrer·User-Agent 기반 유입원·디바이스·검색 키워드 축약 분류 | | lib/admin-pagination.js | 관리자 번호형 페이지네이션 페이지 보정·ellipsis 항목 계산 | | lib/csp-report.js | CSP 보고 Content-Type·32KB/20건 제한 상수, payload 검증, 문서 경로·차단 origin·브라우저 계열 최소화 | @@ -102,6 +102,8 @@ | lib/media-catalog.js | 업로드 URL·저장 키 정규화, 미디어 종류·원본/파생 역할 판별, 콘텐츠 미디어 참조 추출, PostgreSQL LIKE 리터럴 이스케이프 | | lib/social-links.js | 사이트 설정 SNS 링크 아이콘 프리셋·사용자 SVG 정리·URL 자동 보정·레거시 JSON 문자열 복구·공개 노출 목록 생성 | | server/utils/backup-storage-summary.js | 관리자 사이트 설정 백업 현황용 업로드 파일 스캔·원본/자동 생성 이미지/프로필/내보내기 파일 용량 집계 | +| plugins/site-analytics.client.js | 공개 페이지 pageview/read/heartbeat 전송, 탭 단위 client session과 라우트 진입 단위 content session ID 관리 | +| server/repositories/analytics-repository.js | 자체 통계 일별 집계, 콘텐츠 세션 원장, 실시간 접속자, 유입·디바이스·게시물·페이지 통계 조회 | ## 문서 diff --git a/docs/spec.md b/docs/spec.md index eb1de12..ad61cc1 100644 --- a/docs/spec.md +++ b/docs/spec.md @@ -589,16 +589,18 @@ components/content/ | page_analytics_daily | day, page_id, views, visitors, engaged_views, total_engaged_seconds, scroll_25~100 | 페이지 일별 조회·방문자·스크롤 구간 | | analytics_traffic_daily | day, source_group, source_name, device_type, os_name, keyword, page_views, visitors | 유입원·디바이스·키워드 일별 축약 집계 | | analytics_daily_visitors | day, scope(`site`/`post`/`page`/`traffic`), post_id?, page_id?, visitor_hash, source_group?, source_name?, device_type?, os_name?, keyword? | 일별 방문자 해시 등록(중복 방문 제거용) | -| analytics_active_sessions | session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, ip_address, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) | +| analytics_content_sessions | content_session_hash, day, visitor_hash, session_hash, path, post_id?, page_id?, source_group, source_name, device_type, os_name, keyword, view_recorded, duration_seconds, max_scroll_ratio, engaged_recorded, scroll_25~100_recorded, read_recorded, read_at, last_seen_at | 콘텐츠 단위 방문 세션 원장 | +| analytics_active_sessions | session_hash, content_session_hash, user_id?, path, post_id?, post_slug, page_id?, page_slug, ip_address, duration_seconds, max_scroll_ratio, last_seen_at | 실시간 접속 세션(TTL 90초) | - 추적 대상: 공개 경로만. `/admin`, `/signin`, `/signup`, `/forgot-password`, `/settings`는 제외. - 관리자 세션(`sori_admin_session`)이 있는 요청은 공개 경로라도 페이지뷰, 게시물 읽음, heartbeat 체류·스크롤, 실시간 접속자 집계에 포함하지 않는다. - 봇 User-Agent는 서버에서 무시. - 페이지뷰 수집은 탭 최초 referrer와 현재 URL을 서버로 보내며, 서버는 원문 referrer를 저장하지 않고 검색·SNS·직접·기타, 디바이스·OS, 검색 키워드로 축약해 `analytics_traffic_daily`에 집계한다. - 검색 유입은 네이버·다음·구글·줌·빙을 우선 분류하고, SNS 유입은 카카오톡·페이스북·인스타그램·트위터/X·유튜브를 우선 분류한다. 검색 키워드는 referrer query에 남아 있는 경우만 표시한다. -- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송. -- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. -- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다. +- 클라이언트는 탭 단위 `clientSessionId`와 라우트 진입 단위 `contentSessionId`를 분리해 전송한다. 서버는 원문 ID를 저장하지 않고 visitor hash와 통계 시크릿으로 `session_hash`, `content_session_hash`를 만든다. +- 게시물 `reads`는 클라이언트에서 15초 이상 체류·50% 이상 스크롤 후 별도 전송한다. 콘텐츠 세션 원장이 있는 경우 같은 콘텐츠 세션의 `read_recorded`가 처음 켜질 때만 일별 `reads`를 증가시킨다. +- `POST /api/analytics/heartbeat`는 20초 간격으로 체류시간·스크롤·현재 경로를 전송한다. 로그인 사용자는 서버 세션으로 `user_id`를 연결한다. 체류·스크롤 증분은 `analytics_content_sessions`의 콘텐츠 세션별 이전 값 기준으로 계산해 같은 탭에서 여러 게시물·페이지를 이동해도 스크롤 구간과 engaged 집계가 서로 섞이지 않게 한다. +- 관리자 대시보드는 `GET /admin/api/analytics/realtime`으로 현재 접속자 목록(닉네임·아바타·대표 게시물 제목·접속 유지시간·IP·열린 페이지 수·열린 페이지 제목 목록)을 조회한다. 현재 접속자는 로그인 사용자는 회원 기준, 익명 방문자는 IP 기준으로 묶어 같은 사람이 여러 페이지를 열어도 1명으로 표시하되, 묶음 안의 활성 페이지는 목록으로 함께 표시한다. 활성 페이지 행에 마우스를 올리면 공개 경로 새 창 바로가기 버튼을 표시한다. 상단 요약은 현재 접속자·오늘 방문자·누적 방문자·게시물 수를 표시한다. - 관리자 대시보드 **통계 추이**는 `trends` 데이터를 3개 막대 차트(방문자수·평균 체류시간·50% 스크롤 도달)로 표시한다. 선택지는 일간·주간·월간·년간이며 기본값은 일간이다. 집계는 `ANALYTICS_COLLECTED_SINCE` 환경 변수(기본 `2026-06-01`) 이후 데이터만 합산하고 그 이전 구간은 0으로 표시한다. **방문자수** 막대는 검색(빨강)·SNS(청록)·기타(회색) 유입 비율을 스택으로 표시하며, hover 시 해당 막대만 색상을 강조한다. 안내 문구는 `통계는 2026년 6월 1일부터 집계한 데이터를 표시합니다.` 형식이다. 막대 hover/focus 시 기간과 정확한 값을 툴팁으로 표시하며, 막대 클릭 시 하단 **선택한 기간 상세** 패널을 해당 날짜 또는 묶음 구간 기준으로 갱신한다. 표(table)나 외부 차트 라이브러리는 사용하지 않는다. - 관리자 대시보드 **선택한 기간 상세**는 기본값으로 오늘 통계를 표시하고, 차트 막대 클릭 시 선택 범위의 페이지뷰·방문자·평균 체류·50% 스크롤·검색/SNS/직접 포함 유입원·디바이스/OS·인기 게시물·인기 페이지를 표시한다. 자동 갱신 중에는 사용자가 선택한 날짜·기간을 유지하고, 상세 재조회 중에도 기존 내용을 유지해 화면 높이가 줄어들지 않게 한다. 인기 게시물·인기 페이지는 데스크톱에서 왼쪽 75% 영역에 세로 목록으로 표시하고, 유입·디바이스는 오른쪽 25% 영역에 세로 배치한다. 디바이스는 모바일·PC 같은 상위 기기 기준으로 한 카드에 합산하고, 상위 기기 제목 오른쪽에 방문자 수·조회수·전체 디바이스 방문자 대비 비율을 한 줄로 표시한다. 카드 안의 Android/iOS/Windows 같은 OS별 목록은 방문자 수와 전체 디바이스 방문자 대비 비율만 표시한다. 인기 콘텐츠 메타는 `V 조회`, `R 읽음/방문자`, `M 최근 30일 조회`, `C 작성일`, `S 50/75/100% 스크롤` 형식의 한 줄 압축 표기를 사용한다. `V/R/M` 알파벳만 서로 다른 색상으로 강조하고 숫자는 보조 텍스트 색상으로 표시한다. 구분자 `|`는 연한 회색으로 표시하며, `S`의 50/75/100 스크롤 값은 단계가 깊어질수록 진하게 표시한다. 관리자 통계 응답은 표시 안정성을 위해 `읽음 <= 조회`, `100% 스크롤 <= 75% 스크롤 <= 50% 스크롤 <= 조회` 범위로 정규화한다. 유입 프로그래스 바는 보조 지표로 보이도록 카드 폭의 절반만 사용한다. 검색엔진이 대부분 검색어를 전달하지 않는 현실을 반영해 대시보드의 유입 키워드 영역은 표시하지 않는다. - 관리자 글 목록은 게시물별 `post_analytics_daily.views` 누적 합계를 별도 조회수 열에 `1,000`처럼 숫자만 표시하고, 같은 셀 아래 작은 회색 통계 버튼으로 게시물별 통계 모달을 연다. 통계 버튼은 hover 시에만 파란색으로 표시한다. @@ -606,6 +608,7 @@ components/content/ - 관리자 대시보드 추이 차트는 일일 7개, 주간 최대 8개, 월간 12개, 년간 최대 8개 버킷을 조회한다. 게시물별 통계와 기타 인기/유입 목록 API는 기존 최대 365일 조회 제한을 유지한다. - `site_analytics_daily`, `post_analytics_daily`, `analytics_traffic_daily`는 사이트 전체 방문자와 게시물별 조회수, 유입 통계 누적 원본이므로 자동 삭제하지 않는다. - `analytics_daily_visitors`는 일별 중복 방문 제거용이며, 수집·조회 흐름에서 32일보다 오래된 행을 주기적으로 삭제한다. +- `analytics_content_sessions`는 콘텐츠 세션별 중복 방지와 체류·스크롤 증분 계산용 원장이며, 일별 누적 집계는 보존하되 원장 행은 방문자 해시와 같은 32일 보관 정책으로 주기적으로 삭제한다. - `analytics_active_sessions`는 현재 접속자 목록용이며, 90초보다 오래된 행을 삭제한다. IP는 실시간 익명 접속자 묶음 표시용으로만 저장하고, 방문자 통계 고유성은 기존 해시 기반 집계를 유지한다. --- @@ -637,8 +640,8 @@ components/content/ - `GET /api/search?q=` - 통합 검색. 태그는 `name`·`slug`, 게시물은 DB 트리거가 갱신하는 `posts.search_text`, 홈 표시 페이지는 `pages.search_text` 기준으로 부분 일치한다. `search_text`는 제목·요약·본문·페이지 섹션/목록 설명에서 업로드 경로와 이미지 마크다운 토큰을 제거한 정규화 문자열이며 `pg_trgm` GIN 인덱스를 사용한다. - `GET /api/site-settings` - 공개 사이트 설정(어나운스 바·홈 커버 등 포함) - `GET /api/homepage-widget` - gethomepage customapi용 공개 블로그 통계. 기본 필드 `title`, `updatedAt`, `todayVisitors`, `totalVisitors`, `todayPageViews`, `totalPageViews`, `visitorsLast7Days`, `visitorsLast30Days`, `visitorsLast180Days`, `visitorsLast365Days`, `pageViewsLast30Days`, `onlineNow`, `loggedInNow`, `avgEngagedSeconds`, `totalPosts`, `totalComments`, `totalPages`, `totalTags`, `totalMembers`, `items[]`를 반환한다. 기간별 방문자는 최근 7·30·180·365일의 일별 순방문자 합계다. `totalVisitors`는 날짜별 순방문자 수의 누적 합계라 동일 방문자가 다른 날짜에 다시 포함될 수 있다. 게시글·댓글·페이지 수는 현재 공개 가능한 항목만 집계한다. -- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다. 단일 IP 해시는 1분에 최대 120개 쓰기 요청만 처리하고 초과 요청은 성공 응답만 반환한다. -- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다. 단일 IP 해시는 1분에 최대 240개 쓰기 요청만 처리한다. +- `POST /api/analytics/pageview` - 공개 방문·게시물·페이지 조회/읽음 집계. 본문: `path`(필수), `postSlug`(게시물일 때), `pageSlug`(페이지일 때), `clientSessionId`(선택), `contentSessionId`(선택), `referrer`, `currentUrl`, `read`(읽음 이벤트). 발행된 게시물과 공개 페이지만 개별 집계한다. `contentSessionId`가 있으면 같은 콘텐츠 세션의 중복 pageview/read는 일별 집계에 다시 더하지 않는다. 응답 `{ ok: true }`. 관리자 세션 요청은 저장하지 않고 `{ ok: true }`만 반환한다. HTML 문서 모드 페이지는 Nuxt 클라이언트 플러그인을 거치지 않으므로 서버 미들웨어가 GET 요청 시 페이지 조회를 직접 기록하되, 관리자 세션은 제외한다. 단일 IP 해시는 1분에 최대 120개 쓰기 요청만 처리하고 초과 요청은 성공 응답만 반환한다. +- `POST /api/analytics/heartbeat` - 실시간 세션·체류·스크롤 집계. 본문: `path`, `postSlug`, `pageSlug`, `clientSessionId`, `contentSessionId`(선택), `durationSeconds`(최대 1800), `maxScrollRatio`(0~1). 로그인 시 서버가 회원 세션으로 사용자 연결하며, 관리자 세션 요청은 저장하지 않는다. `contentSessionId`가 있으면 콘텐츠 단위 세션 원장의 이전 체류·스크롤 기준으로 증분을 계산한다. 단일 IP 해시는 1분에 최대 240개 쓰기 요청만 처리한다. - `GET /api/navigation` - 공개 네비게이션(`primary`는 트리·`footer`·`recommended`는 평면, 상세는 위 메뉴/네비게이션 절) - `GET /ads.txt` - 사이트 설정의 ads.txt 본문을 `text/plain`으로 반환한다. 값이 없으면 빈 본문을 반환한다. - `POST /api/auth/signup` - 회원 가입. 본문: `username`, `email`, `password`, 선택 `emailOtp`(6자리 숫자). **`GET /api/auth/bootstrap-status`의 `emailOtpConfigured`가 true이고 `needsAdminSetup`이 false일 때**(서버에 Resend·pepper 설정됨) `emailOtp`가 필수이며, 직전에 `POST /api/auth/email-otp/request`로 같은 이메일·`purpose: "signup"` 발송분과 일치해야 한다. 최초 관리자 부트스트랩(`needsAdminSetup: true`)에서는 `emailOtp` 없이 가입 가능. 이메일은 저장 시 소문자로 정규화한다. diff --git a/docs/todo.md b/docs/todo.md index f8e579d..1805594 100644 --- a/docs/todo.md +++ b/docs/todo.md @@ -15,8 +15,8 @@ ## 데이터베이스 -- 통계 heartbeat/read 수집을 콘텐츠별 세션 기준으로 세분화 검토. - - 현재 관리자 응답은 `읽음 <= 조회`, `100% <= 75% <= 50% <= 조회`로 표시값을 정규화함. - - 원시 집계 단계에서도 같은 탭의 여러 게시물 이동이 스크롤 구간 계산에 섞이지 않도록 별도 콘텐츠 세션 저장 구조 검토. +- 통계 콘텐츠 세션 원장 기반 후속 개선. + - `analytics_content_sessions` 누적 데이터를 활용해 게시물·페이지별 평균 체류, 읽음 전환율, 스크롤 완료율을 원장 기준으로 재계산하는 관리자 지표 개선 검토. + - 콘텐츠 세션 원장 보관 기간을 운영 데이터 증가량 기준으로 32일 유지 또는 별도 기간으로 분리할지 검토. ## 배포 diff --git a/docs/update.md b/docs/update.md index 6c8f8b5..4a38613 100644 --- a/docs/update.md +++ b/docs/update.md @@ -1,5 +1,13 @@ # 업데이트 이력 +## v1.12.4 + +- 통계 수집에 콘텐츠 단위 세션 원장 `analytics_content_sessions` 추가. +- 공개 pageview/read/heartbeat 이벤트에 라우트 진입 단위 `contentSessionId` 전송 추가. +- heartbeat 체류·스크롤 증분 계산을 실시간 세션이 아니라 콘텐츠 세션 기준으로 분리. +- 실시간 접속 세션에 현재 콘텐츠 세션 해시를 저장해 페이지 이동 시 접속 유지·스크롤 표시가 이전 콘텐츠 값에 끌리지 않도록 수정. +- 관리자 대시보드 현재 접속자 목록에 공개 페이지 새 창 바로가기 버튼 추가. + ## v1.12.3 - Google 로그인 연동 해제 시 `NOT NULL` Google 프로필 컬럼을 `NULL`이 아니라 빈 문자열로 초기화하도록 수정. diff --git a/lib/analytics.js b/lib/analytics.js index 8bbf385..1fd7f70 100644 --- a/lib/analytics.js +++ b/lib/analytics.js @@ -40,3 +40,13 @@ export const createRealtimeSessionHash = ({ clientSessionId, visitorHash, secret const payload = `${clientSessionId}|${visitorHash}|${secret}` return createHash('sha256').update(payload).digest('hex') } + +/** + * 콘텐츠 단위 세션 해시를 생성한다. + * @param {{ contentSessionId: string, visitorHash: string, secret: string }} input - 해시 입력 + * @returns {string} 콘텐츠 세션 해시 + */ +export const createContentSessionHash = ({ contentSessionId, visitorHash, secret }) => { + const payload = `${contentSessionId}|${visitorHash}|${secret}` + return createHash('sha256').update(payload).digest('hex') +} diff --git a/package-lock.json b/package-lock.json index 5ad78e4..0910c14 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "sori.studio", - "version": "1.12.3", + "version": "1.12.4", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "sori.studio", - "version": "1.12.3", + "version": "1.12.4", "hasInstallScript": true, "dependencies": { "@nuxtjs/tailwindcss": "^6.14.0", diff --git a/package.json b/package.json index 5d79229..d177beb 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "sori.studio", - "version": "1.12.3", + "version": "1.12.4", "private": true, "type": "module", "imports": { diff --git a/pages/admin/index.vue b/pages/admin/index.vue index c51d2c2..241b2d9 100644 --- a/pages/admin/index.vue +++ b/pages/admin/index.vue @@ -520,6 +520,28 @@ const getActivePageTitle = (page) => { return page.path || '알 수 없음' } +/** + * 접속 페이지의 공개 바로가기 경로를 반환한다. + * @param {Object} page - 접속 페이지 + * @returns {string} 공개 경로 + */ +const getActivePageHref = (page) => { + if (page.postSlug) { + return `/post/${encodeURIComponent(page.postSlug)}` + } + + if (page.pageSlug) { + return `/pages/${encodeURIComponent(page.pageSlug)}` + } + + const path = String(page.path || '').trim() + if (path.startsWith('/') && !path.startsWith('/admin')) { + return path + } + + return '' +} + /** * 통계 목록의 날짜를 짧은 문자열로 변환한다. * @param {string | null | undefined} value - ISO 날짜 @@ -1123,9 +1145,23 @@ watch(trendRows, () => {

- {{ getSessionViewingTitle(session) }} + {{ getSessionViewingTitle(session) }} + + +

diff --git a/plugins/site-analytics.client.js b/plugins/site-analytics.client.js index b7689f0..aa83754 100644 --- a/plugins/site-analytics.client.js +++ b/plugins/site-analytics.client.js @@ -18,11 +18,17 @@ const READ_MIN_SCROLL_RATIO = 0.5 /** @type {number} heartbeat 전송 간격(ms) */ const HEARTBEAT_INTERVAL_MS = 20000 -/** @type {Set} 세션 내 전송 완료된 pageview 키 */ -const sentViewKeys = new Set() +/** @type {string} 마지막 pageview 키 */ +let lastPageviewKey = '' -/** @type {Set} 세션 내 전송 완료된 read slug */ -const sentReadSlugs = new Set() +/** @type {number} 마지막 pageview 전송 시각 */ +let lastPageviewSentAt = 0 + +/** @type {string} 마지막 pageview 콘텐츠 세션 ID */ +let lastPageviewContentSessionId = '' + +/** @type {Set} 전송 완료된 read 콘텐츠 세션 ID */ +const sentReadContentSessionIds = new Set() /** @type {number | null} read 폴링 타이머 */ let readPollTimer = null @@ -51,6 +57,9 @@ let currentPostSlug = '' /** @type {string} 현재 추적 페이지 slug */ let currentPageSlug = '' +/** @type {string} 현재 콘텐츠 단위 세션 ID */ +let currentContentSessionId = '' + /** @type {number} 현재 페이지 체류 시작 시각 */ let pageStartedAt = 0 @@ -76,6 +85,18 @@ const getClientSessionId = () => { } } +/** + * 콘텐츠 단위 세션 ID를 생성한다. + * @returns {string} 콘텐츠 세션 ID + */ +const createContentSessionId = () => { + try { + return crypto.randomUUID() + } catch { + return `content-${Date.now()}-${Math.random().toString(36).slice(2)}` + } +} + /** * 탭 최초 유입 referrer를 반환한다. * @returns {string} referrer @@ -189,6 +210,7 @@ const clearPageTracking = () => { currentPath = '' currentPostSlug = '' currentPageSlug = '' + currentContentSessionId = '' pageStartedAt = 0 maxScrollRatio = 0 } @@ -223,7 +245,7 @@ const sendAnalyticsPayload = (endpoint, payload) => { /** * pageview 이벤트를 전송한다. - * @param {{ path: string, postSlug?: string, pageSlug?: string, read?: boolean }} payload - 전송 본문 + * @param {{ path: string, postSlug?: string, pageSlug?: string, contentSessionId?: string, read?: boolean }} payload - 전송 본문 * @returns {void} */ const sendPageviewEvent = (payload) => { @@ -231,6 +253,8 @@ const sendPageviewEvent = (payload) => { path: payload.path, postSlug: payload.postSlug || '', pageSlug: payload.pageSlug || '', + clientSessionId: getClientSessionId(), + contentSessionId: payload.contentSessionId || '', referrer: getInitialReferrer(), currentUrl: window.location.href, read: Boolean(payload.read) @@ -239,7 +263,7 @@ const sendPageviewEvent = (payload) => { /** * heartbeat 이벤트를 전송한다. - * @param {{ path: string, postSlug?: string, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문 + * @param {{ path: string, postSlug?: string, pageSlug?: string, contentSessionId?: string, durationSeconds: number, maxScrollRatio: number }} payload - 전송 본문 * @returns {void} */ const sendHeartbeatEvent = (payload) => { @@ -248,6 +272,7 @@ const sendHeartbeatEvent = (payload) => { postSlug: payload.postSlug || '', pageSlug: payload.pageSlug || '', clientSessionId: getClientSessionId(), + contentSessionId: payload.contentSessionId || '', durationSeconds: payload.durationSeconds, maxScrollRatio: payload.maxScrollRatio }) @@ -268,6 +293,7 @@ const sendCurrentHeartbeat = () => { path: currentPath, postSlug: currentPostSlug, pageSlug: currentPageSlug, + contentSessionId: currentContentSessionId, durationSeconds: getCurrentDurationSeconds(), maxScrollRatio }) @@ -278,7 +304,7 @@ const sendCurrentHeartbeat = () => { * @returns {void} */ const trySendReadEvent = () => { - if (!readPostSlug || sentReadSlugs.has(readPostSlug)) { + if (!readPostSlug || !currentContentSessionId || sentReadContentSessionIds.has(currentContentSessionId)) { return } @@ -293,10 +319,11 @@ const trySendReadEvent = () => { return } - sentReadSlugs.add(readPostSlug) + sentReadContentSessionIds.add(currentContentSessionId) sendPageviewEvent({ path: readPath, postSlug: readPostSlug, + contentSessionId: currentContentSessionId, read: true }) } @@ -356,13 +383,21 @@ const trackRouteAnalytics = (route) => { const postSlug = extractPostSlugFromRoute(route) const pageSlug = postSlug ? '' : extractPageSlugFromRoute(route) const viewKey = `view:${path}:${postSlug}:${pageSlug}` + const now = Date.now() + const shouldSendPageview = viewKey !== lastPageviewKey || now - lastPageviewSentAt > 1000 + const contentSessionId = shouldSendPageview + ? createContentSessionId() + : lastPageviewContentSessionId || createContentSessionId() - if (!sentViewKeys.has(viewKey)) { - sentViewKeys.add(viewKey) + if (shouldSendPageview) { + lastPageviewKey = viewKey + lastPageviewSentAt = now + lastPageviewContentSessionId = contentSessionId sendPageviewEvent({ path, postSlug, pageSlug, + contentSessionId, read: false }) } @@ -372,6 +407,7 @@ const trackRouteAnalytics = (route) => { currentPath = path currentPostSlug = postSlug currentPageSlug = pageSlug + currentContentSessionId = contentSessionId pageStartedAt = Date.now() maxScrollRatio = getDocumentScrollRatio() diff --git a/server/repositories/analytics-repository.js b/server/repositories/analytics-repository.js index 650556e..ad9a706 100644 --- a/server/repositories/analytics-repository.js +++ b/server/repositories/analytics-repository.js @@ -5,6 +5,7 @@ import { ANALYTICS_ENGAGED_MIN_SECONDS, ANALYTICS_RETENTION_PURGE_INTERVAL_MS, ANALYTICS_VISITOR_HASH_RETENTION_DAYS, + createContentSessionHash, clampAnalyticsDurationSeconds, clampAnalyticsScrollRatio, createDailyVisitorHash, @@ -39,6 +40,15 @@ const getAnalyticsHashSecret = () => { ).trim() } +/** + * 비어 있지 않은 통계 세션 ID인지 확인한다. + * @param {string} value - 세션 ID 후보 + * @returns {boolean} 사용 가능 여부 + */ +const hasAnalyticsSessionId = (value) => { + return Boolean(String(value || '').trim()) +} + /** * 통계 집계 시작일을 반환한다. * @returns {string} YYYY-MM-DD @@ -368,7 +378,7 @@ const recordPostAnalyticsTraffic = async (sql, day, postId, visitorHash, traffic /** * 페이지뷰·읽음 이벤트를 기록한다. - * @param {{ visitorHash: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력 + * @param {{ visitorHash: string, sessionHash?: string, contentSessionHash?: string, path?: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string }, recordSite?: boolean, recordView?: boolean, recordRead?: boolean }} input - 기록 입력 * @returns {Promise<{ ok: true }>} */ export const recordAnalyticsPageview = async (input) => { @@ -380,14 +390,41 @@ export const recordAnalyticsPageview = async (input) => { const day = getAnalyticsDayKey() const visitorHash = input.visitorHash + const sessionHash = input.sessionHash || '' + const contentSessionHash = input.contentSessionHash || '' + const path = input.path || '' const postId = input.postId || null const pageId = input.pageId || null const recordSite = input.recordSite !== false const recordView = Boolean(input.recordView) const recordRead = Boolean(input.recordRead) const traffic = input.traffic || null + const shouldRecordPageview = recordView || recordSite + ? await markContentSessionPageview(sql, { + day, + visitorHash, + sessionHash, + contentSessionHash, + path, + postId, + pageId, + traffic + }) + : true + const shouldRecordRead = recordRead + ? await markContentSessionRead(sql, { + day, + visitorHash, + sessionHash, + contentSessionHash, + path, + postId, + pageId, + traffic + }) + : false - if (recordSite) { + if (recordSite && shouldRecordPageview) { await ensureSiteDailyRow(sql, day) const isNewSiteVisitor = await registerSiteVisitor(sql, day, visitorHash) @@ -406,7 +443,7 @@ export const recordAnalyticsPageview = async (input) => { } if (!postId) { - if (pageId && recordView) { + if (pageId && recordView && shouldRecordPageview) { await ensurePageDailyRow(sql, day, pageId) const isNewPageVisitor = await registerPageVisitor(sql, day, pageId, visitorHash) @@ -427,7 +464,7 @@ export const recordAnalyticsPageview = async (input) => { await ensurePostDailyRow(sql, day, postId) - if (recordView) { + if (recordView && shouldRecordPageview) { const isNewPostVisitor = await registerPostVisitor(sql, day, postId, visitorHash) await sql` @@ -444,7 +481,7 @@ export const recordAnalyticsPageview = async (input) => { } } - if (recordRead) { + if (recordRead && shouldRecordRead) { await sql` UPDATE post_analytics_daily SET reads = reads + 1 @@ -490,6 +527,20 @@ export const createSessionHashFromEvent = (event, clientSessionId) => { }) } +/** + * 콘텐츠 단위 세션 해시를 만든다. + * @param {import('h3').H3Event} event - H3 이벤트 + * @param {string} contentSessionId - 콘텐츠 단위 세션 ID + * @returns {string} content session hash + */ +export const createContentSessionHashFromEvent = (event, contentSessionId) => { + return createContentSessionHash({ + contentSessionId: String(contentSessionId || '').trim(), + visitorHash: createVisitorHashFromEvent(event), + secret: getAnalyticsHashSecret() + }) +} + /** * 만료된 실시간 세션 행을 정리한다. * @param {import('postgres').Sql} sql - DB 클라이언트 @@ -522,9 +573,136 @@ const purgeAnalyticsRetention = async (sql) => { WHERE day < ${visitorHashCutoffDay}::date ` + await sql` + DELETE FROM analytics_content_sessions + WHERE day < ${visitorHashCutoffDay}::date + ` + lastAnalyticsRetentionPurgeAt = now } +/** + * 콘텐츠 세션에 pageview 처리를 표시한다. + * @param {import('postgres').Sql} sql - DB 클라이언트 + * @param {{ day: string, contentSessionHash: string, visitorHash: string, sessionHash: string, path: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string } | null }} input - 콘텐츠 세션 입력 + * @returns {Promise} 새 pageview 집계 필요 여부 + */ +const markContentSessionPageview = async (sql, input) => { + if (!hasAnalyticsSessionId(input.contentSessionHash)) { + return true + } + + const traffic = input.traffic || {} + const rows = await sql` + INSERT INTO analytics_content_sessions ( + content_session_hash, + day, + visitor_hash, + session_hash, + path, + post_id, + page_id, + source_group, + source_name, + device_type, + os_name, + keyword, + view_recorded + ) + VALUES ( + ${input.contentSessionHash}, + ${input.day}, + ${input.visitorHash}, + ${input.sessionHash}, + ${input.path}, + ${input.postId || null}, + ${input.pageId || null}, + ${traffic.sourceGroup || ''}, + ${traffic.sourceName || ''}, + ${traffic.deviceType || ''}, + ${traffic.osName || ''}, + ${traffic.keyword || ''}, + true + ) + ON CONFLICT (content_session_hash) DO UPDATE + SET + day = EXCLUDED.day, + visitor_hash = EXCLUDED.visitor_hash, + session_hash = EXCLUDED.session_hash, + path = EXCLUDED.path, + post_id = EXCLUDED.post_id, + page_id = EXCLUDED.page_id, + source_group = EXCLUDED.source_group, + source_name = EXCLUDED.source_name, + device_type = EXCLUDED.device_type, + os_name = EXCLUDED.os_name, + keyword = EXCLUDED.keyword, + view_recorded = true, + last_seen_at = now() + WHERE analytics_content_sessions.view_recorded = false + RETURNING content_session_hash + ` + + return rows.length > 0 +} + +/** + * 콘텐츠 세션에 read 처리를 표시한다. + * @param {import('postgres').Sql} sql - DB 클라이언트 + * @param {{ day: string, contentSessionHash: string, visitorHash: string, sessionHash: string, path: string, postId?: string | null, pageId?: string | null, traffic?: { sourceGroup: string, sourceName: string, deviceType: string, osName: string, keyword?: string } | null }} input - 콘텐츠 세션 입력 + * @returns {Promise} 새 read 집계 필요 여부 + */ +const markContentSessionRead = async (sql, input) => { + if (!hasAnalyticsSessionId(input.contentSessionHash)) { + return true + } + + const traffic = input.traffic || {} + const rows = await sql` + INSERT INTO analytics_content_sessions ( + content_session_hash, + day, + visitor_hash, + session_hash, + path, + post_id, + page_id, + source_group, + source_name, + device_type, + os_name, + keyword, + read_recorded, + read_at + ) + VALUES ( + ${input.contentSessionHash}, + ${input.day}, + ${input.visitorHash}, + ${input.sessionHash}, + ${input.path}, + ${input.postId || null}, + ${input.pageId || null}, + ${traffic.sourceGroup || ''}, + ${traffic.sourceName || ''}, + ${traffic.deviceType || ''}, + ${traffic.osName || ''}, + ${traffic.keyword || ''}, + true, + now() + ) + ON CONFLICT (content_session_hash) DO UPDATE + SET + read_recorded = true, + read_at = COALESCE(analytics_content_sessions.read_at, now()), + last_seen_at = now() + WHERE analytics_content_sessions.read_recorded = false + RETURNING content_session_hash + ` + + return rows.length > 0 +} + /** * 날짜 문자열이 통계 조회 형식인지 확인한다. * @param {string} value - 날짜 문자열 @@ -789,7 +967,7 @@ const incrementPageScrollBuckets = async (sql, day, pageId, columns) => { /** * heartbeat·체류·스크롤·실시간 세션을 기록한다. - * @param {{ event: import('h3').H3Event, sessionHash: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력 + * @param {{ event: import('h3').H3Event, sessionHash: string, contentSessionHash?: string, path: string, postId?: string | null, postSlug?: string, pageId?: string | null, pageSlug?: string, durationSeconds: number, maxScrollRatio: number }} input - 기록 입력 * @returns {Promise<{ ok: true }>} */ export const recordAnalyticsHeartbeat = async (input) => { @@ -801,6 +979,7 @@ export const recordAnalyticsHeartbeat = async (input) => { const day = getAnalyticsDayKey() const sessionHash = input.sessionHash + const contentSessionHash = input.contentSessionHash || '' const path = input.path const postId = input.postId || null const postSlug = input.postSlug || '' @@ -808,25 +987,88 @@ export const recordAnalyticsHeartbeat = async (input) => { const pageSlug = input.pageSlug || '' const durationSeconds = clampAnalyticsDurationSeconds(input.durationSeconds) const maxScrollRatio = clampAnalyticsScrollRatio(input.maxScrollRatio) + const visitorHash = createVisitorHashFromEvent(input.event) const memberSession = getMemberSession(input.event) const userId = memberSession?.userId || null const ipAddress = getClientIp(input.event) - const previousRows = await sql` - SELECT duration_seconds, max_scroll_ratio - FROM analytics_active_sessions - WHERE session_hash = ${sessionHash} - LIMIT 1 - ` + const previousRows = contentSessionHash + ? await sql` + SELECT duration_seconds, max_scroll_ratio, engaged_recorded + FROM analytics_content_sessions + WHERE content_session_hash = ${contentSessionHash} + LIMIT 1 + ` + : await sql` + SELECT duration_seconds, max_scroll_ratio, false AS engaged_recorded + FROM analytics_active_sessions + WHERE session_hash = ${sessionHash} + LIMIT 1 + ` const previousDuration = Number(previousRows[0]?.duration_seconds || 0) const previousScrollRatio = Number(previousRows[0]?.max_scroll_ratio || 0) + const wasEngaged = Boolean(previousRows[0]?.engaged_recorded) const durationDelta = Math.max(0, durationSeconds - previousDuration) const scrollBuckets = getNewScrollBucketColumns(previousScrollRatio, maxScrollRatio) + if (contentSessionHash) { + await sql` + INSERT INTO analytics_content_sessions ( + content_session_hash, + day, + visitor_hash, + session_hash, + path, + post_id, + page_id, + duration_seconds, + max_scroll_ratio, + engaged_recorded, + scroll_25_recorded, + scroll_50_recorded, + scroll_75_recorded, + scroll_100_recorded + ) + VALUES ( + ${contentSessionHash}, + ${day}, + ${visitorHash}, + ${sessionHash}, + ${path}, + ${postId}, + ${pageId}, + ${durationSeconds}, + ${maxScrollRatio}, + ${durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS}, + ${maxScrollRatio >= 0.25}, + ${maxScrollRatio >= 0.5}, + ${maxScrollRatio >= 0.75}, + ${maxScrollRatio >= 1} + ) + ON CONFLICT (content_session_hash) DO UPDATE + SET + day = EXCLUDED.day, + visitor_hash = EXCLUDED.visitor_hash, + session_hash = EXCLUDED.session_hash, + path = EXCLUDED.path, + post_id = EXCLUDED.post_id, + page_id = EXCLUDED.page_id, + duration_seconds = GREATEST(analytics_content_sessions.duration_seconds, EXCLUDED.duration_seconds), + max_scroll_ratio = GREATEST(analytics_content_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio), + engaged_recorded = analytics_content_sessions.engaged_recorded OR EXCLUDED.engaged_recorded, + scroll_25_recorded = analytics_content_sessions.scroll_25_recorded OR EXCLUDED.scroll_25_recorded, + scroll_50_recorded = analytics_content_sessions.scroll_50_recorded OR EXCLUDED.scroll_50_recorded, + scroll_75_recorded = analytics_content_sessions.scroll_75_recorded OR EXCLUDED.scroll_75_recorded, + scroll_100_recorded = analytics_content_sessions.scroll_100_recorded OR EXCLUDED.scroll_100_recorded, + last_seen_at = now() + ` + } + await sql` INSERT INTO analytics_active_sessions ( session_hash, + content_session_hash, user_id, path, post_id, @@ -839,6 +1081,7 @@ export const recordAnalyticsHeartbeat = async (input) => { ) VALUES ( ${sessionHash}, + ${contentSessionHash}, ${userId}, ${path}, ${postId}, @@ -852,14 +1095,23 @@ export const recordAnalyticsHeartbeat = async (input) => { ON CONFLICT (session_hash) DO UPDATE SET user_id = COALESCE(EXCLUDED.user_id, analytics_active_sessions.user_id), + content_session_hash = EXCLUDED.content_session_hash, path = EXCLUDED.path, post_id = EXCLUDED.post_id, post_slug = EXCLUDED.post_slug, page_id = EXCLUDED.page_id, page_slug = EXCLUDED.page_slug, ip_address = EXCLUDED.ip_address, - duration_seconds = GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds), - max_scroll_ratio = GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio), + duration_seconds = CASE + WHEN analytics_active_sessions.content_session_hash = EXCLUDED.content_session_hash + THEN GREATEST(analytics_active_sessions.duration_seconds, EXCLUDED.duration_seconds) + ELSE EXCLUDED.duration_seconds + END, + max_scroll_ratio = CASE + WHEN analytics_active_sessions.content_session_hash = EXCLUDED.content_session_hash + THEN GREATEST(analytics_active_sessions.max_scroll_ratio, EXCLUDED.max_scroll_ratio) + ELSE EXCLUDED.max_scroll_ratio + END, last_seen_at = now() ` @@ -872,7 +1124,9 @@ export const recordAnalyticsHeartbeat = async (input) => { WHERE day = ${day} ` - const wasSiteEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS + const wasSiteEngaged = contentSessionHash + ? wasEngaged + : previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS const isSiteEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS if (!wasSiteEngaged && isSiteEngaged) { @@ -895,7 +1149,9 @@ export const recordAnalyticsHeartbeat = async (input) => { AND post_id = ${postId} ` - const wasPostEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS + const wasPostEngaged = contentSessionHash + ? wasEngaged + : previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS const isPostEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS if (!wasPostEngaged && isPostEngaged) { @@ -922,7 +1178,9 @@ export const recordAnalyticsHeartbeat = async (input) => { AND page_id = ${pageId} ` - const wasPageEngaged = previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS + const wasPageEngaged = contentSessionHash + ? wasEngaged + : previousDuration >= ANALYTICS_ENGAGED_MIN_SECONDS const isPageEngaged = durationSeconds >= ANALYTICS_ENGAGED_MIN_SECONDS if (!wasPageEngaged && isPageEngaged) { diff --git a/server/utils/analytics-heartbeat-input.js b/server/utils/analytics-heartbeat-input.js index 69c9e91..c807dbb 100644 --- a/server/utils/analytics-heartbeat-input.js +++ b/server/utils/analytics-heartbeat-input.js @@ -7,6 +7,7 @@ import { } from '../../lib/analytics.js' import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js' import { + createContentSessionHashFromEvent, createSessionHashFromEvent, recordAnalyticsHeartbeat } from '../repositories/analytics-repository.js' @@ -18,6 +19,7 @@ const heartbeatInputSchema = z.object({ postSlug: z.string().trim().max(200).optional().default(''), pageSlug: z.string().trim().max(200).optional().default(''), clientSessionId: z.string().trim().min(8).max(120), + contentSessionId: z.string().trim().max(120).optional().default(''), durationSeconds: z.number().int().min(0).max(1800), maxScrollRatio: z.number().min(0).max(1) }) @@ -78,10 +80,14 @@ export const handleAnalyticsHeartbeat = async (event) => { } const sessionHash = createSessionHashFromEvent(event, body.clientSessionId) + const contentSessionHash = body.contentSessionId + ? createContentSessionHashFromEvent(event, body.contentSessionId) + : '' await recordAnalyticsHeartbeat({ event, sessionHash, + contentSessionHash, path: body.path, postId, postSlug, diff --git a/server/utils/analytics-pageview-input.js b/server/utils/analytics-pageview-input.js index 562f421..6dc32c0 100644 --- a/server/utils/analytics-pageview-input.js +++ b/server/utils/analytics-pageview-input.js @@ -11,6 +11,8 @@ import { } from '../../lib/analytics-traffic.js' import { getPageBySlug, getPostBySlug } from '../repositories/content-repository.js' import { + createContentSessionHashFromEvent, + createSessionHashFromEvent, createVisitorHashFromEvent, recordAnalyticsPageview } from '../repositories/analytics-repository.js' @@ -21,6 +23,8 @@ const pageviewInputSchema = z.object({ path: z.string().trim().min(1).max(500), postSlug: z.string().trim().max(200).optional().default(''), pageSlug: z.string().trim().max(200).optional().default(''), + clientSessionId: z.string().trim().max(120).optional().default(''), + contentSessionId: z.string().trim().max(120).optional().default(''), referrer: z.string().trim().max(1000).optional().default(''), currentUrl: z.string().trim().max(1000).optional().default(''), read: z.boolean().optional().default(false) @@ -82,6 +86,12 @@ export const handleAnalyticsPageview = async (event) => { } const visitorHash = createVisitorHashFromEvent(event) + const sessionHash = body.clientSessionId + ? createSessionHashFromEvent(event, body.clientSessionId) + : '' + const contentSessionHash = body.contentSessionId + ? createContentSessionHashFromEvent(event, body.contentSessionId) + : '' const isReadEvent = Boolean(body.read) const traffic = { ...classifyAnalyticsTrafficSource({ @@ -93,6 +103,9 @@ export const handleAnalyticsPageview = async (event) => { await recordAnalyticsPageview({ visitorHash, + sessionHash, + contentSessionHash, + path: body.path, postId, pageId, traffic,