@@ -601,6 +601,246 @@ describe('api', () => {
601601 } )
602602 } )
603603
604+ describe ( 'snapshot timeout' , ( ) => {
605+ function createSnapshotProbe ( methodName : string ) : Probe {
606+ return {
607+ id : `timeout-probe-${ methodName } ` ,
608+ version : 0 ,
609+ type : 'LOG_PROBE' ,
610+ where : { typeName : 'TestClass' , methodName } ,
611+ template : 'Test' ,
612+ captureSnapshot : true ,
613+ capture : { maxReferenceDepth : 3 } ,
614+ sampling : { snapshotsPerSecond : 5000 } ,
615+ evaluateAt : 'ENTRY' ,
616+ }
617+ }
618+
619+ it ( 'should drop snapshot when entry capture exceeds timeout' , ( ) => {
620+ const probe = createSnapshotProbe ( 'entryTimeout' )
621+ addProbe ( probe )
622+
623+ let callCount = 0
624+ const realNow = performance . now . bind ( performance )
625+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
626+ callCount ++
627+ // Let the first few calls (start time, deadline creation) use real time,
628+ // then jump past the deadline to simulate slow capture.
629+ if ( callCount <= 3 ) {
630+ return realNow ( )
631+ }
632+ return realNow ( ) + 20
633+ } )
634+
635+ const probes = getProbes ( 'TestClass;entryTimeout' ) !
636+ const deepObj = { level1 : { level2 : { level3 : { level4 : 'deep' } } } }
637+ onEntry ( probes , { } , { arg : deepObj } )
638+ onReturn ( probes , null , { } , { arg : deepObj } , { } )
639+
640+ // The entry capture timed out, so onEntry pushed null.
641+ // onReturn still gets an active entry from its own onEntry call, but
642+ // the entry snapshot is dropped. The return capture has its own timeout.
643+ // Since performance.now is still returning future values, the return
644+ // capture also times out and no snapshot is sent.
645+ expect ( mockBatchAdd ) . not . toHaveBeenCalled ( )
646+ } )
647+
648+ it ( 'should drop snapshot when return capture exceeds timeout' , ( ) => {
649+ const probe = createSnapshotProbe ( 'returnTimeout' )
650+ addProbe ( probe )
651+
652+ const probes = getProbes ( 'TestClass;returnTimeout' ) !
653+
654+ // Let onEntry succeed with real time
655+ onEntry ( probes , { } , { x : 1 } )
656+
657+ // Now make performance.now jump forward so the return capture times out
658+ let callCount = 0
659+ const realNow = performance . now . bind ( performance )
660+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
661+ callCount ++
662+ if ( callCount <= 2 ) {
663+ return realNow ( )
664+ }
665+ return realNow ( ) + 20
666+ } )
667+
668+ onReturn ( probes , null , { } , { x : 1 } , { local : 'value' } )
669+
670+ expect ( mockBatchAdd ) . not . toHaveBeenCalled ( )
671+ } )
672+
673+ it ( 'should drop snapshot when throw capture exceeds timeout' , ( ) => {
674+ const probe = createSnapshotProbe ( 'throwTimeout' )
675+ addProbe ( probe )
676+
677+ const probes = getProbes ( 'TestClass;throwTimeout' ) !
678+
679+ // Let onEntry succeed with real time
680+ onEntry ( probes , { } , { x : 1 } )
681+
682+ // Now make performance.now jump forward so the throw capture times out
683+ let callCount = 0
684+ const realNow = performance . now . bind ( performance )
685+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
686+ callCount ++
687+ if ( callCount <= 2 ) {
688+ return realNow ( )
689+ }
690+ return realNow ( ) + 20
691+ } )
692+
693+ onThrow ( probes , new Error ( 'test' ) , { } , { x : 1 } )
694+
695+ expect ( mockBatchAdd ) . not . toHaveBeenCalled ( )
696+ } )
697+
698+ it ( 'should not affect non-snapshot probes' , ( ) => {
699+ const probe : Probe = {
700+ id : 'non-snapshot-timeout' ,
701+ version : 0 ,
702+ type : 'LOG_PROBE' ,
703+ where : { typeName : 'TestClass' , methodName : 'nonSnapshot' } ,
704+ template : 'Test' ,
705+ captureSnapshot : false ,
706+ capture : { } ,
707+ sampling : { snapshotsPerSecond : 5000 } ,
708+ evaluateAt : 'ENTRY' ,
709+ }
710+ addProbe ( probe )
711+
712+ // Spike performance.now to simulate slow execution
713+ let callCount = 0
714+ const realNow = performance . now . bind ( performance )
715+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
716+ callCount ++
717+ if ( callCount <= 2 ) {
718+ return realNow ( )
719+ }
720+ return realNow ( ) + 20
721+ } )
722+
723+ const probes = getProbes ( 'TestClass;nonSnapshot' ) !
724+ onEntry ( probes , { } , { } )
725+ onReturn ( probes , null , { } , { } , { } )
726+
727+ expect ( mockBatchAdd ) . toHaveBeenCalledTimes ( 1 )
728+ } )
729+
730+ it ( 'should not leak active entries when entry capture times out' , ( ) => {
731+ const probe = createSnapshotProbe ( 'entryLeakTest' )
732+ addProbe ( probe )
733+
734+ let shouldTimeout = true
735+ let callCount = 0
736+ const realNow = performance . now . bind ( performance )
737+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
738+ callCount ++
739+ if ( ! shouldTimeout || callCount <= 3 ) {
740+ return realNow ( )
741+ }
742+ return realNow ( ) + 20
743+ } )
744+
745+ const probes = getProbes ( 'TestClass;entryLeakTest' ) !
746+ // This onEntry will time out and push null
747+ onEntry ( probes , { } , { x : 1 } )
748+
749+ // onReturn should handle the null entry gracefully (no snapshot sent)
750+ shouldTimeout = false
751+ callCount = 0
752+ onReturn ( probes , null , { } , { x : 1 } , { } )
753+
754+ expect ( mockBatchAdd ) . not . toHaveBeenCalled ( )
755+ } )
756+
757+ it ( 'should skip subsequent snapshot probes after timeout but still process non-snapshot probes' , ( ) => {
758+ const snapshotProbe1 : Probe = {
759+ id : 'timeout-shared-1' ,
760+ version : 0 ,
761+ type : 'LOG_PROBE' ,
762+ where : { typeName : 'TestClass' , methodName : 'sharedDeadline' } ,
763+ template : 'Snapshot probe' ,
764+ captureSnapshot : true ,
765+ capture : { maxReferenceDepth : 3 } ,
766+ sampling : { snapshotsPerSecond : 5000 } ,
767+ evaluateAt : 'ENTRY' ,
768+ }
769+ const nonSnapshotProbe : Probe = {
770+ id : 'timeout-shared-2' ,
771+ version : 0 ,
772+ type : 'LOG_PROBE' ,
773+ where : { typeName : 'TestClass' , methodName : 'sharedDeadline' } ,
774+ template : 'Non-snapshot probe' ,
775+ captureSnapshot : false ,
776+ capture : { } ,
777+ sampling : { snapshotsPerSecond : 5000 } ,
778+ evaluateAt : 'ENTRY' ,
779+ }
780+ const snapshotProbe2 : Probe = {
781+ id : 'timeout-shared-3' ,
782+ version : 0 ,
783+ type : 'LOG_PROBE' ,
784+ where : { typeName : 'TestClass' , methodName : 'sharedDeadline' } ,
785+ template : 'Second snapshot probe' ,
786+ captureSnapshot : true ,
787+ capture : { maxReferenceDepth : 3 } ,
788+ sampling : { snapshotsPerSecond : 5000 } ,
789+ evaluateAt : 'ENTRY' ,
790+ }
791+ addProbe ( snapshotProbe1 )
792+ addProbe ( nonSnapshotProbe )
793+ addProbe ( snapshotProbe2 )
794+
795+ let callCount = 0
796+ const realNow = performance . now . bind ( performance )
797+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
798+ callCount ++
799+ if ( callCount <= 3 ) {
800+ return realNow ( )
801+ }
802+ return realNow ( ) + 20
803+ } )
804+
805+ const probes = getProbes ( 'TestClass;sharedDeadline' ) !
806+ onEntry ( probes , { } , { } )
807+ onReturn ( probes , null , { } , { } , { } )
808+
809+ // The non-snapshot probe should still send, but both snapshot probes should be dropped
810+ const calls = mockBatchAdd . calls . allArgs ( )
811+ expect ( calls . length ) . toBe ( 1 )
812+ expect ( calls [ 0 ] [ 0 ] . message ) . toBe ( 'Non-snapshot probe' )
813+ } )
814+
815+ it ( 'should share deadline across probes so second snapshot probe exits immediately' , ( ) => {
816+ const probe1 = createSnapshotProbe ( 'sharedDeadline1' )
817+ const probe2 : Probe = {
818+ ...createSnapshotProbe ( 'sharedDeadline2' ) ,
819+ id : 'timeout-probe-sharedDeadline2' ,
820+ where : { typeName : 'TestClass' , methodName : 'sharedDeadline1' } ,
821+ }
822+ addProbe ( probe1 )
823+ addProbe ( probe2 )
824+
825+ let callCount = 0
826+ const realNow = performance . now . bind ( performance )
827+ spyOn ( performance , 'now' ) . and . callFake ( ( ) => {
828+ callCount ++
829+ if ( callCount <= 3 ) {
830+ return realNow ( )
831+ }
832+ return realNow ( ) + 20
833+ } )
834+
835+ const probes = getProbes ( 'TestClass;sharedDeadline1' ) !
836+ onEntry ( probes , { } , { x : 1 } )
837+ onReturn ( probes , null , { } , { x : 1 } , { } )
838+
839+ // Both snapshot probes share the deadline -- neither should send
840+ expect ( mockBatchAdd ) . not . toHaveBeenCalled ( )
841+ } )
842+ } )
843+
604844 describe ( 'error handling' , ( ) => {
605845 it ( 'should handle missing DD_RUM gracefully' , ( ) => {
606846 delete ( window as any ) . DD_RUM
0 commit comments