Skip to content

Commit 22745c0

Browse files
committed
feat: external session
1 parent c043d3a commit 22745c0

File tree

9 files changed

+194
-39
lines changed

9 files changed

+194
-39
lines changed

packages/session-recorder/src/index.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -94,7 +94,7 @@ const createSessionReplaySpanIfAllowed = (spanName: SpanName, sessionId: string
9494
// Check if session is managed by native SDK
9595
const SplunkRum = getGlobal<SplunkOtelWebType>()
9696
const sessionState = SplunkRum?.sessionManager?.getSessionState()
97-
if (sessionState?.source === 'native') {
97+
if (sessionState?.source === 'external') {
9898
log.debug('Session replay span not created - recording is managed by native SDK', { sessionId, spanName })
9999
return
100100
}

packages/web/src/index.ts

Lines changed: 34 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,7 @@ export { SplunkZipkinExporter } from './exporters/zipkin'
8585
export * from './session-based-sampler'
8686
export * from './splunk-web-tracer-provider'
8787
import { PrivacyManager, SessionManager, SessionState, StorageManager, UserManager } from './managers'
88+
import { ExternalSessionMetadata, isValidExternalSessionMetadata } from './types/external-session-metadata'
8889
import { getElementXPath, getTextFromNode } from './utils/index'
8990

9091
interface SplunkOtelWebConfigInternal extends SplunkOtelWebConfig {
@@ -121,6 +122,7 @@ const OPTIONS_DEFAULTS: SplunkOtelWebConfigInternal = {
121122
instrumentations: {},
122123
persistence: 'cookie',
123124
rumAccessToken: undefined,
125+
sessionMetadata: undefined,
124126
spanProcessor: {
125127
factory: (exporter, config) => new BatchSpanProcessor(exporter, config),
126128
},
@@ -220,6 +222,8 @@ export interface SplunkOtelWebType extends SplunkOtelWebEventTarget {
220222
*/
221223
getSessionId: () => string | undefined
222224

225+
getSessionMetadata: () => ExternalSessionMetadata | null
226+
223227
getSessionState: () => SessionState | undefined
224228

225229
init: (options: SplunkOtelWebConfig) => void
@@ -330,6 +334,23 @@ export const SplunkRum: SplunkOtelWebType = {
330334
}
331335
},
332336

337+
getSessionMetadata(): ExternalSessionMetadata {
338+
if (!inited || !this.sessionManager) {
339+
return null
340+
}
341+
342+
const session = this.sessionManager.getSessionMetadata()
343+
const anonymousUserId = this.getAnonymousId()
344+
if (!session || !anonymousUserId) {
345+
return null
346+
}
347+
348+
return {
349+
anonymousUserId,
350+
...session,
351+
}
352+
},
353+
333354
getSessionState() {
334355
if (!inited) {
335356
return
@@ -514,10 +535,20 @@ export const SplunkRum: SplunkOtelWebType = {
514535
})
515536

516537
this.resource = new Resource(resourceAttrs)
517-
this.sessionManager = new SessionManager(storageManager)
518-
this.userManager = new UserManager(userTrackingMode, storageManager)
538+
539+
let sessionMetadataFromOptions: NonNullable<ExternalSessionMetadata> | undefined
540+
if (processedOptions.sessionMetadata && isValidExternalSessionMetadata(processedOptions.sessionMetadata)) {
541+
sessionMetadataFromOptions = processedOptions.sessionMetadata
542+
}
543+
544+
this.sessionManager = new SessionManager(storageManager, sessionMetadataFromOptions)
545+
this.userManager = new UserManager(
546+
userTrackingMode,
547+
storageManager,
548+
sessionMetadataFromOptions?.anonymousUserId,
549+
)
519550
_sessionStateUnsubscribe = this.sessionManager.subscribe(({ currentState, previousState }) => {
520-
if (currentState.isNew && currentState.source !== 'native') {
551+
if (currentState.isNew && currentState.source !== 'external') {
521552
provider.getTracer('splunk-sessions').startSpan('session.start').end()
522553
}
523554

packages/web/src/managers/session-manager/session-manager.test.ts

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -127,35 +127,35 @@ describe('SessionManager', () => {
127127
})
128128

129129
describe('Static Methods', () => {
130-
describe('getNativeSessionId', () => {
130+
describe('getExternalSession', () => {
131131
it('should return null when SplunkRumNative is not available', () => {
132132
window.SplunkRumNative = undefined
133133

134-
expect(SessionManager.getNativeSessionId()).toBeNull()
134+
expect(SessionManager.getExternalSession()).toBeNull()
135135
})
136136

137-
it('should return native session ID when available', () => {
137+
it('should return external session when available', () => {
138138
window.SplunkRumNative = {
139139
getNativeSessionId: vi.fn().mockReturnValue('native-id'),
140140
}
141141

142-
expect(SessionManager.getNativeSessionId()).toBe('native-id')
142+
expect(SessionManager.getExternalSession()?.id).toBe('native-id')
143143
})
144144
})
145145

146-
describe('hasNativeSessionId', () => {
146+
describe('hasExternalSession', () => {
147147
it('should return false when SplunkRumNative is not available', () => {
148148
window.SplunkRumNative = undefined
149149

150-
expect(SessionManager.hasNativeSessionId()).toBe(false)
150+
expect(SessionManager.hasExternalSession()).toBe(false)
151151
})
152152

153153
it('should return true when SplunkRumNative is available', () => {
154154
window.SplunkRumNative = {
155155
getNativeSessionId: vi.fn(),
156156
}
157157

158-
expect(SessionManager.hasNativeSessionId()).toBe(true)
158+
expect(SessionManager.hasExternalSession()).toBe(true)
159159
})
160160
})
161161
})

packages/web/src/managers/session-manager/session-manager.ts

Lines changed: 89 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@
1818

1919
import { diag } from '@opentelemetry/api'
2020

21+
import { ExternalSessionMetadata } from '../../types/external-session-metadata'
2122
import { generateId } from '../../utils'
2223
import { isTrustedEvent } from '../../utils/is-trusted-event'
2324
import { Observable } from '../../utils/observable'
@@ -64,16 +65,33 @@ export class SessionManager {
6465

6566
private stopCallbacks: Array<() => void> = []
6667

67-
constructor(private readonly storageManager: StorageManager) {
68-
const nativeSessionId = SessionManager.getNativeSessionId()
69-
if (nativeSessionId) {
70-
this._session = {
71-
expiresAt: Date.now() + 4 * SESSION_INACTIVITY_TIMEOUT_MS,
72-
id: nativeSessionId,
73-
source: 'native',
74-
startTime: Date.now(),
68+
constructor(
69+
private readonly storageManager: StorageManager,
70+
externalSessionMetadata?: NonNullable<ExternalSessionMetadata>,
71+
) {
72+
if (externalSessionMetadata) {
73+
const externalSessionInit: SessionState = {
74+
expiresAt: externalSessionMetadata.sessionLastActivity + SESSION_INACTIVITY_TIMEOUT_MS,
75+
id: externalSessionMetadata.sessionId,
76+
source: 'external',
77+
startTime: externalSessionMetadata.sessionStart,
7578
state: 'active',
7679
}
80+
81+
if (SessionManager.canContinueUsingSession(externalSessionInit)) {
82+
this._session = externalSessionInit
83+
diag.debug('SessionManager: Initialized with provided external session metadata.', {
84+
state: this._session,
85+
})
86+
return
87+
}
88+
}
89+
90+
const externalSession = SessionManager.getExternalSession()
91+
if (externalSession) {
92+
this._session = {
93+
...externalSession,
94+
}
7795
} else {
7896
const sessionState = this.getSessionStateFromStorageAndValidate()
7997
if (sessionState) {
@@ -90,23 +108,74 @@ export class SessionManager {
90108
diag.debug('SessionManager: Initialized. Current session state', { state: this._session })
91109
}
92110

93-
static getNativeSessionId() {
94-
if (!(typeof window !== 'undefined' && window.SplunkRumNative && window.SplunkRumNative.getNativeSessionId)) {
111+
static getExternalSession(): SessionState | null {
112+
if (typeof window === 'undefined') {
95113
return null
96114
}
97115

98-
return window.SplunkRumNative.getNativeSessionId()
116+
if (window.SplunkRumExternal && window.SplunkRumExternal.getSessionMetadata) {
117+
const sessionMetadata = window.SplunkRumExternal.getSessionMetadata()
118+
const externalSession: SessionState = {
119+
expiresAt: sessionMetadata.sessionLastActivity + SESSION_INACTIVITY_TIMEOUT_MS,
120+
id: sessionMetadata.sessionId,
121+
source: 'external',
122+
startTime: sessionMetadata.sessionStart,
123+
state: 'active',
124+
}
125+
126+
if (SessionManager.canContinueUsingSession(externalSession)) {
127+
return externalSession
128+
} else {
129+
diag.warn(
130+
'Retrieved session from SplunkRumExternal, but it cannot be continued (it may be expired or have reached its maximum duration). Ignoring the external session.',
131+
)
132+
}
133+
} else if (window.SplunkRumNative && window.SplunkRumNative.getNativeSessionId) {
134+
const externalSessionId = window.SplunkRumNative.getNativeSessionId()
135+
136+
return {
137+
expiresAt: Date.now() + SESSION_INACTIVITY_TIMEOUT_MS,
138+
id: externalSessionId,
139+
source: 'external',
140+
startTime: Date.now(),
141+
state: 'active',
142+
}
143+
}
144+
145+
return null
99146
}
100147

101-
static hasNativeSessionId() {
102-
return Boolean(
103-
typeof window !== 'undefined' && window.SplunkRumNative && window.SplunkRumNative.getNativeSessionId,
148+
static hasExternalSession(): boolean {
149+
if (typeof window === 'undefined') {
150+
return false
151+
}
152+
153+
const isNativeSessionPresent = Boolean(window.SplunkRumNative && window.SplunkRumNative.getNativeSessionId)
154+
const isExternalSessionPresent = Boolean(
155+
window.SplunkRumExternal && window.SplunkRumExternal.getSessionMetadata(),
104156
)
157+
158+
return isNativeSessionPresent || isExternalSessionPresent
105159
}
106160

107161
getSessionId() {
108162
this.ensureSessionStateIsUpToDate()
109-
return SessionManager.getNativeSessionId() ?? this.session.id
163+
return SessionManager.getExternalSession()?.id ?? this.session.id
164+
}
165+
166+
getSessionMetadata(): {
167+
sessionId: string
168+
sessionLastActivity: number
169+
sessionStart: number
170+
} | null {
171+
this.ensureSessionStateIsUpToDate()
172+
const session = this.session
173+
174+
return {
175+
sessionId: session.id,
176+
sessionLastActivity: session.expiresAt - SESSION_INACTIVITY_TIMEOUT_MS,
177+
sessionStart: session.startTime,
178+
}
110179
}
111180

112181
getSessionState() {
@@ -121,7 +190,7 @@ export class SessionManager {
121190
}
122191

123192
this.isStarted = true
124-
if (SessionManager.hasNativeSessionId()) {
193+
if (SessionManager.hasExternalSession()) {
125194
this.attachNativeSessionWatch()
126195
} else {
127196
this.attachUserActivityListeners()
@@ -192,16 +261,13 @@ export class SessionManager {
192261
private attachNativeSessionWatch() {
193262
// eslint-disable-next-line unicorn/consistent-function-scoping
194263
const nativeSessionWatch = () => {
195-
const nativeSessionId = SessionManager.getNativeSessionId()
264+
const externalSession = SessionManager.getExternalSession()
196265
const session = this.session
197266

198-
if (nativeSessionId) {
267+
if (externalSession) {
199268
this.session = {
200269
...session,
201-
expiresAt: Date.now() + SESSION_INACTIVITY_TIMEOUT_MS,
202-
id: nativeSessionId,
203-
source: 'native',
204-
state: 'active',
270+
...externalSession,
205271
}
206272
}
207273
}
@@ -285,7 +351,7 @@ export class SessionManager {
285351
return
286352
}
287353

288-
if (SessionManager.hasNativeSessionId()) {
354+
if (SessionManager.hasExternalSession()) {
289355
diag.debug('SessionManager: Native session ID detected. Session extension or creation is managed natively.')
290356
return
291357
}

packages/web/src/managers/session-manager/session-state.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@
1919
export type SessionState = {
2020
expiresAt: number
2121
id: string
22-
source: 'native' | 'web'
22+
source: 'external' | 'web'
2323
startTime: number
2424
state: 'active' | 'expired-inactivity' | 'expired-duration'
2525
}

packages/web/src/managers/user-manager/user-manager.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,12 @@ export class UserManager {
3333
constructor(
3434
public userTrackingMode: UserTrackingMode,
3535
private readonly storageManager: StorageManager,
36-
) {}
36+
anonymousId?: string,
37+
) {
38+
if (userTrackingMode === 'anonymousTracking') {
39+
this.anonymousId = anonymousId
40+
}
41+
}
3742

3843
static isUserTrackingMode = (value: unknown): value is UserTrackingMode =>
3944
value === 'noTracking' || value === 'anonymousTracking'

packages/web/src/types/config.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import {
3030
SplunkUserInteractionInstrumentationConfig,
3131
SplunkWebVitalsInstrumentationConfig,
3232
} from '../instrumentations'
33+
import { ExternalSessionMetadata } from './external-session-metadata'
3334

3435
export interface SplunkOtelWebOptionsInstrumentations {
3536
connectivity?: boolean | InstrumentationConfig
@@ -192,6 +193,8 @@ export interface SplunkOtelWebConfig {
192193
*/
193194
rumAccessToken?: string
194195

196+
sessionMetadata?: NonNullable<ExternalSessionMetadata>
197+
195198
/**
196199
* Enables SPA (Single Page Application) metrics.
197200
*
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
*
3+
* Copyright 2020-2026 Splunk Inc.
4+
*
5+
* Licensed under the Apache License, Version 2.0 (the "License");
6+
* you may not use this file except in compliance with the License.
7+
* You may obtain a copy of the License at
8+
*
9+
* https://www.apache.org/licenses/LICENSE-2.0
10+
*
11+
* Unless required by applicable law or agreed to in writing, software
12+
* distributed under the License is distributed on an "AS IS" BASIS,
13+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
14+
* See the License for the specific language governing permissions and
15+
* limitations under the License.
16+
*
17+
*/
18+
19+
export type ExternalSessionMetadata = {
20+
anonymousUserId: string
21+
sessionId: string
22+
sessionLastActivity: number
23+
sessionStart: number
24+
} | null
25+
26+
export function isValidExternalSessionMetadata(value: unknown): value is NonNullable<ExternalSessionMetadata> {
27+
if (typeof value !== 'object' || value === null) {
28+
return false
29+
}
30+
31+
const metadata = value as Record<string, unknown>
32+
33+
return (
34+
typeof metadata.sessionId === 'string' &&
35+
metadata.sessionId.length > 0 &&
36+
typeof metadata.sessionStart === 'number' &&
37+
Number.isFinite(metadata.sessionStart) &&
38+
metadata.sessionStart > 0 &&
39+
typeof metadata.sessionLastActivity === 'number' &&
40+
Number.isFinite(metadata.sessionLastActivity) &&
41+
metadata.sessionLastActivity > 0 &&
42+
typeof metadata.anonymousUserId === 'string' &&
43+
metadata.anonymousUserId.length > 0
44+
)
45+
}

0 commit comments

Comments
 (0)