From 767d36eb819a193adbef6da94820f72b093c25a5 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Pavel=20Pot=C3=A1=C4=8Dek?= Date: Wed, 4 Dec 2024 10:53:52 +0100 Subject: [PATCH] feat: add option to save session data to local storage (#900) --- packages/web/src/cookie-session.ts | 124 ++++++++++++++++++ packages/web/src/index.ts | 12 +- packages/web/src/local-storage-session.ts | 62 +++++++++ packages/web/src/session.ts | 101 +++----------- packages/web/src/types.ts | 23 ++++ packages/web/src/utils.ts | 19 --- packages/web/src/utils/storage.ts | 30 +++++ packages/web/test/SessionBasedSampler.test.ts | 3 +- packages/web/test/session.test.ts | 40 ++++-- packages/web/test/utils.test.ts | 3 +- 10 files changed, 304 insertions(+), 113 deletions(-) create mode 100644 packages/web/src/cookie-session.ts create mode 100644 packages/web/src/local-storage-session.ts create mode 100644 packages/web/src/types.ts diff --git a/packages/web/src/cookie-session.ts b/packages/web/src/cookie-session.ts new file mode 100644 index 00000000..6dc7cf8f --- /dev/null +++ b/packages/web/src/cookie-session.ts @@ -0,0 +1,124 @@ +/** + * + * Copyright 2024 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { isIframe } from './utils' +import { SessionState } from './types' + +export const COOKIE_NAME = '_splunk_rum_sid' + +const CookieSession = 4 * 60 * 60 * 1000 // 4 hours +const InactivityTimeoutSeconds = 15 * 60 + +export const cookieStore = { + set: (value: string): void => { + document.cookie = value + }, + get: (): string => document.cookie, +} + +export function parseCookieToSessionState(): SessionState | undefined { + const rawValue = findCookieValue(COOKIE_NAME) + if (!rawValue) { + return undefined + } + + const decoded = decodeURIComponent(rawValue) + if (!decoded) { + return undefined + } + + let sessionState: unknown = undefined + try { + sessionState = JSON.parse(decoded) + } catch { + return undefined + } + + if (!isSessionState(sessionState)) { + return undefined + } + + // id validity + if ( + !sessionState.id || + typeof sessionState.id !== 'string' || + !sessionState.id.length || + sessionState.id.length !== 32 + ) { + return undefined + } + + // startTime validity + if (!sessionState.startTime || typeof sessionState.startTime !== 'number' || isPastMaxAge(sessionState.startTime)) { + return undefined + } + + return sessionState +} + +export function renewCookieTimeout(sessionState: SessionState, cookieDomain: string | undefined): void { + if (isPastMaxAge(sessionState.startTime)) { + // safety valve + return + } + + const cookieValue = encodeURIComponent(JSON.stringify(sessionState)) + const domain = cookieDomain ? `domain=${cookieDomain};` : '' + let cookie = COOKIE_NAME + '=' + cookieValue + '; path=/;' + domain + 'max-age=' + InactivityTimeoutSeconds + + if (isIframe()) { + cookie += ';SameSite=None; Secure' + } else { + cookie += ';SameSite=Strict' + } + + cookieStore.set(cookie) +} + +export function clearSessionCookie(cookieDomain?: string): void { + const domain = cookieDomain ? `domain=${cookieDomain};` : '' + const cookie = `${COOKIE_NAME}=;domain=${domain};expires=Thu, 01 Jan 1970 00:00:00 GMT` + cookieStore.set(cookie) +} + +export function findCookieValue(cookieName: string): string | undefined { + const decodedCookie = decodeURIComponent(cookieStore.get()) + const cookies = decodedCookie.split(';') + for (let i = 0; i < cookies.length; i++) { + const c = cookies[i].trim() + if (c.indexOf(cookieName + '=') === 0) { + return c.substring((cookieName + '=').length, c.length) + } + } + return undefined +} + +function isPastMaxAge(startTime: number): boolean { + const now = Date.now() + return startTime > now || now > startTime + CookieSession +} + +function isSessionState(maybeSessionState: unknown): maybeSessionState is SessionState { + return ( + typeof maybeSessionState === 'object' && + maybeSessionState !== null && + 'id' in maybeSessionState && + typeof maybeSessionState['id'] === 'string' && + 'startTime' in maybeSessionState && + typeof maybeSessionState['startTime'] === 'number' + ) +} diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index da270cbe..b1cb2b35 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -46,7 +46,7 @@ import { SplunkExporterConfig } from './exporters/common' import { SplunkZipkinExporter } from './exporters/zipkin' import { ERROR_INSTRUMENTATION_NAME, SplunkErrorInstrumentation } from './SplunkErrorInstrumentation' import { generateId, getPluginConfig } from './utils' -import { getRumSessionId, initSessionTracking, SessionIdType } from './session' +import { getRumSessionId, initSessionTracking } from './session' import { SplunkWebSocketInstrumentation } from './SplunkWebSocketInstrumentation' import { WebVitalsInstrumentationConfig, initWebVitals } from './webvitals' import { SplunkLongTaskInstrumentation } from './SplunkLongTaskInstrumentation' @@ -75,6 +75,7 @@ import { import { SplunkOTLPTraceExporter } from './exporters/otlp' import { registerGlobal, unregisterGlobal } from './global-utils' import { BrowserInstanceService } from './services/BrowserInstanceService' +import { SessionId } from './types' export { SplunkExporterConfig } from './exporters/common' export { SplunkZipkinExporter } from './exporters/zipkin' @@ -193,6 +194,9 @@ export interface SplunkOtelWebConfig { */ tracer?: WebTracerConfig + /** Use local storage to save session ID instead of cookie */ + useLocalStorage?: boolean + /** * Sets a value for the 'app.version' attribute */ @@ -316,7 +320,7 @@ export interface SplunkOtelWebType extends SplunkOtelWebEventTarget { /** * @deprecated Use {@link getSessionId()} */ - _experimental_getSessionId: () => SessionIdType | undefined + _experimental_getSessionId: () => SessionId | undefined /** * Allows experimental options to be passed. No versioning guarantees are given for this method. @@ -337,7 +341,7 @@ export interface SplunkOtelWebType extends SplunkOtelWebEventTarget { /** * This method returns current session ID */ - getSessionId: () => SessionIdType | undefined + getSessionId: () => SessionId | undefined init: (options: SplunkOtelWebConfig) => void @@ -470,12 +474,14 @@ export const SplunkRum: SplunkOtelWebType = { resource: this.resource, }) + // TODO _deinitSessionTracking = initSessionTracking( provider, instanceId, eventTarget, processedOptions.cookieDomain, !!options._experimental_allSpansExtendSession, + processedOptions.useLocalStorage, ).deinit const instrumentations = INSTRUMENTATIONS.map(({ Instrument, confKey, disable }) => { diff --git a/packages/web/src/local-storage-session.ts b/packages/web/src/local-storage-session.ts new file mode 100644 index 00000000..bedd0b1c --- /dev/null +++ b/packages/web/src/local-storage-session.ts @@ -0,0 +1,62 @@ +/** + * + * Copyright 2024 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +import { SessionState } from './types' +import { safelyGetLocalStorage, safelySetLocalStorage, safelyRemoveFromLocalStorage } from './utils/storage' + +const SESSION_ID_LENGTH = 32 +const SESSION_DURATION_MS = 4 * 60 * 60 * 1000 // 4 hours + +const SESSION_ID_KEY = '_SPLUNK_SESSION_ID' +const SESSION_LAST_UPDATED_KEY = '_SPLUNK_SESSION_LAST_UPDATED' + +export const getSessionStateFromLocalStorage = (): SessionState | undefined => { + const sessionId = safelyGetLocalStorage(SESSION_ID_KEY) + if (!isSessionIdValid(sessionId)) { + return + } + + const startTimeString = safelyGetLocalStorage(SESSION_LAST_UPDATED_KEY) + const startTime = Number.parseInt(startTimeString, 10) + if (!isSessionStartTimeValid(startTime) || isSessionExpired(startTime)) { + return + } + + return { id: sessionId, startTime } +} + +export const setSessionStateToLocalStorage = (sessionState: SessionState): void => { + if (isSessionExpired(sessionState.startTime)) { + return + } + + safelySetLocalStorage(SESSION_ID_KEY, sessionState.id) + safelySetLocalStorage(SESSION_LAST_UPDATED_KEY, String(sessionState.startTime)) +} + +export const clearSessionStateFromLocalStorage = (): void => { + safelyRemoveFromLocalStorage(SESSION_ID_KEY) + safelyRemoveFromLocalStorage(SESSION_LAST_UPDATED_KEY) +} + +const isSessionIdValid = (sessionId: unknown): boolean => + typeof sessionId === 'string' && sessionId.length === SESSION_ID_LENGTH + +const isSessionStartTimeValid = (startTime: unknown): boolean => + typeof startTime === 'number' && startTime <= Date.now() + +const isSessionExpired = (startTime: number) => Date.now() - startTime > SESSION_DURATION_MS diff --git a/packages/web/src/session.ts b/packages/web/src/session.ts index 8944bc5d..e6953daa 100644 --- a/packages/web/src/session.ts +++ b/packages/web/src/session.ts @@ -18,7 +18,10 @@ import { SpanProcessor, WebTracerProvider } from '@opentelemetry/sdk-trace-web' import { InternalEventTarget } from './EventTarget' -import { cookieStore, findCookieValue, generateId, isIframe } from './utils' +import { generateId } from './utils' +import { parseCookieToSessionState, renewCookieTimeout } from './cookie-session' +import { SessionState, SessionId } from './types' +import { getSessionStateFromLocalStorage, setSessionStateToLocalStorage } from './local-storage-session' /* The basic idea is to let the browser expire cookies for us "naturally" once @@ -39,13 +42,9 @@ import { cookieStore, findCookieValue, generateId, isIframe } from './utils' with setting cookies, checking for inactivity, etc. */ -const MaxSessionAgeMillis = 4 * 60 * 60 * 1000 -const InactivityTimeoutSeconds = 15 * 60 -const PeriodicCheckSeconds = 60 -export const COOKIE_NAME = '_splunk_rum_sid' +const periodicCheckSeconds = 60 -export type SessionIdType = string -let rumSessionId: SessionIdType | undefined +let rumSessionId: SessionId | undefined let recentActivity = false let cookieDomain: string let eventTarget: InternalEventTarget | undefined @@ -54,86 +53,21 @@ export function markActivity(): void { recentActivity = true } -function pastMaxAge(startTime: number): boolean { - const now = Date.now() - return startTime > now || now > startTime + MaxSessionAgeMillis -} - -function parseCookieToSessionState() { - const rawValue = findCookieValue(COOKIE_NAME) - if (!rawValue) { - return undefined - } - - const decoded = decodeURIComponent(rawValue) - if (!decoded) { - return undefined - } - - let ss: any = undefined - try { - ss = JSON.parse(decoded) - } catch { - return undefined - } - // should exist and be an object - if (!ss || typeof ss !== 'object') { - return undefined - } - - // id validity - if (!ss.id || typeof ss.id !== 'string' || !ss.id.length || ss.id.length !== 32) { - return undefined - } - - // startTime validity - if (!ss.startTime || typeof ss.startTime !== 'number' || pastMaxAge(ss.startTime)) { - return undefined - } - - return ss -} - -function newSessionState() { +function createSessionState(): SessionState { return { id: generateId(128), startTime: Date.now(), } } -function renewCookieTimeout(sessionState) { - if (pastMaxAge(sessionState.startTime)) { - // safety valve - return - } - - const cookieValue = encodeURIComponent(JSON.stringify(sessionState)) - const domain = cookieDomain ? `domain=${cookieDomain};` : '' - let cookie = COOKIE_NAME + '=' + cookieValue + '; path=/;' + domain + 'max-age=' + InactivityTimeoutSeconds - - if (isIframe()) { - cookie += ';SameSite=None; Secure' - } else { - cookie += ';SameSite=Strict' - } - - cookieStore.set(cookie) -} - -export function clearSessionCookie(): void { - const domain = cookieDomain ? `domain=${cookieDomain};` : '' - const cookie = `${COOKIE_NAME}=;domain=${domain};expires=Thu, 01 Jan 1970 00:00:00 GMT` - cookieStore.set(cookie) -} - // This is called periodically and has two purposes: // 1) Check if the cookie has been expired by the browser; if so, create a new one // 2) If activity has occured since the last periodic invocation, renew the cookie timeout // (Only exported for testing purposes.) -export function updateSessionStatus(): void { - let sessionState = parseCookieToSessionState() +export function updateSessionStatus(useLocalStorage = false): void { + let sessionState = useLocalStorage ? getSessionStateFromLocalStorage() : parseCookieToSessionState() if (!sessionState) { - sessionState = newSessionState() + sessionState = createSessionState() recentActivity = true // force write of new cookie } @@ -141,7 +75,11 @@ export function updateSessionStatus(): void { eventTarget?.emit('session-changed', { sessionId: rumSessionId }) if (recentActivity) { - renewCookieTimeout(sessionState) + if (useLocalStorage) { + setSessionStateToLocalStorage(sessionState) + } else { + renewCookieTimeout(sessionState, cookieDomain) + } } recentActivity = false @@ -171,10 +109,11 @@ const ACTIVITY_EVENTS = ['click', 'scroll', 'mousedown', 'keydown', 'touchend', export function initSessionTracking( provider: WebTracerProvider, - instanceId: SessionIdType, + instanceId: SessionId, newEventTarget: InternalEventTarget, domain?: string, allSpansAreActivity = false, + useLocalStorage = false, ): { deinit: () => void } { if (hasNativeSessionId()) { // short-circuit and bail out - don't create cookie, watch for inactivity, or anything @@ -198,8 +137,8 @@ export function initSessionTracking( provider.addSpanProcessor(new ActivitySpanProcessor()) } - updateSessionStatus() - const intervalHandle = setInterval(() => updateSessionStatus(), PeriodicCheckSeconds * 1000) + updateSessionStatus(useLocalStorage) + const intervalHandle = setInterval(() => updateSessionStatus(useLocalStorage), periodicCheckSeconds * 1000) return { deinit: () => { @@ -211,7 +150,7 @@ export function initSessionTracking( } } -export function getRumSessionId(): SessionIdType | undefined { +export function getRumSessionId(): SessionId | undefined { if (hasNativeSessionId()) { return window['SplunkRumNative'].getNativeSessionId() } diff --git a/packages/web/src/types.ts b/packages/web/src/types.ts new file mode 100644 index 00000000..d3f920bd --- /dev/null +++ b/packages/web/src/types.ts @@ -0,0 +1,23 @@ +/** + * + * Copyright 2024 Splunk Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ +export type SessionId = string + +export type SessionState = { + id: SessionId + startTime: number +} diff --git a/packages/web/src/utils.ts b/packages/web/src/utils.ts index 8ddc6296..597a7f15 100644 --- a/packages/web/src/utils.ts +++ b/packages/web/src/utils.ts @@ -25,25 +25,6 @@ export function generateId(bits: number): string { }) } -export const cookieStore = { - set: (value: string): void => { - document.cookie = value - }, - get: (): string => document.cookie, -} - -export function findCookieValue(cookieName: string): string | undefined { - const decodedCookie = decodeURIComponent(cookieStore.get()) - const cookies = decodedCookie.split(';') - for (let i = 0; i < cookies.length; i++) { - const c = cookies[i].trim() - if (c.indexOf(cookieName + '=') === 0) { - return c.substring((cookieName + '=').length, c.length) - } - } - return undefined -} - export function limitLen(s: string, cap: number): string { if (s.length > cap) { return s.substring(0, cap) diff --git a/packages/web/src/utils/storage.ts b/packages/web/src/utils/storage.ts index ceeb9ded..250feb3e 100644 --- a/packages/web/src/utils/storage.ts +++ b/packages/web/src/utils/storage.ts @@ -15,6 +15,36 @@ * limitations under the License. * */ +export const safelyGetLocalStorage = (key: string): string | null => { + let value = null + try { + value = window.localStorage.getItem(key) + } catch { + // localStorage not accessible probably user is in incognito-mode + // or set "Block third-party cookies" option in browser settings + } + return value +} + +export const safelySetLocalStorage = (key: string, value: string): boolean => { + try { + window.localStorage.setItem(key, value) + return true + } catch { + // localStorage not accessible probably user is in incognito-mode + // or set "Block third-party cookies" option in browser settings + return false + } +} + +export const safelyRemoveFromLocalStorage = (key: string): void => { + try { + window.localStorage.removeItem(key) + } catch { + // localStorage not accessible probably user is in incognito-mode + // or set "Block third-party cookies" option in browser settings + } +} export const safelyGetSessionStorage = (key: string): string | null | undefined => { try { diff --git a/packages/web/test/SessionBasedSampler.test.ts b/packages/web/test/SessionBasedSampler.test.ts index c1eac885..a278af2f 100644 --- a/packages/web/test/SessionBasedSampler.test.ts +++ b/packages/web/test/SessionBasedSampler.test.ts @@ -19,9 +19,10 @@ import * as assert from 'assert' import { InternalEventTarget } from '../src/EventTarget' import { SessionBasedSampler } from '../src/SessionBasedSampler' -import { initSessionTracking, COOKIE_NAME, updateSessionStatus } from '../src/session' +import { initSessionTracking, updateSessionStatus } from '../src/session' import { context, SamplingDecision } from '@opentelemetry/api' import { SplunkWebTracerProvider } from '../src' +import { COOKIE_NAME } from '../src/cookie-session' describe('Session based sampler', () => { it('decide sampling based on session id and ratio', () => { diff --git a/packages/web/test/session.test.ts b/packages/web/test/session.test.ts index cb56f987..c7bb335a 100644 --- a/packages/web/test/session.test.ts +++ b/packages/web/test/session.test.ts @@ -18,16 +18,11 @@ import * as assert from 'assert' import { InternalEventTarget } from '../src/EventTarget' -import { - initSessionTracking, - COOKIE_NAME, - getRumSessionId, - updateSessionStatus, - clearSessionCookie, -} from '../src/session' +import { initSessionTracking, getRumSessionId, updateSessionStatus } from '../src/session' import { SplunkWebTracerProvider } from '../src' import sinon from 'sinon' -import { cookieStore } from '../src/utils' +import { COOKIE_NAME, clearSessionCookie, cookieStore } from '../src/cookie-session' +import { clearSessionStateFromLocalStorage } from '../src/local-storage-session' describe('Session tracking', () => { beforeEach(() => { @@ -110,3 +105,32 @@ describe('Session tracking', () => { }) }) }) + +describe('Session tracking - localStorage', () => { + beforeEach(() => { + clearSessionStateFromLocalStorage() + }) + + afterEach(() => { + clearSessionStateFromLocalStorage() + }) + + it('should save session state to local storage', () => { + const useLocalStorage = true + const provider = new SplunkWebTracerProvider() + const trackingHandle = initSessionTracking( + provider, + '1234', + new InternalEventTarget(), + undefined, + undefined, + useLocalStorage, + ) + + const firstSessionId = getRumSessionId() + updateSessionStatus(useLocalStorage) + assert.strictEqual(firstSessionId, getRumSessionId()) + + trackingHandle.deinit() + }) +}) diff --git a/packages/web/test/utils.test.ts b/packages/web/test/utils.test.ts index ff7a2078..6b36a62b 100644 --- a/packages/web/test/utils.test.ts +++ b/packages/web/test/utils.test.ts @@ -17,7 +17,8 @@ */ import * as assert from 'assert' -import { findCookieValue, generateId } from '../src/utils' +import { generateId } from '../src/utils' +import { findCookieValue } from '../src/cookie-session' describe('generateId', () => { it('should generate IDs of 64 and 128 bits', () => {