diff --git a/.size-limit.cjs b/.size-limit.cjs index 402e9322..c080c884 100644 --- a/.size-limit.cjs +++ b/.size-limit.cjs @@ -24,13 +24,13 @@ module.exports = [ { name: 'artifacts/splunk-otel-web.js', - limit: '40 kB', + limit: '41 kB', path: './packages/web/dist/artifacts/splunk-otel-web.js', }, { name: 'artifacts/splunk-otel-web.js', - limit: '72 kB', + limit: '73 kB', path: './packages/web/dist/artifacts/splunk-otel-web-legacy.js', }, diff --git a/packages/session-recorder/src/index.ts b/packages/session-recorder/src/index.ts index fcec65ae..31ab2194 100644 --- a/packages/session-recorder/src/index.ts +++ b/packages/session-recorder/src/index.ts @@ -194,6 +194,10 @@ const SplunkRumRecorder = { return } + if (SplunkRum._internalOnExternalSpanCreated) { + SplunkRum._internalOnExternalSpanCreated() + } + // Safeguards from our ingest getting DDOSed: // 1. A session can send up to 4 hours of data // 2. Recording resumes on session change if it isn't a background tab (session regenerated in an another tab) diff --git a/packages/web/integration-tests/tests/cookies/cookies.spec.js b/packages/web/integration-tests/tests/cookies/cookies.spec.js index 2d6111eb..1f478aa5 100644 --- a/packages/web/integration-tests/tests/cookies/cookies.spec.js +++ b/packages/web/integration-tests/tests/cookies/cookies.spec.js @@ -80,22 +80,27 @@ module.exports = { } /* - We are using nip.io to let us test subdomains not sure how reliable it is, so if + We are using nip.io to let us test subdomains not sure how reliable it is, so if you are debugging flaky test then this should be your first guess. cookies-domain.ejs has cookieDomain set to 127.0.0.1.nip.io, cookie set via cookieDomain - should be accessible for subdomains also so when we go to test. subdomain we should find the same + should be accessible for subdomains also so when we go to test. subdomain we should find the same cookie. */ const protocol = browser.globals.enableHttps ? 'https' : 'http' await browser.url(`${protocol}://127.0.0.1.nip.io:${browser.globals.httpPort}/cookies/cookies-domain.ejs`) const cookie = await browser.getCookie('_splunk_rum_sid') + const cookieParse = decodeURI(cookie.value) + await browser.assert.ok(cookie) await browser.url(`${protocol}://test.127.0.0.1.nip.io:${browser.globals.httpPort}/cookies/cookies-domain.ejs`) const cookie2 = await browser.getCookie('_splunk_rum_sid') + const cookie2Parse = decodeURI(cookie2.value) + await browser.assert.strictEqual(cookie.domain, cookie2.domain) - await browser.assert.strictEqual(cookie.value, cookie2.value) + await browser.assert.strictEqual(cookieParse.id, cookie2Parse.id) + await browser.assert.strictEqual(cookieParse.startTime, cookie2Parse.startTime) await browser.globals.assertNoErrorSpans() }, diff --git a/packages/web/src/SplunkContextManager.ts b/packages/web/src/SplunkContextManager.ts index 46f18d8d..fab6dfb0 100644 --- a/packages/web/src/SplunkContextManager.ts +++ b/packages/web/src/SplunkContextManager.ts @@ -19,13 +19,7 @@ import { Context, ContextManager, ROOT_CONTEXT } from '@opentelemetry/api' import { unwrap } from 'shimmer' import { getOriginalFunction, isFunction, wrapNatively } from './utils' - -export interface ContextManagerConfig { - /** Enable async tracking of span parents */ - async?: boolean - onBeforeContextEnd?: () => void - onBeforeContextStart?: () => void -} +import { ContextManagerConfig } from './types' type EventListenerWithOrig = EventListener & { _orig?: EventListener } diff --git a/packages/web/src/SplunkLongTaskInstrumentation.ts b/packages/web/src/SplunkLongTaskInstrumentation.ts index 270ef4d1..a7e0c72c 100644 --- a/packages/web/src/SplunkLongTaskInstrumentation.ts +++ b/packages/web/src/SplunkLongTaskInstrumentation.ts @@ -19,6 +19,8 @@ import { InstrumentationBase, InstrumentationConfig } from '@opentelemetry/instrumentation' import { VERSION } from './version' +import { getCurrentSessionState } from './session' +import { SplunkOtelWebConfig } from './types' const LONGTASK_PERFORMANCE_TYPE = 'longtask' const MODULE_NAME = 'splunk-longtask' @@ -26,8 +28,12 @@ const MODULE_NAME = 'splunk-longtask' export class SplunkLongTaskInstrumentation extends InstrumentationBase { private _longtaskObserver: PerformanceObserver | undefined - constructor(config: InstrumentationConfig = {}) { + private initOptions: SplunkOtelWebConfig + + constructor(config: InstrumentationConfig = {}, initOptions: SplunkOtelWebConfig) { super(MODULE_NAME, VERSION, Object.assign({}, config)) + + this.initOptions = initOptions } disable(): void { @@ -52,6 +58,16 @@ export class SplunkLongTaskInstrumentation extends InstrumentationBase { init(): void {} private _createSpanFromEntry(entry: PerformanceEntry) { + if ( + !!this.initOptions._experimental_longtaskNoStartSession && + !getCurrentSessionState({ + forceStoreRead: false, + }) + ) { + // session expired, we do not want to spawn new session from long tasks + return + } + const span = this.tracer.startSpan(LONGTASK_PERFORMANCE_TYPE, { startTime: entry.startTime, }) diff --git a/packages/web/src/index.ts b/packages/web/src/index.ts index b1cb2b35..25ff8aee 100644 --- a/packages/web/src/index.ts +++ b/packages/web/src/index.ts @@ -17,12 +17,11 @@ */ import './polyfill-safari10' -import { InstrumentationConfig, registerInstrumentations } from '@opentelemetry/instrumentation' +import { registerInstrumentations } from '@opentelemetry/instrumentation' import { ConsoleSpanExporter, SimpleSpanProcessor, BatchSpanProcessor, - ReadableSpan, SpanExporter, SpanProcessor, BufferConfig, @@ -30,14 +29,12 @@ import { AlwaysOnSampler, ParentBasedSampler, } from '@opentelemetry/sdk-trace-base' -import { WebTracerConfig } from '@opentelemetry/sdk-trace-web' import { Attributes, diag, DiagConsoleLogger, DiagLogLevel } from '@opentelemetry/api' import { SplunkDocumentLoadInstrumentation } from './SplunkDocumentLoadInstrumentation' import { SplunkXhrPlugin } from './SplunkXhrPlugin' import { SplunkFetchInstrumentation } from './SplunkFetchInstrumentation' import { SplunkUserInteractionInstrumentation, - SplunkUserInteractionInstrumentationConfig, DEFAULT_AUTO_INSTRUMENTED_EVENTS, DEFAULT_AUTO_INSTRUMENTED_EVENT_NAMES, UserInteractionEventsConfig, @@ -46,21 +43,16 @@ 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 } from './session' +import { getRumSessionId, initSessionTracking, updateSessionStatus } from './session' import { SplunkWebSocketInstrumentation } from './SplunkWebSocketInstrumentation' -import { WebVitalsInstrumentationConfig, initWebVitals } from './webvitals' +import { initWebVitals } from './webvitals' import { SplunkLongTaskInstrumentation } from './SplunkLongTaskInstrumentation' import { SplunkPageVisibilityInstrumentation } from './SplunkPageVisibilityInstrumentation' import { SplunkConnectivityInstrumentation } from './SplunkConnectivityInstrumentation' -import { - SplunkPostDocLoadResourceInstrumentation, - SplunkPostDocLoadResourceInstrumentationConfig, -} from './SplunkPostDocLoadResourceInstrumentation' +import { SplunkPostDocLoadResourceInstrumentation } from './SplunkPostDocLoadResourceInstrumentation' import { SplunkWebTracerProvider } from './SplunkWebTracerProvider' -import { FetchInstrumentationConfig } from '@opentelemetry/instrumentation-fetch' -import { XMLHttpRequestInstrumentationConfig } from '@opentelemetry/instrumentation-xml-http-request' import { InternalEventTarget, SplunkOtelWebEventTarget } from './EventTarget' -import { ContextManagerConfig, SplunkContextManager } from './SplunkContextManager' +import { SplunkContextManager } from './SplunkContextManager' import { Resource, ResourceAttributes } from '@opentelemetry/resources' import { SemanticResourceAttributes } from '@opentelemetry/semantic-conventions' import { SDK_INFO, _globalThis } from '@opentelemetry/core' @@ -68,141 +60,18 @@ import { VERSION } from './version' import { getSyntheticsRunId, SYNTHETICS_RUN_ID_ATTRIBUTE } from './synthetics' import { SplunkSpanAttributesProcessor } from './SplunkSpanAttributesProcessor' import { SessionBasedSampler } from './SessionBasedSampler' -import { - SocketIoClientInstrumentationConfig, - SplunkSocketIoClientInstrumentation, -} from './SplunkSocketIoClientInstrumentation' +import { SplunkSocketIoClientInstrumentation } from './SplunkSocketIoClientInstrumentation' import { SplunkOTLPTraceExporter } from './exporters/otlp' import { registerGlobal, unregisterGlobal } from './global-utils' import { BrowserInstanceService } from './services/BrowserInstanceService' -import { SessionId } from './types' +import { SessionId } from './session' +import { SplunkOtelWebConfig, SplunkOtelWebExporterOptions, SplunkOtelWebOptionsInstrumentations } from './types' export { SplunkExporterConfig } from './exporters/common' export { SplunkZipkinExporter } from './exporters/zipkin' export * from './SplunkWebTracerProvider' export * from './SessionBasedSampler' -interface SplunkOtelWebOptionsInstrumentations { - connectivity?: boolean | InstrumentationConfig - document?: boolean | InstrumentationConfig - errors?: boolean - fetch?: boolean | FetchInstrumentationConfig - interactions?: boolean | SplunkUserInteractionInstrumentationConfig - longtask?: boolean | InstrumentationConfig - postload?: boolean | SplunkPostDocLoadResourceInstrumentationConfig - socketio?: boolean | SocketIoClientInstrumentationConfig - visibility?: boolean | InstrumentationConfig - websocket?: boolean | InstrumentationConfig - webvitals?: boolean | WebVitalsInstrumentationConfig - xhr?: boolean | XMLHttpRequestInstrumentationConfig -} - -export interface SplunkOtelWebExporterOptions { - /** - * Allows remapping Span's attributes right before they're serialized. - * One potential use case of this method is to remove PII from the attributes. - */ - onAttributesSerializing?: (attributes: Attributes, span: ReadableSpan) => Attributes - - /** - * Switch from zipkin to otlp for exporting - */ - otlp?: boolean -} - -export interface SplunkOtelWebConfig { - /** - * If enabled, all spans are treated as activity and extend the duration of the session. Defaults to false. - */ - _experimental_allSpansExtendSession?: boolean - - /** Allows http beacon urls */ - allowInsecureBeacon?: boolean - - /** Application name - * @deprecated Renamed to `applicationName` - */ - app?: string - - /** Application name */ - applicationName?: string - - /** Destination for the captured data */ - beaconEndpoint?: string - - /** - * Destination for the captured data - * @deprecated Renamed to `beaconEndpoint`, or use realm - */ - beaconUrl?: string - - /** Options for context manager */ - context?: ContextManagerConfig - - /** Sets session cookie to this domain */ - cookieDomain?: string - - /** Turns on/off internal debug logging */ - debug?: boolean - - /** - * Sets a value for the `environment` attribute (persists through calls to `setGlobalAttributes()`) - * */ - deploymentEnvironment?: string - - /** - * Sets a value for the `environment` attribute (persists through calls to `setGlobalAttributes()`) - * @deprecated Renamed to `deploymentEnvironment` - */ - environment?: string - - /** Allows configuring how telemetry data is sent to the backend */ - exporter?: SplunkOtelWebExporterOptions - - /** Sets attributes added to every Span. */ - globalAttributes?: Attributes - - /** - * Applies for XHR, Fetch and Websocket URLs. URLs that partially match any regex in ignoreUrls will not be traced. - * In addition, URLs that are _exact matches_ of strings in ignoreUrls will also not be traced. - * */ - ignoreUrls?: Array - - /** Configuration for instrumentation modules. */ - instrumentations?: SplunkOtelWebOptionsInstrumentations - - /** - * The name of your organization’s realm. Automatically configures beaconUrl with correct URL - */ - realm?: string - - /** - * Publicly-visible rum access token value. Please do not paste any other access token or auth value into here, as this - * will be visible to every user of your app - */ - rumAccessToken?: string - - /** - * Publicly-visible `rumAuth` value. Please do not paste any other access token or auth value into here, as this - * will be visible to every user of your app - * @deprecated Renamed to rumAccessToken - */ - rumAuth?: string - - /** - * Config options passed to web tracer - */ - tracer?: WebTracerConfig - - /** Use local storage to save session ID instead of cookie */ - useLocalStorage?: boolean - - /** - * Sets a value for the 'app.version' attribute - */ - version?: string -} - interface SplunkOtelWebConfigInternal extends SplunkOtelWebConfig { bufferSize?: number bufferTimeout?: number @@ -327,6 +196,11 @@ export interface SplunkOtelWebType extends SplunkOtelWebEventTarget { */ _internalInit: (options: Partial) => void + /* Used internally by the SplunkSessionRecorder - span from session can extend the session */ + _internalOnExternalSpanCreated: () => void + + _processedOptions: SplunkOtelWebConfigInternal | null + attributesProcessor?: SplunkSpanAttributesProcessor deinit: (force?: boolean) => void @@ -370,6 +244,8 @@ export const SplunkRum: SplunkOtelWebType = { ParentBasedSampler, SessionBasedSampler, + _processedOptions: null, + get inited(): boolean { return inited }, @@ -421,6 +297,8 @@ export const SplunkRum: SplunkOtelWebType = { }, ) + this._processedOptions = processedOptions + if (processedOptions.realm) { if (!processedOptions.beaconEndpoint) { processedOptions.beaconEndpoint = getBeaconEndpointForRealm(processedOptions) @@ -487,8 +365,12 @@ export const SplunkRum: SplunkOtelWebType = { const instrumentations = INSTRUMENTATIONS.map(({ Instrument, confKey, disable }) => { const pluginConf = getPluginConfig(processedOptions.instrumentations[confKey], pluginDefaults, disable) if (pluginConf) { - // @ts-expect-error Can't mark in any way that processedOptions.instrumentations[confKey] is of specifc config type - const instrumentation = new Instrument(pluginConf) + const instrumentation = + Instrument === SplunkLongTaskInstrumentation + ? new Instrument(pluginConf, options) + : // @ts-expect-error Can't mark in any way that processedOptions.instrumentations[confKey] is of specifc config type + new Instrument(pluginConf) + if (confKey === ERROR_INSTRUMENTATION_NAME && instrumentation instanceof SplunkErrorInstrumentation) { _errorInstrumentation = instrumentation } @@ -633,6 +515,18 @@ export const SplunkRum: SplunkOtelWebType = { _experimental_getSessionId() { return this.getSessionId() }, + + _internalOnExternalSpanCreated() { + if (!this._processedOptions) { + return + } + + updateSessionStatus({ + forceStore: false, + useLocalStorage: this._processedOptions.useLocalStorage ?? false, + forceActivity: this._processedOptions._experimental_allSpansExtendSession, + }) + }, } export default SplunkRum diff --git a/packages/web/src/local-storage-session.ts b/packages/web/src/local-storage-session.ts deleted file mode 100644 index bedd0b1c..00000000 --- a/packages/web/src/local-storage-session.ts +++ /dev/null @@ -1,62 +0,0 @@ -/** - * - * 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/constants.ts b/packages/web/src/session/constants.ts new file mode 100644 index 00000000..cd498186 --- /dev/null +++ b/packages/web/src/session/constants.ts @@ -0,0 +1,22 @@ +/** + * + * 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 const SESSION_ID_LENGTH = 32 +export const SESSION_DURATION_SECONDS = 4 * 60 * 60 // 4 hours +export const SESSION_DURATION_MS = SESSION_DURATION_SECONDS * 1000 +export const SESSION_INACTIVITY_TIMEOUT_MS = 15 * 60 * 1000 // 15 minutes +export const SESSION_STORAGE_KEY = '_splunk_rum_sid' diff --git a/packages/web/src/cookie-session.ts b/packages/web/src/session/cookie-session.ts similarity index 50% rename from packages/web/src/cookie-session.ts rename to packages/web/src/session/cookie-session.ts index 6dc7cf8f..c90a001a 100644 --- a/packages/web/src/cookie-session.ts +++ b/packages/web/src/session/cookie-session.ts @@ -15,23 +15,39 @@ * limitations under the License. * */ -import { isIframe } from './utils' +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 +import { SESSION_DURATION_SECONDS, SESSION_STORAGE_KEY } from './constants' +import { isSessionDurationExceeded, isSessionInactivityTimeoutReached, isSessionState } from './utils' +import { throttle } from '../utils/throttle' export const cookieStore = { - set: (value: string): void => { + cachedValue: null, + set: (value: string) => { + cookieStore.cachedValue = value + cookieStore._set(value) + }, + + _set: throttle((value: string) => { document.cookie = value + }, 1000), + + flush: () => { + cookieStore._set.flush() + }, + + get: ({ forceStoreRead }: { forceStoreRead: boolean }): string => { + if (cookieStore.cachedValue === null || forceStoreRead) { + cookieStore.cachedValue = document.cookie + return cookieStore.cachedValue + } + + return cookieStore.cachedValue }, - get: (): string => document.cookie, } -export function parseCookieToSessionState(): SessionState | undefined { - const rawValue = findCookieValue(COOKIE_NAME) +export function parseCookieToSessionState({ forceStoreRead }: { forceStoreRead: boolean }): SessionState | undefined { + const rawValue = findCookieValue(SESSION_STORAGE_KEY, { forceStoreRead }) if (!rawValue) { return undefined } @@ -52,33 +68,30 @@ export function parseCookieToSessionState(): SessionState | undefined { return undefined } - // id validity - if ( - !sessionState.id || - typeof sessionState.id !== 'string' || - !sessionState.id.length || - sessionState.id.length !== 32 - ) { + if (isSessionDurationExceeded(sessionState)) { return undefined } - // startTime validity - if (!sessionState.startTime || typeof sessionState.startTime !== 'number' || isPastMaxAge(sessionState.startTime)) { + if (isSessionInactivityTimeoutReached(sessionState)) { return undefined } return sessionState } -export function renewCookieTimeout(sessionState: SessionState, cookieDomain: string | undefined): void { - if (isPastMaxAge(sessionState.startTime)) { +export function renewCookieTimeout( + sessionState: SessionState, + cookieDomain: string | undefined, + { forceStoreWrite }: { forceStoreWrite: boolean }, +): void { + if (isSessionDurationExceeded(sessionState)) { // safety valve return } const cookieValue = encodeURIComponent(JSON.stringify(sessionState)) const domain = cookieDomain ? `domain=${cookieDomain};` : '' - let cookie = COOKIE_NAME + '=' + cookieValue + '; path=/;' + domain + 'max-age=' + InactivityTimeoutSeconds + let cookie = SESSION_STORAGE_KEY + '=' + cookieValue + '; path=/;' + domain + 'max-age=' + SESSION_DURATION_SECONDS if (isIframe()) { cookie += ';SameSite=None; Secure' @@ -87,16 +100,23 @@ export function renewCookieTimeout(sessionState: SessionState, cookieDomain: str } cookieStore.set(cookie) + if (forceStoreWrite) { + cookieStore.flush() + } } 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` + const cookie = `${SESSION_STORAGE_KEY}=;domain=${domain};expires=Thu, 01 Jan 1970 00:00:00 GMT` cookieStore.set(cookie) + cookieStore.flush() } -export function findCookieValue(cookieName: string): string | undefined { - const decodedCookie = decodeURIComponent(cookieStore.get()) +export function findCookieValue( + cookieName: string, + { forceStoreRead }: { forceStoreRead: boolean }, +): string | undefined { + const decodedCookie = decodeURIComponent(cookieStore.get({ forceStoreRead })) const cookies = decodedCookie.split(';') for (let i = 0; i < cookies.length; i++) { const c = cookies[i].trim() @@ -106,19 +126,3 @@ export function findCookieValue(cookieName: string): string | undefined { } 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/session/index.ts b/packages/web/src/session/index.ts new file mode 100644 index 00000000..bae4de9e --- /dev/null +++ b/packages/web/src/session/index.ts @@ -0,0 +1,19 @@ +/** + * + * 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 * from './types' +export * from './session' diff --git a/packages/web/src/session/local-storage-session.ts b/packages/web/src/session/local-storage-session.ts new file mode 100644 index 00000000..657988f3 --- /dev/null +++ b/packages/web/src/session/local-storage-session.ts @@ -0,0 +1,92 @@ +/** + * + * 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' +import { SESSION_STORAGE_KEY } from './constants' +import { isSessionDurationExceeded, isSessionInactivityTimeoutReached, isSessionState } from './utils' +import { throttle } from '../utils/throttle' + +export const localStore = { + cachedValue: undefined, + set: (value: string) => { + localStore.cachedValue = value + localStore._set(value) + }, + + flush: () => { + localStore._set.flush() + }, + + _set: throttle((value: string) => { + safelySetLocalStorage(SESSION_STORAGE_KEY, value) + }, 1000), + + get: ({ forceStoreRead }: { forceStoreRead: boolean }): string => { + if (localStore.cachedValue === undefined || forceStoreRead) { + localStore.cachedValue = safelyGetLocalStorage(SESSION_STORAGE_KEY) + return localStore.cachedValue + } + + return localStore.cachedValue + }, + remove: () => { + safelyRemoveFromLocalStorage(SESSION_STORAGE_KEY) + localStore.cachedValue = undefined + }, +} + +export const getSessionStateFromLocalStorage = ({ + forceStoreRead, +}: { + forceStoreRead: boolean +}): SessionState | undefined => { + let sessionState: unknown = undefined + try { + sessionState = JSON.parse(localStore.get({ forceStoreRead })) + } catch { + return undefined + } + + if (!isSessionState(sessionState)) { + return + } + + if (isSessionDurationExceeded(sessionState) || isSessionInactivityTimeoutReached(sessionState)) { + return + } + + return sessionState +} + +export const setSessionStateToLocalStorage = ( + sessionState: SessionState, + { forceStoreWrite }: { forceStoreWrite: boolean }, +): void => { + if (isSessionDurationExceeded(sessionState)) { + return + } + + localStore.set(JSON.stringify(sessionState)) + if (forceStoreWrite) { + localStore.flush() + } +} + +export const clearSessionStateFromLocalStorage = (): void => { + localStore.remove() +} diff --git a/packages/web/src/session.ts b/packages/web/src/session/session.ts similarity index 65% rename from packages/web/src/session.ts rename to packages/web/src/session/session.ts index e6953daa..91e3e069 100644 --- a/packages/web/src/session.ts +++ b/packages/web/src/session/session.ts @@ -17,11 +17,14 @@ */ import { SpanProcessor, WebTracerProvider } from '@opentelemetry/sdk-trace-web' -import { InternalEventTarget } from './EventTarget' -import { generateId } from './utils' +import { InternalEventTarget } from '../EventTarget' +import { generateId } from '../utils' import { parseCookieToSessionState, renewCookieTimeout } from './cookie-session' import { SessionState, SessionId } from './types' import { getSessionStateFromLocalStorage, setSessionStateToLocalStorage } from './local-storage-session' +import { SESSION_INACTIVITY_TIMEOUT_MS } from './constants' +import { suppressTracing } from '@opentelemetry/core' +import { context } from '@opentelemetry/api' /* The basic idea is to let the browser expire cookies for us "naturally" once @@ -42,8 +45,6 @@ import { getSessionStateFromLocalStorage, setSessionStateToLocalStorage } from ' with setting cookies, checking for inactivity, etc. */ -const periodicCheckSeconds = 60 - let rumSessionId: SessionId | undefined let recentActivity = false let cookieDomain: string @@ -55,30 +56,52 @@ export function markActivity(): void { function createSessionState(): SessionState { return { + expiresAt: Date.now() + SESSION_INACTIVITY_TIMEOUT_MS, id: generateId(128), startTime: Date.now(), } } +export function getCurrentSessionState({ useLocalStorage = false, forceStoreRead = false }): SessionState | undefined { + return useLocalStorage + ? getSessionStateFromLocalStorage({ forceStoreRead }) + : parseCookieToSessionState({ forceStoreRead }) +} + // 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 +// 2) If activity has occurred since the last periodic invocation, renew the cookie timeout // (Only exported for testing purposes.) -export function updateSessionStatus(useLocalStorage = false): void { - let sessionState = useLocalStorage ? getSessionStateFromLocalStorage() : parseCookieToSessionState() +export function updateSessionStatus({ + forceStore, + useLocalStorage = false, + forceActivity, +}: { + forceActivity?: boolean + forceStore: boolean + useLocalStorage: boolean +}): void { + let sessionState = getCurrentSessionState({ useLocalStorage, forceStoreRead: forceStore }) + let shouldForceWrite = false if (!sessionState) { - sessionState = createSessionState() - recentActivity = true // force write of new cookie + // Check if another tab has created a new session + sessionState = getCurrentSessionState({ useLocalStorage, forceStoreRead: true }) + if (!sessionState) { + sessionState = createSessionState() + recentActivity = true // force write of new cookie + shouldForceWrite = true + } } rumSessionId = sessionState.id eventTarget?.emit('session-changed', { sessionId: rumSessionId }) - if (recentActivity) { + if (recentActivity || forceActivity) { + sessionState.expiresAt = Date.now() + SESSION_INACTIVITY_TIMEOUT_MS if (useLocalStorage) { - setSessionStateToLocalStorage(sessionState) + setSessionStateToLocalStorage(sessionState, { forceStoreWrite: shouldForceWrite || forceStore }) } else { - renewCookieTimeout(sessionState, cookieDomain) + renewCookieTimeout(sessionState, cookieDomain, { forceStoreWrite: shouldForceWrite || forceStore }) } } @@ -89,7 +112,14 @@ function hasNativeSessionId(): boolean { return typeof window !== 'undefined' && window['SplunkRumNative'] && window['SplunkRumNative'].getNativeSessionId } -class ActivitySpanProcessor implements SpanProcessor { +class SessionSpanProcessor implements SpanProcessor { + constructor( + private readonly options: { + allSpansAreActivity: boolean + useLocalStorage: boolean + }, + ) {} + forceFlush(): Promise { return Promise.resolve() } @@ -97,7 +127,16 @@ class ActivitySpanProcessor implements SpanProcessor { onEnd(): void {} onStart(): void { - markActivity() + if (this.options.allSpansAreActivity) { + markActivity() + } + + context.with(suppressTracing(context.active()), () => { + updateSessionStatus({ + forceStore: false, + useLocalStorage: this.options.useLocalStorage, + }) + }) } shutdown(): Promise { @@ -133,17 +172,13 @@ export function initSessionTracking( eventTarget = newEventTarget ACTIVITY_EVENTS.forEach((type) => document.addEventListener(type, markActivity, { capture: true, passive: true })) - if (allSpansAreActivity) { - provider.addSpanProcessor(new ActivitySpanProcessor()) - } + provider.addSpanProcessor(new SessionSpanProcessor({ allSpansAreActivity, useLocalStorage })) - updateSessionStatus(useLocalStorage) - const intervalHandle = setInterval(() => updateSessionStatus(useLocalStorage), periodicCheckSeconds * 1000) + updateSessionStatus({ useLocalStorage, forceStore: true }) return { deinit: () => { ACTIVITY_EVENTS.forEach((type) => document.removeEventListener(type, markActivity)) - clearInterval(intervalHandle) rumSessionId = undefined eventTarget = undefined }, diff --git a/packages/web/src/types.ts b/packages/web/src/session/types.ts similarity index 97% rename from packages/web/src/types.ts rename to packages/web/src/session/types.ts index d3f920bd..d9b3191c 100644 --- a/packages/web/src/types.ts +++ b/packages/web/src/session/types.ts @@ -18,6 +18,7 @@ export type SessionId = string export type SessionState = { + expiresAt?: number id: SessionId startTime: number } diff --git a/packages/web/src/session/utils.ts b/packages/web/src/session/utils.ts new file mode 100644 index 00000000..9afd77a7 --- /dev/null +++ b/packages/web/src/session/utils.ts @@ -0,0 +1,42 @@ +/** + * + * 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 { SESSION_DURATION_MS, SESSION_ID_LENGTH } from './constants' + +export const isSessionState = (maybeSessionState: unknown): maybeSessionState is SessionState => + typeof maybeSessionState === 'object' && + maybeSessionState !== null && + 'id' in maybeSessionState && + typeof maybeSessionState['id'] === 'string' && + maybeSessionState.id.length === SESSION_ID_LENGTH && + 'startTime' in maybeSessionState && + typeof maybeSessionState['startTime'] === 'number' + +export const isSessionDurationExceeded = (sessionState: SessionState): boolean => { + const now = Date.now() + return sessionState.startTime > now || now > sessionState.startTime + SESSION_DURATION_MS +} + +export const isSessionInactivityTimeoutReached = (sessionState: SessionState): boolean => { + if (sessionState.expiresAt === undefined) { + return false + } + + const now = Date.now() + return now > sessionState.expiresAt +} diff --git a/packages/web/src/types/config.ts b/packages/web/src/types/config.ts new file mode 100644 index 00000000..d855be89 --- /dev/null +++ b/packages/web/src/types/config.ts @@ -0,0 +1,160 @@ +/** + * + * 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 { Attributes } from '@opentelemetry/api' +import { WebTracerConfig } from '@opentelemetry/sdk-trace-web' +import { InstrumentationConfig } from '@opentelemetry/instrumentation' +import { FetchInstrumentationConfig } from '@opentelemetry/instrumentation-fetch' +import { SplunkUserInteractionInstrumentationConfig } from '../SplunkUserInteractionInstrumentation' +import { SplunkPostDocLoadResourceInstrumentationConfig } from '../SplunkPostDocLoadResourceInstrumentation' +import { SocketIoClientInstrumentationConfig } from '../SplunkSocketIoClientInstrumentation' +import { WebVitalsInstrumentationConfig } from '../webvitals' +import { XMLHttpRequestInstrumentationConfig } from '@opentelemetry/instrumentation-xml-http-request' +import { ReadableSpan } from '@opentelemetry/sdk-trace-base' + +export interface SplunkOtelWebOptionsInstrumentations { + connectivity?: boolean | InstrumentationConfig + document?: boolean | InstrumentationConfig + errors?: boolean + fetch?: boolean | FetchInstrumentationConfig + interactions?: boolean | SplunkUserInteractionInstrumentationConfig + longtask?: boolean | InstrumentationConfig + postload?: boolean | SplunkPostDocLoadResourceInstrumentationConfig + socketio?: boolean | SocketIoClientInstrumentationConfig + visibility?: boolean | InstrumentationConfig + websocket?: boolean | InstrumentationConfig + webvitals?: boolean | WebVitalsInstrumentationConfig + xhr?: boolean | XMLHttpRequestInstrumentationConfig +} + +export interface ContextManagerConfig { + /** Enable async tracking of span parents */ + async?: boolean + onBeforeContextEnd?: () => void + onBeforeContextStart?: () => void +} + +export interface SplunkOtelWebExporterOptions { + /** + * Allows remapping Span's attributes right before they're serialized. + * One potential use case of this method is to remove PII from the attributes. + */ + onAttributesSerializing?: (attributes: Attributes, span: ReadableSpan) => Attributes + + /** + * Switch from zipkin to otlp for exporting + */ + otlp?: boolean +} + +export interface SplunkOtelWebConfig { + /** + * If enabled, all spans are treated as activity and extend the duration of the session. Defaults to false. + */ + _experimental_allSpansExtendSession?: boolean + + /* + * If enabled, longtask will not start the new session. Defaults to false. + */ + _experimental_longtaskNoStartSession?: boolean + + /** Allows http beacon urls */ + allowInsecureBeacon?: boolean + + /** Application name + * @deprecated Renamed to `applicationName` + */ + app?: string + + /** Application name */ + applicationName?: string + + /** Destination for the captured data */ + beaconEndpoint?: string + + /** + * Destination for the captured data + * @deprecated Renamed to `beaconEndpoint`, or use realm + */ + beaconUrl?: string + + /** Options for context manager */ + context?: ContextManagerConfig + + /** Sets session cookie to this domain */ + cookieDomain?: string + + /** Turns on/off internal debug logging */ + debug?: boolean + + /** + * Sets a value for the `environment` attribute (persists through calls to `setGlobalAttributes()`) + * */ + deploymentEnvironment?: string + + /** + * Sets a value for the `environment` attribute (persists through calls to `setGlobalAttributes()`) + * @deprecated Renamed to `deploymentEnvironment` + */ + environment?: string + + /** Allows configuring how telemetry data is sent to the backend */ + exporter?: SplunkOtelWebExporterOptions + + /** Sets attributes added to every Span. */ + globalAttributes?: Attributes + + /** + * Applies for XHR, Fetch and Websocket URLs. URLs that partially match any regex in ignoreUrls will not be traced. + * In addition, URLs that are _exact matches_ of strings in ignoreUrls will also not be traced. + * */ + ignoreUrls?: Array + + /** Configuration for instrumentation modules. */ + instrumentations?: SplunkOtelWebOptionsInstrumentations + + /** + * The name of your organization’s realm. Automatically configures beaconUrl with correct URL + */ + realm?: string + + /** + * Publicly-visible rum access token value. Please do not paste any other access token or auth value into here, as this + * will be visible to every user of your app + */ + rumAccessToken?: string + + /** + * Publicly-visible `rumAuth` value. Please do not paste any other access token or auth value into here, as this + * will be visible to every user of your app + * @deprecated Renamed to rumAccessToken + */ + rumAuth?: string + + /** + * Config options passed to web tracer + */ + tracer?: WebTracerConfig + + /** Use local storage to save session ID instead of cookie */ + useLocalStorage?: boolean + + /** + * Sets a value for the 'app.version' attribute + */ + version?: string +} diff --git a/packages/web/src/types/index.ts b/packages/web/src/types/index.ts new file mode 100644 index 00000000..4d3f7253 --- /dev/null +++ b/packages/web/src/types/index.ts @@ -0,0 +1,18 @@ +/** + * + * 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 * from './config' diff --git a/packages/web/src/utils/throttle.ts b/packages/web/src/utils/throttle.ts new file mode 100644 index 00000000..e1fae27a --- /dev/null +++ b/packages/web/src/utils/throttle.ts @@ -0,0 +1,83 @@ +/** + * + * 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 function throttle any>(func: T, limit: number) { + let lastExecutionTime = 0 + let timeout: ReturnType | null = null + let visibilityListener: (() => void) | null = null + let lastArgs: Parameters | undefined + + const executeFunc = (...args: Parameters) => { + lastExecutionTime = performance.now() + return func(...args) + } + + const throttled = (...args: Parameters) => { + lastArgs = args + + if (visibilityListener) { + document.removeEventListener('visibilitychange', visibilityListener) + visibilityListener = null + } + + const now = performance.now() + const timeSinceLastExecution = now - lastExecutionTime + + if (timeout) { + clearTimeout(timeout) + timeout = null + } + + if (timeSinceLastExecution >= limit || lastExecutionTime === 0) { + executeFunc(...args) + } else { + timeout = setTimeout(() => { + executeFunc(...args) + }, limit - timeSinceLastExecution) + + visibilityListener = () => { + if (document.visibilityState === 'hidden') { + if (timeout !== null) { + clearTimeout(timeout) + } + + executeFunc(...args) + } + + document.removeEventListener('visibilitychange', visibilityListener) + } + } + } + + throttled.flush = () => { + if (timeout) { + clearTimeout(timeout) + timeout = null + } + + if (visibilityListener) { + document.removeEventListener('visibilitychange', visibilityListener) + visibilityListener = null + } + + if (lastArgs) { + executeFunc(...lastArgs) + } + } + + return throttled +} diff --git a/packages/web/test/SessionBasedSampler.test.ts b/packages/web/test/SessionBasedSampler.test.ts index a278af2f..2e92ab55 100644 --- a/packages/web/test/SessionBasedSampler.test.ts +++ b/packages/web/test/SessionBasedSampler.test.ts @@ -19,17 +19,23 @@ import * as assert from 'assert' import { InternalEventTarget } from '../src/EventTarget' import { SessionBasedSampler } from '../src/SessionBasedSampler' -import { initSessionTracking, updateSessionStatus } from '../src/session' +import { initSessionTracking, updateSessionStatus } from '../src/session/session' import { context, SamplingDecision } from '@opentelemetry/api' import { SplunkWebTracerProvider } from '../src' -import { COOKIE_NAME } from '../src/cookie-session' +import { SESSION_INACTIVITY_TIMEOUT_MS, SESSION_STORAGE_KEY } from '../src/session/constants' describe('Session based sampler', () => { it('decide sampling based on session id and ratio', () => { // Session id < target ratio const lowSessionId = '0'.repeat(32) - const lowCookieValue = encodeURIComponent(JSON.stringify({ id: lowSessionId, startTime: new Date().getTime() })) - document.cookie = COOKIE_NAME + '=' + lowCookieValue + '; path=/; max-age=' + 10 + const lowCookieValue = encodeURIComponent( + JSON.stringify({ + id: lowSessionId, + startTime: new Date().getTime(), + expiresAt: new Date().getTime() + SESSION_INACTIVITY_TIMEOUT_MS, + }), + ) + document.cookie = SESSION_STORAGE_KEY + '=' + lowCookieValue + '; path=/; max-age=' + 10 const provider = new SplunkWebTracerProvider() initSessionTracking(provider, lowSessionId, new InternalEventTarget()) @@ -43,10 +49,17 @@ describe('Session based sampler', () => { // Session id > target ratio const highSessionId = '1234567890abcdeffedcba0987654321' const highCookieValue = encodeURIComponent( - JSON.stringify({ id: highSessionId, startTime: new Date().getTime() }), + JSON.stringify({ + id: highSessionId, + startTime: new Date().getTime(), + expiresAt: new Date().getTime() + SESSION_INACTIVITY_TIMEOUT_MS, + }), ) - document.cookie = COOKIE_NAME + '=' + highCookieValue + '; path=/; max-age=' + 10 - updateSessionStatus() + document.cookie = SESSION_STORAGE_KEY + '=' + highCookieValue + '; path=/; max-age=' + 10 + updateSessionStatus({ + forceStore: true, + useLocalStorage: false, + }) assert.strictEqual( sampler.shouldSample(context.active(), '0000000000000000', 'test', 0, {}, []).decision, diff --git a/packages/web/test/SplunkOtelWeb.test.ts b/packages/web/test/SplunkOtelWeb.test.ts index f54cb79e..fe8f90ca 100644 --- a/packages/web/test/SplunkOtelWeb.test.ts +++ b/packages/web/test/SplunkOtelWeb.test.ts @@ -19,7 +19,7 @@ import { SpanAttributes } from '@opentelemetry/api' import { expect } from 'chai' import SplunkRum from '../src' -import { updateSessionStatus } from '../src/session' +import { updateSessionStatus } from '../src/session/session' describe('SplunkOtelWeb', () => { afterEach(() => { @@ -124,7 +124,7 @@ describe('SplunkOtelWeb', () => { }) document.body.click() - updateSessionStatus() + updateSessionStatus({ forceStore: false, useLocalStorage: false }) // Wait for promise chain to resolve await Promise.resolve() diff --git a/packages/web/test/init.test.ts b/packages/web/test/init.test.ts index 7d15345b..13ecb748 100644 --- a/packages/web/test/init.test.ts +++ b/packages/web/test/init.test.ts @@ -29,7 +29,7 @@ import { VERSION } from '../src/version' function doesBeaconUrlEndWith(suffix) { const sps = (SplunkRum.provider.getActiveSpanProcessor() as any)._spanProcessors // TODO: refactor to make beaconUrl field private - const beaconUrl = sps[1]._exporter.beaconUrl || sps[1]._exporter.url + const beaconUrl = sps[2]._exporter.beaconUrl || sps[2]._exporter.url assert.ok(beaconUrl.endsWith(suffix), `Checking beaconUrl if (${beaconUrl}) ends with ${suffix}`) } diff --git a/packages/web/test/session.test.ts b/packages/web/test/session.test.ts index c7bb335a..17552b40 100644 --- a/packages/web/test/session.test.ts +++ b/packages/web/test/session.test.ts @@ -18,11 +18,12 @@ import * as assert from 'assert' import { InternalEventTarget } from '../src/EventTarget' -import { initSessionTracking, getRumSessionId, updateSessionStatus } from '../src/session' +import { initSessionTracking, getRumSessionId, updateSessionStatus } from '../src/session/session' import { SplunkWebTracerProvider } from '../src' import sinon from 'sinon' -import { COOKIE_NAME, clearSessionCookie, cookieStore } from '../src/cookie-session' -import { clearSessionStateFromLocalStorage } from '../src/local-storage-session' +import { SESSION_STORAGE_KEY, SESSION_INACTIVITY_TIMEOUT_MS } from '../src/session/constants' +import { clearSessionCookie, cookieStore } from '../src/session/cookie-session' +import { clearSessionStateFromLocalStorage } from '../src/session/local-storage-session' describe('Session tracking', () => { beforeEach(() => { @@ -40,29 +41,39 @@ describe('Session tracking', () => { const firstSessionId = getRumSessionId() assert.strictEqual(firstSessionId.length, 32) // no marked activity, should keep same state - updateSessionStatus() + updateSessionStatus({ forceStore: false, useLocalStorage: false }) assert.strictEqual(firstSessionId, getRumSessionId()) // set cookie to expire in 2 seconds, mark activity, and then updateSessionStatus. // Wait 4 seconds and cookie should still be there (having been renewed) - const cookieValue = encodeURIComponent(JSON.stringify({ id: firstSessionId, startTime: new Date().getTime() })) - document.cookie = COOKIE_NAME + '=' + cookieValue + '; path=/; max-age=' + 2 + const cookieValue = encodeURIComponent( + JSON.stringify({ + id: firstSessionId, + startTime: new Date().getTime(), + expiresAt: new Date().getTime() + SESSION_INACTIVITY_TIMEOUT_MS, + }), + ) + document.cookie = SESSION_STORAGE_KEY + '=' + cookieValue + '; path=/; max-age=' + 2 document.body.dispatchEvent(new Event('click')) - updateSessionStatus() + updateSessionStatus({ forceStore: false, useLocalStorage: false }) setTimeout(() => { // because of activity, same session should be there - assert.ok(document.cookie.includes(COOKIE_NAME)) + assert.ok(document.cookie.includes(SESSION_STORAGE_KEY)) assert.strictEqual(firstSessionId, getRumSessionId()) // Finally, set a fake cookie with startTime 5 hours ago, update status, and find a new cookie with a new session ID // after max age code does its thing const fiveHoursMillis = 5 * 60 * 60 * 1000 const tooOldCookieValue = encodeURIComponent( - JSON.stringify({ id: firstSessionId, startTime: new Date().getTime() - fiveHoursMillis }), + JSON.stringify({ + id: firstSessionId, + startTime: new Date().getTime() - fiveHoursMillis, + expiresAt: new Date().getTime() + SESSION_INACTIVITY_TIMEOUT_MS - fiveHoursMillis, + }), ) - document.cookie = COOKIE_NAME + '=' + tooOldCookieValue + '; path=/; max-age=' + 4 + document.cookie = SESSION_STORAGE_KEY + '=' + tooOldCookieValue + '; path=/; max-age=' + 4 - updateSessionStatus() - assert.ok(document.cookie.includes(COOKIE_NAME)) + updateSessionStatus({ forceStore: true, useLocalStorage: false }) + assert.ok(document.cookie.includes(SESSION_STORAGE_KEY)) const newSessionId = getRumSessionId() assert.strictEqual(newSessionId.length, 32) assert.ok(firstSessionId !== newSessionId) @@ -83,7 +94,7 @@ describe('Session tracking', () => { initSessionTracking(provider, firstSessionId, new InternalEventTarget(), undefined, allSpansAreActivity) provider.getTracer('tracer').startSpan('any-span').end() - updateSessionStatus() + updateSessionStatus({ forceStore: false, useLocalStorage: false }) } it('non-activity spans do not trigger a new session', (done) => { @@ -128,7 +139,7 @@ describe('Session tracking - localStorage', () => { ) const firstSessionId = getRumSessionId() - updateSessionStatus(useLocalStorage) + updateSessionStatus({ forceStore: true, useLocalStorage }) assert.strictEqual(firstSessionId, getRumSessionId()) trackingHandle.deinit() diff --git a/packages/web/test/socketio.test.ts b/packages/web/test/socketio.test.ts index b6ffd480..aef72d59 100644 --- a/packages/web/test/socketio.test.ts +++ b/packages/web/test/socketio.test.ts @@ -19,7 +19,7 @@ import * as assert from 'assert' import { deinit, initWithDefaultConfig, SpanCapturer } from './utils' import { io } from 'socket.io-client' -import { SplunkOtelWebConfig } from '../src' +import { SplunkOtelWebConfig } from '../src/types' import { SpanKind } from '@opentelemetry/api' describe('can produce websocket events', () => { diff --git a/packages/web/test/utils.test.ts b/packages/web/test/utils.test.ts index 6b36a62b..ef63c4d9 100644 --- a/packages/web/test/utils.test.ts +++ b/packages/web/test/utils.test.ts @@ -18,7 +18,7 @@ import * as assert from 'assert' import { generateId } from '../src/utils' -import { findCookieValue } from '../src/cookie-session' +import { findCookieValue } from '../src/session/cookie-session' describe('generateId', () => { it('should generate IDs of 64 and 128 bits', () => { @@ -32,6 +32,6 @@ describe('generateId', () => { }) describe('findCookieValue', () => { it('should not find unset cookie', () => { - assert.ok(findCookieValue('nosuchCookie') === undefined) + assert.ok(findCookieValue('nosuchCookie', { forceStoreRead: true }) === undefined) }) })