Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add option to save session data to local storage #900

Merged
merged 2 commits into from
Dec 4, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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 ||
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do you need this here... I guess it is covered with !== 32

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was part of the original code, I'm refactoring this in upcoming PR.

sessionState.id.length !== 32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

could you please use SESSION_ID_LENGTH? I mean can be done in the upcoming PR

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Sure, it will be improved in upcoming PR.

) {
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
Loading