@@ -21,6 +21,7 @@ import {
2121 deleteCookie ,
2222 generateId ,
2323 getTimestamp ,
24+ isBrowser ,
2425 createConsentManager ,
2526 collectAttribution ,
2627} from '@imtbl/audience-core' ;
@@ -37,12 +38,18 @@ export class ImmutableAudienceSDK {
3738
3839 private userId : string | undefined ;
3940
41+ private sessionId : string | undefined ;
42+
43+ private sessionStartTime : number | undefined ;
44+
4045 private debug : DebugLogger ;
4146
4247 private config : Required < Pick < AudienceSDKConfig , 'publishableKey' | 'environment' > > & AudienceSDKConfig ;
4348
4449 private destroyed = false ;
4550
51+ private unloadHandler ?: ( ) => void ;
52+
4653 constructor ( config : AudienceSDKConfig ) {
4754 const {
4855 publishableKey,
@@ -82,14 +89,16 @@ export class ImmutableAudienceSDK {
8289 ) ;
8390
8491 this . queue . start ( ) ;
92+ this . registerSessionEnd ( ) ;
8593 }
8694
8795 // -- Public API ---------------------------------------------------------
8896
8997 page ( properties ?: Record < string , unknown > ) : void {
9098 if ( ! this . canTrack ( ) ) return ;
9199
92- const { sessionId } = this . touchSession ( ) ;
100+ const { sessionId, isNew } = this . touchSession ( ) ;
101+ this . refreshSession ( sessionId , isNew ) ;
93102 const attribution = collectAttribution ( ) ;
94103 const context = collectContext ( ) ;
95104
@@ -111,7 +120,8 @@ export class ImmutableAudienceSDK {
111120 track ( eventName : string , properties ?: Record < string , unknown > ) : void {
112121 if ( ! this . canTrack ( ) ) return ;
113122
114- const { sessionId } = this . touchSession ( ) ;
123+ const { sessionId, isNew } = this . touchSession ( ) ;
124+ this . refreshSession ( sessionId , isNew ) ;
115125 const context = collectContext ( ) ;
116126
117127 const message : TrackMessage = {
@@ -134,7 +144,8 @@ export class ImmutableAudienceSDK {
134144 if ( this . destroyed || this . consent . level !== 'full' ) return ;
135145
136146 this . userId = userId ;
137- const { sessionId } = this . touchSession ( ) ;
147+ const { sessionId, isNew } = this . touchSession ( ) ;
148+ this . refreshSession ( sessionId , isNew ) ;
138149 const context = collectContext ( ) ;
139150
140151 const message : IdentifyMessage = {
@@ -179,7 +190,8 @@ export class ImmutableAudienceSDK {
179190 const previous = this . consent . level ;
180191 this . consent . setLevel ( level ) ;
181192
182- // Clear cookies on revocation (core handles queue purge)
193+ // TODO: cookie cleanup should move to core's createConsentManager
194+ // so all surfaces (SDK, Pixel) get consistent revocation behavior.
183195 if ( level === 'none' ) {
184196 deleteCookie ( COOKIE_NAME , this . config . cookieDomain ) ;
185197 deleteCookie ( SESSION_COOKIE , this . config . cookieDomain ) ;
@@ -194,9 +206,85 @@ export class ImmutableAudienceSDK {
194206
195207 destroy ( ) : void {
196208 this . destroyed = true ;
209+ this . removeSessionEnd ( ) ;
197210 this . queue . destroy ( ) ;
198211 }
199212
213+ // -- Session lifecycle --------------------------------------------------
214+
215+ private refreshSession ( sessionId : string , isNew : boolean ) : void {
216+ this . sessionId = sessionId ;
217+ if ( isNew ) {
218+ this . sessionStartTime = Date . now ( ) ;
219+ this . fireSessionStart ( sessionId ) ;
220+ }
221+ }
222+
223+ private fireSessionStart ( sessionId : string ) : void {
224+ if ( ! this . canTrack ( ) ) return ;
225+
226+ const message : TrackMessage = {
227+ type : 'track' ,
228+ eventName : 'session_start' ,
229+ messageId : generateId ( ) ,
230+ eventTimestamp : getTimestamp ( ) ,
231+ anonymousId : this . anonymousId ,
232+ surface : 'web' ,
233+ context : collectContext ( ) ,
234+ properties : { sessionId } ,
235+ userId : this . consent . level === 'full' ? this . userId : undefined ,
236+ } ;
237+
238+ this . debug . logEvent ( 'session_start' , message ) ;
239+ this . queue . enqueue ( message ) ;
240+ }
241+
242+ private fireSessionEnd ( ) : void {
243+ if ( ! this . canTrack ( ) || ! this . sessionId ) return ;
244+
245+ const duration = this . sessionStartTime
246+ ? Math . round ( ( Date . now ( ) - this . sessionStartTime ) / 1000 )
247+ : undefined ;
248+
249+ const message : TrackMessage = {
250+ type : 'track' ,
251+ eventName : 'session_end' ,
252+ messageId : generateId ( ) ,
253+ eventTimestamp : getTimestamp ( ) ,
254+ anonymousId : this . anonymousId ,
255+ surface : 'web' ,
256+ context : collectContext ( ) ,
257+ properties : {
258+ sessionId : this . sessionId ,
259+ duration,
260+ } ,
261+ userId : this . consent . level === 'full' ? this . userId : undefined ,
262+ } ;
263+
264+ this . debug . logEvent ( 'session_end' , message ) ;
265+ this . queue . enqueue ( message ) ;
266+ }
267+
268+ private registerSessionEnd ( ) : void {
269+ if ( ! isBrowser ( ) ) return ;
270+
271+ this . unloadHandler = ( ) => this . fireSessionEnd ( ) ;
272+
273+ document . addEventListener ( 'visibilitychange' , ( ) => {
274+ if ( document . visibilityState === 'hidden' ) {
275+ this . unloadHandler ?.( ) ;
276+ }
277+ } ) ;
278+ window . addEventListener ( 'pagehide' , this . unloadHandler ) ;
279+ }
280+
281+ private removeSessionEnd ( ) : void {
282+ if ( this . unloadHandler ) {
283+ window . removeEventListener ( 'pagehide' , this . unloadHandler ) ;
284+ this . unloadHandler = undefined ;
285+ }
286+ }
287+
200288 // -- Internals ----------------------------------------------------------
201289
202290 private canTrack ( ) : boolean {
0 commit comments