diff --git a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.test.ts b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.test.ts index c53916057f..5097197624 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/ObservabilityClient.test.ts @@ -1,6 +1,10 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { ObservabilityClient } from './ObservabilityClient' import { Platform } from 'react-native' +import { + SESSION_RESUME_THRESHOLD_MS, + SESSION_STORAGE_KEY, +} from '../constants/sessions' describe('ObservabilityClient — stop during async init', () => { beforeEach(() => { @@ -155,4 +159,32 @@ describe('ObservabilityClient — platform attributes on the Resource', () => { 'device.model.identifier': 'user-model', }) }) + + it('carries session.previous_id when a new session replaces a stale one', async () => { + const now = Date.now() + localStorage.setItem( + SESSION_STORAGE_KEY, + JSON.stringify({ + sessionId: 'stale-session-id', + startTime: now - SESSION_RESUME_THRESHOLD_MS * 2, + lastActivityTime: now - SESSION_RESUME_THRESHOLD_MS - 1_000, + reloadCount: 0, + }), + ) + try { + const attributes = await buildInitializedResource() + + expect(attributes['session.previous_id']).toBe('stale-session-id') + expect(attributes['session.id']).not.toBe('stale-session-id') + } finally { + localStorage.clear() + } + }) + + it('omits session.previous_id when there was no previous session', async () => { + localStorage.clear() + const attributes = await buildInitializedResource() + + expect(attributes).not.toHaveProperty('session.previous_id') + }) }) diff --git a/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.test.ts b/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.test.ts index be90d961b8..9bf225fe76 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.test.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.test.ts @@ -156,6 +156,80 @@ describe('SessionManager session preservation', () => { expect(mgr.getResumeInfo().reloadCount).toBe(0) }) + it('links a session started after a stale one via session.previous_id', async () => { + const store = new MemoryStore() + const now = Date.now() + store.seed({ + sessionId: 'stale-session-id', + startTime: now - SESSION_RESUME_THRESHOLD_MS * 2, + lastActivityTime: now - SESSION_RESUME_THRESHOLD_MS - 1_000, + reloadCount: 0, + }) + + const mgr = new SessionManager({}, store) + await mgr.initialize() + + expect(mgr.getPreviousSessionId()).toBe('stale-session-id') + expect(mgr.getSessionAttributes()).toEqual({ + 'session.id': mgr.getSessionInfo().sessionId, + 'session.start_time': mgr.getSessionInfo().startTime.toString(), + 'session.previous_id': 'stale-session-id', + }) + // The next load links to this session, not the one before it. + expect(store.read().sessionId).toBe(mgr.getSessionInfo().sessionId) + }) + + it('links a session started by a cold start via session.previous_id', async () => { + const store = new MemoryStore() + const now = Date.now() + store.seed({ + sessionId: 'prev-session-id', + startTime: now - 60_000, + lastActivityTime: now - 1_000, + reloadCount: 0, + }) + + const mgr = new SessionManager( + { getProcessStartTimeMs: () => now - 500 }, + store, + ) + await mgr.initialize() + + expect(mgr.getSessionAttributes()['session.previous_id']).toBe( + 'prev-session-id', + ) + }) + + it('omits session.previous_id when the session is resumed', async () => { + const store = new MemoryStore() + const now = Date.now() + store.seed({ + sessionId: 'prev-session-id', + startTime: now - 60_000, + lastActivityTime: now - 1_000, + reloadCount: 0, + }) + + const mgr = new SessionManager({}, store) + await mgr.initialize() + + expect(mgr.getSessionInfo().sessionId).toBe('prev-session-id') + expect(mgr.getPreviousSessionId()).toBeUndefined() + expect(mgr.getSessionAttributes()).not.toHaveProperty( + 'session.previous_id', + ) + }) + + it('omits session.previous_id when nothing was persisted', async () => { + const mgr = new SessionManager({}, new MemoryStore()) + await mgr.initialize() + + expect(mgr.getPreviousSessionId()).toBeUndefined() + expect(mgr.getSessionAttributes()).not.toHaveProperty( + 'session.previous_id', + ) + }) + it('does not resume without a persistent store', async () => { const mgr = new SessionManager({}, noOpStore) await mgr.initialize() diff --git a/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.ts b/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.ts index 95b3083c4b..4bec03de7f 100644 --- a/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.ts +++ b/sdk/@launchdarkly/observability-react-native/src/client/SessionManager.ts @@ -61,6 +61,9 @@ export class SessionManager { reloadCount: 0, } private lastPersistAt = 0 + // The persisted session this JS load replaced (stale or cold start), so + // the new session's telemetry can link back to it as `session.previous_id`. + private previousSessionId?: string constructor(options?: Options, store?: SessionStore) { this.options = { @@ -110,6 +113,7 @@ export class SessionManager { startTime: Date.now(), } this.resumeInfo = { reloaded: false, elapsedMs: 0, reloadCount: 0 } + this.previousSessionId = undefined } } @@ -161,6 +165,9 @@ export class SessionManager { // completes. this.reloadCount = 0 this.resumeInfo = { reloaded: false, elapsedMs: 0, reloadCount: 0 } + if (previous && previous.sessionId !== this.sessionInfo.sessionId) { + this.previousSessionId = previous.sessionId + } if (this.options.debug && isColdStart) { console.log( @@ -271,10 +278,22 @@ export class SessionManager { return { ...this.sessionInfo } } + /** + * The ID of the persisted session this JS load replaced with a new one + * (the previous session timed out, or the app cold started). Undefined + * when the session was resumed or nothing was persisted. + */ + public getPreviousSessionId(): string | undefined { + return this.previousSessionId + } + public getSessionAttributes(): Record { return { 'session.id': this.sessionInfo.sessionId, 'session.start_time': this.sessionInfo.startTime.toString(), + ...(this.previousSessionId + ? { 'session.previous_id': this.previousSessionId } + : {}), } }