From 7c3a5154ab8fedc390f673635738b98eb676e5e8 Mon Sep 17 00:00:00 2001 From: Vadim Korolik Date: Thu, 29 Oct 2026 23:29:00 +0000 Subject: [PATCH] feat(react-native): link new sessions to the one they replace via session.previous_id When a JS load starts a new session in place of a persisted one (the previous session timed out, or the app cold started), keep the replaced session's ID and report it as `session.previous_id` in the session attributes. They are spread onto the OTel Resource, so every span, log, error and metric of the new session carries it, and sessions can be chained across cold starts. Resumed sessions and first-ever launches omit it. Mirrors the browser SDK's `session.previous_id`. Co-Authored-By: Claude Opus 5.5 Generated-by: termag --- .../src/client/ObservabilityClient.test.ts | 32 ++++++++ .../src/client/SessionManager.test.ts | 74 +++++++++++++++++++ .../src/client/SessionManager.ts | 19 +++++ 3 files changed, 125 insertions(+) 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 } + : {}), } }