Skip to content

Commit

Permalink
feat: add option to save session data to local storage (#900)
Browse files Browse the repository at this point in the history
  • Loading branch information
potty authored Dec 4, 2024
1 parent c9b430c commit 767d36e
Show file tree
Hide file tree
Showing 10 changed files with 304 additions and 113 deletions.
124 changes: 124 additions & 0 deletions packages/web/src/cookie-session.ts
Original file line number Diff line number Diff line change
@@ -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'
)
}
12 changes: 9 additions & 3 deletions packages/web/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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
*/
Expand Down Expand Up @@ -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.
Expand All @@ -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

Expand Down Expand Up @@ -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 }) => {
Expand Down
62 changes: 62 additions & 0 deletions packages/web/src/local-storage-session.ts
Original file line number Diff line number Diff line change
@@ -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
Loading

0 comments on commit 767d36e

Please sign in to comment.