diff --git a/entry/src/main/ets/common/DemoSdk.ets b/entry/src/main/ets/common/DemoSdk.ets index f14af5c..4b21f32 100644 --- a/entry/src/main/ets/common/DemoSdk.ets +++ b/entry/src/main/ets/common/DemoSdk.ets @@ -52,10 +52,12 @@ export class DemoSdk { * @param initSampleRate the rate this build ships with. Deliberately not 100 * for the remote-configuration runs: the event reports the rate actually * drawn with, so a distinctive init value is what proves which one won. + * @param sessionOnError keep the sessions the rate misses when they error. */ static init(context: common.Context, prod: boolean, customEndpoint: string, recover: boolean = false, trackErrors: boolean = true, pacing: string = 'demo', - remoteConfig: string = '', initSampleRate: number = 100): void { + remoteConfig: string = '', initSampleRate: number = 100, sessionOnError: boolean = false, + jsCrashPolicy: string = ''): void { if (DemoSdk.initialized) { return; } @@ -93,6 +95,7 @@ export class DemoSdk { .setTrackNavigation(true) // phase 2: auto View events on router push/pop (A1) .setTrackNetworkRequests(true) // phase 2: auto Resource via FlashcatHttp wrapper (A2) .setTrackErrors(trackErrors) // false → crash-only reporting (auto errors suppressed) + .setSessionOnError(sessionOnError) .setEventMapper(DemoSdk.demoEventMapper); // phase 2 R3: PII scrubbing / drop if (remoteConfig.length > 0) { rumBuilder.setRemoteConfigurationEnabled(true); @@ -105,6 +108,8 @@ export class DemoSdk { const crashBuilder: CrashConfigurationBuilder = new CrashConfigurationBuilder(); if (recover) { crashBuilder.setJsCrashPolicy(JsCrashPolicy.REPORT_AND_RECOVER); + } else if (jsCrashPolicy === 'observe_only') { + crashBuilder.setJsCrashPolicy(JsCrashPolicy.OBSERVE_ONLY); } FlashcatCrash.enable(crashBuilder.build()); @@ -149,7 +154,8 @@ export class DemoSdk { /** * Phase 2 R3 demo mapper. Two redactions you can verify in HiLog / NDJSON: * - resource events: strip the query string from `resource.url` (tokens, PII); - * - action events whose target name contains "secret": dropped entirely (null). + * - action events whose target name contains "secret": dropped entirely (null); + * - error events whose message contains "secret": dropped entirely (null). * Everything else passes through unchanged. Must never throw. */ static demoEventMapper(event: Record): Record | null { @@ -179,6 +185,14 @@ export class DemoSdk { } } } + } else if (type === 'error') { + const error: Object | undefined = event['error']; + if (typeof error === 'object') { + const message: Object | undefined = (error as Record)['message']; + if (typeof message === 'string' && message.indexOf('secret') >= 0) { + return null; // drop secret-marked errors + } + } } return event; } diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 59fc3e0..172f8fc 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -119,6 +119,10 @@ export default class EntryAbility extends UIAbility { // this run's build "ships" with, so a run can show which of the two won. EntryAbility.forwardParam(params, 'rc_mode', 'rcMode'); EntryAbility.forwardParam(params, 'rc_init_rate', 'rcInitRate'); + // '1' keeps the sessions the rate misses when they error (sessionOnError). + EntryAbility.forwardParam(params, 'soe', 'soe'); + // 'observe_only' runs the crash feature's OBSERVE_ONLY policy. + EntryAbility.forwardParam(params, 'crash_policy', 'jsCrashPolicy'); hilog.info(DOMAIN, TAG, 'e2e params from %{public}s: endpoint=%{public}s scenario=%{public}s', from, e2eEndpoint, scenario); } else { diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index f4966ac..f2bc39d 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -108,8 +108,10 @@ struct Index { const pacing: string = AppStorage.get('netbenchPacing') ?? 'demo'; // SDK init only const rcMode: string = AppStorage.get('rcMode') ?? ''; const rcInitRate: number = Number.parseInt(AppStorage.get('rcInitRate') ?? '100', 10); + const sessionOnError: boolean = (AppStorage.get('soe') ?? '') === '1'; + const jsCrashPolicy: string = AppStorage.get('jsCrashPolicy') ?? ''; DemoSdk.init(this.context, this.useProd, this.customEndpoint, false, trackErrors, pacing, - rcMode, rcInitRate); + rcMode, rcInitRate, sessionOnError, jsCrashPolicy); } catch (e) { this.append(e instanceof Error ? e.message : 'SDK initialization failed'); return; @@ -188,11 +190,25 @@ struct Index { this.runNetBench(`${endpoint}/e2e/echo`); } else if (name === 'remoteconfig') { this.runRemoteConfigScenario(); + } else if (name.startsWith('soe')) { + this.runSessionOnErrorScenario(name); + } else if (name === 'native-crash') { + this.append('e2e: native crash in 800ms'); + setTimeout(() => { + testNapi.triggerNativeCrash(); + }, 800); } else if (name === 'crash') { this.append('e2e: crashing in 800ms'); setTimeout(() => { throw new Error('E2E crash marker'); }, 800); + } else if (name === 'crash-secret') { + // A crash the demo mapper drops ("secret" in the message): it must not + // count, release, or be reported on the next launch. + this.append('e2e: secret crash in 800ms'); + setTimeout(() => { + throw new Error('E2E secret crash'); + }, 800); } } @@ -232,6 +248,44 @@ struct Index { }, 3000); } + /** + * Error-session capture runs (launch with `--ps soe 1`). Every step is logged + * under FCSOE so a run can be lined up with what the intake received: + * - soe: activity, then an error 2 s later — the error releases the held minute; + * - soe-quiet: activity, then the session ends without an error; + * - soe-secret: activity, then an error the event mapper drops; + * - soe-quiet-native: the session ends without an error, then a native crash. + */ + private runSessionOnErrorScenario(name: string): void { + const monitor: RumMonitor = DemoSdk.monitor(); + monitor.addAction(RumActionType.TAP, 'soe-tap'); + monitor.getCurrentSessionId((sessionId: string | undefined): void => { + hilog.info(0x0000, 'FCSOE', '%{public}s session=%{public}s', name, sessionId ?? 'none'); + }); + setTimeout(() => { + if (name === 'soe') { + monitor.addError('soe manual error', RumErrorSource.CUSTOM, 'at Index.soe'); + hilog.info(0x0000, 'FCSOE', 'error at %{public}d', Date.now()); + } else if (name === 'soe-secret') { + monitor.addError('soe secret error', RumErrorSource.CUSTOM, 'at Index.soe'); + monitor.stopSession(); + hilog.info(0x0000, 'FCSOE', 'dropped error, session stopped at %{public}d', Date.now()); + } else if (name === 'soe-quiet-native') { + // The session is thrown away, then the process dies of a native crash + // before any view is written: the next launch must not rebuild it. + monitor.stopSession(); + hilog.info(0x0000, 'FCSOE', 'session stopped at %{public}d; native crash in 1 s', Date.now()); + setTimeout(() => { + testNapi.triggerNativeCrash(); + }, 1000); + } else { + monitor.stopSession(); + hilog.info(0x0000, 'FCSOE', 'session stopped at %{public}d', Date.now()); + } + this.append(`e2e: ${name} done`); + }, 2000); + } + aboutToDisappear(): void { if (!this.faultWatcherRegistered) { return; diff --git a/flashcat-core/src/main/ets/internal/FlashcatCore.ets b/flashcat-core/src/main/ets/internal/FlashcatCore.ets index 7002817..3571b32 100644 --- a/flashcat-core/src/main/ets/internal/FlashcatCore.ets +++ b/flashcat-core/src/main/ets/internal/FlashcatCore.ets @@ -242,6 +242,9 @@ export class FlashcatCore implements SdkCore { // included. Revocation has to take it too. this.bus.send(CRASH_STORAGE_TARGET, { 'type': 'crash_storage_delete_last_view' } as Record); + // RUM may still hold events in memory that have not reached a batch; + // those are the refused user's data as much as the batches are. + this.bus.send(RUM_FEATURE_NAME, { 'type': 'consent_revoked' } as Record); this.deferredUploadRegistrar.deregister(this.deferredWorkOptions()); } else { // → PENDING: stop uploading; new events buffer separately until a decision. diff --git a/flashcat-core/src/main/ets/internal/persistence/CrashPendingStore.ets b/flashcat-core/src/main/ets/internal/persistence/CrashPendingStore.ets index 48ed124..e2b8767 100644 --- a/flashcat-core/src/main/ets/internal/persistence/CrashPendingStore.ets +++ b/flashcat-core/src/main/ets/internal/persistence/CrashPendingStore.ets @@ -73,6 +73,11 @@ export class CrashPendingStore implements FeatureEventReceiver { CrashPendingStore.stringValue(event['incident_id'])); const serialized: string = CrashPendingStore.stringValue(event['serialized']); event['success'] = this.writeIncident(incidentId, serialized); + if (event['success'] === true) { + // The name is this store's to choose; the writer gets it back so it can + // delete exactly what it wrote. + event['file_name'] = `${INCIDENT_PREFIX}${incidentId}${INCIDENT_SUFFIX}`; + } } else if (type === 'crash_storage_list') { event['files'] = this.readIncidents(); } else if (type === 'crash_storage_delete') { diff --git a/flashcat-core/src/test/ConsentPolicy.test.ets b/flashcat-core/src/test/ConsentPolicy.test.ets index a038c9e..a398708 100644 --- a/flashcat-core/src/test/ConsentPolicy.test.ets +++ b/flashcat-core/src/test/ConsentPolicy.test.ets @@ -3,6 +3,7 @@ import { common } from '@kit.AbilityKit'; import { ConfigurationBuilder } from '../main/ets/config/Configuration'; import { TrackingConsent } from '../main/ets/privacy/TrackingConsent'; import { FlashcatCore } from '../main/ets/internal/FlashcatCore'; +import { FeatureEventReceiver, RUM_FEATURE_NAME } from '../main/ets/api/feature/Feature'; // NOTE (headless runner): Preferences are process-wide per name, so two // FlashcatCore constructions in one test behave like two app launches sharing @@ -91,6 +92,25 @@ export default function consentPolicyTests(): void { main2.stop(); }); + it('revocationIsAnnouncedToRum', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // RUM may hold events in memory that never reached a batch; the wipe + // of the batches has to reach those too. + const core: FlashcatCore = build(TrackingConsent.GRANTED); + const received: string[] = []; + const receiver: FeatureEventReceiver = { + onReceive: (event: Record): void => { + received.push(event['type'] as string); + } + }; + core.setEventReceiver(RUM_FEATURE_NAME, receiver); + core.setTrackingConsent(TrackingConsent.PENDING); + expect(received.indexOf('consent_revoked')).assertEqual(-1); // undecided is not refused + core.setTrackingConsent(TrackingConsent.NOT_GRANTED); + expect(received.indexOf('consent_revoked') !== -1).assertTrue(); + core.stop(); + }); + it('stopDeactivatesTheCore', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FlashcatCore = build(TrackingConsent.GRANTED); diff --git a/flashcat-crash/src/main/ets/internal/CrashFeature.ets b/flashcat-crash/src/main/ets/internal/CrashFeature.ets index 30422c4..581e316 100644 --- a/flashcat-crash/src/main/ets/internal/CrashFeature.ets +++ b/flashcat-crash/src/main/ets/internal/CrashFeature.ets @@ -54,6 +54,8 @@ export class CrashFeature implements Feature, FeatureEventReceiver { // Newest incident persisted with REPORT_AND_RECOVER this run, so a failed // restart can downgrade it before exit (crash.recovered must not lie). private lastRecoveryIncident: CrashIncidentRecord | null = null; + // The file the last pending write landed in, as the storage named it. + private lastPersistedFileName: string = ''; // Incidents whose replay delivery FAILED this launch (retained on disk). // The in-process watcher must not report their crashes a second time — the // retained incident is the canonical carrier and replays next launch. @@ -263,6 +265,14 @@ export class CrashFeature implements Feature, FeatureEventReceiver { } } } + if (written && this.droppedByRumMapper(this.lastRecoveryIncident ?? incident, fingerprint, nowMs)) { + this.lastRecoveryIncident = null; + event['suppressed'] = true; + event['pending_written'] = false; + event['report_async'] = false; + event['should_exit'] = true; + return; + } event['pending_written'] = written; // Termination is policy-driven, not write-driven: a rare pending-write // failure must not silently revert to keep-alive semantics. The report @@ -322,7 +332,43 @@ export class CrashFeature implements Feature, FeatureEventReceiver { 'success': false }; this.sendStorageCommand(command); - return command['success'] === true; + if (command['success'] === true) { + this.lastPersistedFileName = CrashFeature.stringValue(command['file_name']); + return true; + } + return false; + } + + /** + * RUM's event mapper has the last word on whether a persisted crash is an + * error at all, and in a session that withholds its events that word has to + * be spoken in this process: the crash counts on the view and releases what + * the session held here, while the error itself is only assembled on the next + * launch. RUM is asked with the very event the replay would deliver, built + * from the persisted record, so the mapper judges one event, not two. A crash + * it drops is removed again, remembered as consumed so the watcher's copy does + * not come back, and never reported. + */ + private droppedByRumMapper(incident: CrashIncidentRecord, fingerprint: string, nowMs: number): boolean { + const rum: FeatureScope | null = this.core.getFeature(RUM_FEATURE_NAME); + if (rum === null) { + return false; + } + const probe: Record = CrashEventMapper.fromPendingIncident(incident); + probe['type'] = 'crash_mapper_probe'; + probe['target'] = RUM_FEATURE_NAME; + rum.sendEvent(probe); + if (probe['kept'] !== false) { + return false; + } + if (this.lastPersistedFileName.length > 0) { + this.deletePending(this.lastPersistedFileName); + } + this.consumed.push(new ConsumedCrashIncident(incident.incidentId, fingerprint, nowMs, nowMs)); + this.consumed = CrashDeduplication.retainRecent(this.consumed, nowMs, CONSUMED_RETENTION_MS); + this.persistConsumed(); + FlashcatLog.d(`crash.js: event mapper dropped crash incident=${incident.incidentId}; not persisted, not reported`); + return true; } private enableRecovery(): void { diff --git a/flashcat-rum/CHANGELOG.md b/flashcat-rum/CHANGELOG.md index bd471e0..2165778 100644 --- a/flashcat-rum/CHANGELOG.md +++ b/flashcat-rum/CHANGELOG.md @@ -1,5 +1,33 @@ # Changelog +## Unreleased + +- **Error-session capture.** `RumConfigurationBuilder.setSessionOnError(true)` + also keeps the sessions `sessionSampleRate` does not draw, but only those + that report an error. Such a session is collected in memory and none of its + events is uploaded until it reports an error the event mapper keeps; then + the minute leading up to it (its views, and up to 200 actions, resources and + errors within 64 KiB) is uploaded after a short per-session delay, and the + rest of the session is collected normally. A session that ends without an + error is thrown away with everything it held. Off by default; the console's + `sessionOnError` overrides the init value when remote configuration is on. + Its view events report `session.sampled_for_error: true` and a session + sample rate of 0, so each stands for itself. +- `RumMonitor.getCurrentSessionId()` reports `undefined` for such a session + until it has been released: before that the backend has no session under + its id. +- A rate-0 configuration with `sessionOnError` on no longer ends the session + it kept when the configuration is fetched; turning the switch on redraws a + session drawn dark at rate 0. +- Revoking tracking consent now also discards what RUM holds in memory for a + session that has not reached storage. +- Known limitations of error-session capture: a crash replayed on a later + launch that nothing attributes to a session is not reported while the + current session is withheld; and the event mapper must be a pure function + of the event — an uncaught exception that terminates the process is mapped + once at crash time and again when it is replayed on the next launch, and + the two verdicts must agree. + ## 0.6.0 - **Remote configuration.** `RumConfigurationBuilder.setRemoteConfigurationEnabled(true)` diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index f9c2533..1850ebc 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -16,6 +16,8 @@ export class RumConfiguration { /** Whether to read the sampling settings the console publishes. Default false. */ readonly remoteConfigurationEnabled: boolean; readonly beforeSampling: BeforeSamplingCallback | null; + /** Keep the sessions `sessionSampleRate` misses when they report an error. Default false. */ + readonly sessionOnError: boolean; constructor( applicationId: string, @@ -27,7 +29,8 @@ export class RumConfiguration { eventMapper: RumEventMapper | null = null, trackErrors: boolean = true, remoteConfigurationEnabled: boolean = false, - beforeSampling: BeforeSamplingCallback | null = null + beforeSampling: BeforeSamplingCallback | null = null, + sessionOnError: boolean = false ) { this.applicationId = applicationId; this.sessionSampleRate = sessionSampleRate; @@ -39,6 +42,7 @@ export class RumConfiguration { this.eventMapper = eventMapper; this.remoteConfigurationEnabled = remoteConfigurationEnabled; this.beforeSampling = beforeSampling; + this.sessionOnError = sessionOnError; } } @@ -56,6 +60,7 @@ export class RumConfigurationBuilder { private eventMapper: RumEventMapper | null = null; private remoteConfigurationEnabled: boolean = false; private beforeSampling: BeforeSamplingCallback | null = null; + private sessionOnError: boolean = false; constructor(applicationId: string) { this.applicationId = applicationId; @@ -148,6 +153,38 @@ export class RumConfigurationBuilder { return this; } + /** + * Also keep the sessions `sessionSampleRate` does not sample, but only those + * that report an error. Default false. + * + * Such a session is collected in memory and none of its events is uploaded until it + * reports an error of its own — one that survives the event mapper. Then the + * minute leading up to that error (its views, and up to 200 actions, resources + * and errors within 64 KiB, the least informative dropped first when there are + * more) is uploaded, after a delay of up to 3 s that spreads out the uploads a + * fleet-wide failure would otherwise send all at once, and the rest of the + * session is collected normally. A session that ends without an error is + * thrown away with everything it held, and nothing of it — not even a crash + * replayed on the next launch — reaches the backend afterwards. An uncaught + * ArkTS exception the event mapper keeps releases what was held at once, + * before the process exits, with or without the crash feature; one the + * mapper drops releases nothing and is not reported. A native crash or a + * freeze, reported on the next launch, is delivered with the last view of + * the session only — what was held in memory died with the process. + * + * Such sessions report a session sample rate of 0 and + * `session.sampled_for_error: true`: each one stands for itself rather than + * for `100 / rate` sessions. A rate the `beforeSampling` hook sets to 0 turns + * this off for that session — 0 means never collect. + * + * With remote configuration enabled, the console's `sessionOnError` overrides + * this value for the sessions drawn after it arrives. + */ + setSessionOnError(enabled: boolean): RumConfigurationBuilder { + this.sessionOnError = enabled; + return this; + } + build(): RumConfiguration { return new RumConfiguration( this.applicationId, @@ -159,7 +196,8 @@ export class RumConfigurationBuilder { this.eventMapper, this.trackErrors, this.remoteConfigurationEnabled, - this.beforeSampling + this.beforeSampling, + this.sessionOnError ); } } diff --git a/flashcat-rum/src/main/ets/RumMonitor.ets b/flashcat-rum/src/main/ets/RumMonitor.ets index 5a12dfa..a38bf11 100644 --- a/flashcat-rum/src/main/ets/RumMonitor.ets +++ b/flashcat-rum/src/main/ets/RumMonitor.ets @@ -34,6 +34,15 @@ export interface RumMonitor { /** Remove ALL global attributes. */ clearAttributes(): void; + /** + * The id of the current session, or `undefined` when there is none. A session + * `sessionOnError` keeps only if it errors reports `undefined` until it has + * reported an error and its events have been released: before that the + * backend holds no session under its id, so the id could not be looked up. + * From the release on it reports its real id. A session `sessionSampleRate` + * did not draw still reports its id, as it always has: it exists in the SDK, + * even though the backend never sees it. + */ getCurrentSessionId(callback: (sessionId: string | undefined) => void): void; /** diff --git a/flashcat-rum/src/main/ets/internal/LastViewSnapshot.ets b/flashcat-rum/src/main/ets/internal/LastViewSnapshot.ets index 949f427..fedde27 100644 --- a/flashcat-rum/src/main/ets/internal/LastViewSnapshot.ets +++ b/flashcat-rum/src/main/ets/internal/LastViewSnapshot.ets @@ -21,21 +21,29 @@ import { FlashcatLog } from '@flashcatcloud/core'; */ export type LastViewWriter = (serialized: string) => void; export type LastViewReader = () => string; +export type LastViewDeleter = () => void; export class LastViewSnapshot { private static writer: LastViewWriter | null = null; private static reader: LastViewReader | null = null; + private static deleter: LastViewDeleter | null = null; + /** The session of the view last recorded by this process, '' when none is on file. */ + private static recordedSessionId: string = ''; /** @internal wired by RumFeature.onInitialize to the core's crash storage. */ - static configure(writer: LastViewWriter, reader: LastViewReader): void { + static configure(writer: LastViewWriter, reader: LastViewReader, deleter: LastViewDeleter): void { LastViewSnapshot.writer = writer; LastViewSnapshot.reader = reader; + LastViewSnapshot.deleter = deleter; + LastViewSnapshot.recordedSessionId = ''; } /** @internal cleared on SDK stop so a torn-down core is never written to. */ static reset(): void { LastViewSnapshot.writer = null; LastViewSnapshot.reader = null; + LastViewSnapshot.deleter = null; + LastViewSnapshot.recordedSessionId = ''; } /** @@ -51,6 +59,7 @@ export class LastViewSnapshot { } try { w(JSON.stringify(event)); + LastViewSnapshot.recordedSessionId = LastViewSnapshot.sessionIdOf(event); } catch (e) { // Snapshotting is an optimisation for the NEXT launch; never let it // interfere with the event actually being delivered on this one. @@ -58,6 +67,27 @@ export class LastViewSnapshot { } } + /** + * Forget the snapshot if it describes `sessionId`. A session thrown away + * without ever reaching the backend must not be rebuilt by a crash replayed + * against its last view; the snapshot of any other session stays. + */ + static invalidateFor(sessionId: string): void { + if (sessionId.length === 0 || LastViewSnapshot.recordedSessionId !== sessionId) { + return; + } + LastViewSnapshot.recordedSessionId = ''; + const d: LastViewDeleter | null = LastViewSnapshot.deleter; + if (d === null) { + return; + } + try { + d(); + } catch (e) { + FlashcatLog.e(`rum.lastview: snapshot delete failed: ${e instanceof Error ? e.message : `${e}`}`); + } + } + /** Read back the snapshot, or null when absent/corrupt. */ static read(): Record | null { const r: LastViewReader | null = LastViewSnapshot.reader; @@ -79,4 +109,11 @@ export class LastViewSnapshot { return null; } } + + private static sessionIdOf(event: Record): string { + const session: Object | undefined = event['session']; + const id: Object | undefined = typeof session === 'object' && session !== null ? + (session as Record)['id'] : undefined; + return typeof id === 'string' ? id as string : ''; + } } diff --git a/flashcat-rum/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index d2f8ac4..feb175d 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -103,6 +103,12 @@ export class RumFeature implements Feature, FeatureEventReceiver { }; this.sendStorageCommand(command); return RumFeature.asString(command['serialized']); + }, + (): void => { + this.sendStorageCommand({ + 'type': 'crash_storage_delete_last_view', + 'target': CRASH_STORAGE_TARGET + }); }); // Capture the PREVIOUS process's snapshot now, before this run writes any @@ -228,6 +234,7 @@ export class RumFeature implements Feature, FeatureEventReceiver { if (this.monitor !== null) { this.monitor.stopKeepAlive(); // stop the keep-alive timer so it doesn't leak this.monitor.stopRemoteConfig(); // and the configuration refresh with it + this.monitor.stopWithheld(); // and any withheld release still waiting on its jitter } RumNavigationTracker.detach(); // remove the router observer RumAutoInstrumentation.reset(); // make tap/nav trackers inert after stop @@ -260,6 +267,16 @@ export class RumFeature implements Feature, FeatureEventReceiver { } } else if (type === 'crash_report') { this.onCrashReport(event); + } else if (type === 'crash_mapper_probe') { + // The crash feature is about to let the process die of a persisted + // incident and asks whether the app's mapper keeps the error the next + // launch will replay from it. `event` is that very replay event, so both + // verdicts are on one and the same event. The answer only matters to a + // session that withholds its events — counting the crash and releasing + // the held minute happen in this process — so no other session asks the + // mapper now: for them it runs once, at the replay, as it always has. + const m: DefaultRumMonitor | null = this.monitor; + event['kept'] = m === null || !m.isWithholding() || this.keepsReplayedCrash(event); } else if (type === 'js_crash_policy') { const policy: string = RumFeature.asString(event['policy']); this.activeJsCrashPolicy = policy.length > 0 ? policy : null; @@ -274,10 +291,19 @@ export class RumFeature implements Feature, FeatureEventReceiver { } } else if (type === 'keep_alive') { // App backgrounded — refresh the active view so its final time_spent is sent - // before the batch is flushed (published by the core on background). + // before the batch is flushed (published by the core on background, and by + // every flush, including the one before a crash policy exits the process). const m: DefaultRumMonitor | null = this.monitor; if (m !== null) { m.keepAlive(); + m.releaseScheduledWithheld(); + } + } else if (type === 'consent_revoked') { + // The core has wiped its batches; what a session still holds in memory + // goes the same way. + const m: DefaultRumMonitor | null = this.monitor; + if (m !== null) { + m.forgetWithheld(); } } } @@ -341,7 +367,8 @@ export class RumFeature implements Feature, FeatureEventReceiver { 'unhandled', attributes, undefined, - !alreadyCounted); + !alreadyCounted, + true); // Known limitation: this path cannot observe the write result (the scope // tree has no return channel), so delivered=true is optimistic; and the // freshly minted session re-flips the sampling coin, which can drop the @@ -372,21 +399,9 @@ export class RumFeature implements Feature, FeatureEventReceiver { FlashcatLog.d('rum.crash: dropped because rum feature scope is null'); return false; } - const viewId: string = RumFeature.asString(event['original_view_id']); - const viewName: string = RumFeature.asString(event['original_view_name']); - const dateMs: number = RumFeature.optionalPositiveNumber(event['timestamp_ms']) ?? Date.now(); - const attributes: Record = RumFeature.crashAttributes(event); - const applicationId: string = this.configuration.applicationId; let delivered: boolean = false; scope.withWriteContext((context: FlashcatContext, writer: EventWriter) => { - const errorEvent: Record = RumEventAssembler.error( - context, applicationId, sessionId, - viewId.length > 0 ? viewId : undefined, - viewId.length > 0 ? viewName : undefined, - RumFeature.asString(event['message']), - RumErrorSource.SOURCE as string, - RumFeature.asString(event['stack']), - true, 'unhandled', dateMs, attributes); + const errorEvent: Record = this.assembleReplayedCrash(context, event, sessionId); const mapped: Record | null = RumEventMapperHolder.apply(errorEvent); if (mapped === null) { delivered = true; // the app's mapper dropped it on purpose — handled @@ -401,10 +416,37 @@ export class RumFeature implements Feature, FeatureEventReceiver { } FlashcatLog.d( `rum.crash: replayed crash into original session=${sessionId} ` + - `view=${viewId.length > 0 ? viewId : '(none)'} delivered=${delivered}`); + `view=${RumFeature.asString(event['original_view_id']).length > 0 ? RumFeature.asString(event['original_view_id']) : '(none)'} delivered=${delivered}`); return delivered; } + /** + * The error a replayed incident becomes, stamped with the crashed run's + * session and view. One assembly for the replay and for the crash feature's + * question at crash time, so the app's mapper judges the same event twice. + */ + private assembleReplayedCrash(context: FlashcatContext, event: Record, + sessionId: string): Record { + const viewId: string = RumFeature.asString(event['original_view_id']); + const viewName: string = RumFeature.asString(event['original_view_name']); + const dateMs: number = RumFeature.optionalPositiveNumber(event['timestamp_ms']) ?? Date.now(); + return RumEventAssembler.error( + context, this.configuration.applicationId, sessionId, + viewId.length > 0 ? viewId : undefined, + viewId.length > 0 ? viewName : undefined, + RumFeature.asString(event['message']), + RumErrorSource.SOURCE as string, + RumFeature.asString(event['stack']), + true, 'unhandled', dateMs, RumFeature.crashAttributes(event)); + } + + /** Whether the app's mapper keeps the error `probe` would be replayed as. */ + private keepsReplayedCrash(probe: Record): boolean { + const errorEvent: Record = this.assembleReplayedCrash( + this.core.getContext(), probe, RumFeature.asString(probe['original_session_id'])); + return RumEventMapperHolder.apply(errorEvent) !== null; + } + /** Deliver a sidecar command to the core's crash storage over the bus. */ private sendStorageCommand(command: Record): void { const scope: FeatureScope | null = this.core.getFeature(RUM_FEATURE_NAME); @@ -664,6 +706,16 @@ export class RumFeature implements Feature, FeatureEventReceiver { if (control['duplicate'] === true) { return; } + if (control['suppressed'] === true) { + // The crash feature asked (see `crash_mapper_probe`) and the app's + // mapper dropped the crash: it is not counted and not reported, and + // nothing was persisted for a replay. + FlashcatLog.d('rum.error: crash dropped by the event mapper; not counted, not reported'); + if (control['should_exit'] === true) { + this.flushAndTerminate(control['recover'] === true); + } + return; + } // The incident was durably persisted: the error event arrives via // next-launch replay (bypassing the scope tree), so count the crash on // the active view here — this is the only process that can. @@ -724,6 +776,9 @@ export class RumFeature implements Feature, FeatureEventReceiver { // Frames only: the labeled callback text is for fingerprinting; sending // it as error.stack made the backend title issues "Error name". m.reportError(message, RumErrorSource.SOURCE, RumFeature.stackFramesOnly(stackText), false, 'unhandled'); + // An uncaught exception may be the last thing this process does: what the + // session held for it leaves now, not after a jitter it may not live out. + m.releaseScheduledWithheld(); } else { FlashcatLog.d('rum.error: dropped because monitor is null'); } diff --git a/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets b/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets index e84f8a2..65006db 100644 --- a/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets +++ b/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets @@ -36,14 +36,21 @@ export class RumEventAssembler { documentVersion: number, isActive: boolean, attributes: Record, - draw: DrawnConfiguration | null = null + draw: DrawnConfiguration | null = null, + sampledForError: boolean = false ): Record { const event: Record = {}; event['type'] = 'view'; event['date'] = dateMs; // view START time — stable across all updates of this view.id - event['_dd'] = RumEventAssembler.dd(documentVersion, draw); + event['_dd'] = RumEventAssembler.dd(documentVersion, draw, sampledForError); event['application'] = RumEventAssembler.idObj(applicationId); - event['session'] = RumEventAssembler.session(sessionId); + const session: Record = RumEventAssembler.session(sessionId); + if (sampledForError) { + // Kept only because it errored: its detail starts where the withheld + // buffer reached, so the gap before reads as "not collected", not missing. + session['sampled_for_error'] = true; + } + event['session'] = session; const view: Record = {}; view['id'] = viewId; @@ -240,7 +247,8 @@ export class RumEventAssembler { // ---- shared sub-objects ---- - private static dd(documentVersion: number, draw: DrawnConfiguration | null = null): Record { + private static dd(documentVersion: number, draw: DrawnConfiguration | null = null, + sampledForError: boolean = false): Record { const dd: Record = {}; dd['format_version'] = 2; dd['session'] = RumEventAssembler.sessionPlan(); @@ -252,8 +260,12 @@ export class RumEventAssembler { // init: server-side extrapolation multiplies by this, and reporting the // init value after the console moved the knob would scale the numbers by // a rate nobody drew with. + // + // A session kept only because it errored reports 0 whatever it was drawn + // at: it stands for itself, and 0 is what the backend reads as "one + // session, do not scale" — not for `100 / rate` sessions like a sampled one. const configuration: Record = {}; - configuration['session_sample_rate'] = draw.sessionSampleRate; + configuration['session_sample_rate'] = sampledForError ? 0 : draw.sessionSampleRate; if (draw.version > 0) { configuration['rc_version'] = draw.version; } diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index ff6adc6..45577e8 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -60,7 +60,8 @@ export class DefaultRumMonitor implements RumMonitor { this.applicationScope = new RumApplicationScope( featureScope, core, configuration.applicationId, configuration.sessionSampleRate, remoteConfig, configuration.beforeSampling, - controller === null ? null : (): void => controller.onSessionStarted()); + controller === null ? null : (): void => controller.onSessionStarted(), + configuration.sessionOnError); this.startKeepAlive(); } @@ -72,6 +73,15 @@ export class DefaultRumMonitor implements RumMonitor { } } + /** + * SDK stop. A release that earned its place was already written by the + * flush that precedes stop; one still waiting here (the flush is skipped + * under a refused consent) must not fire through a stopped core. + */ + stopWithheld(): void { + this.applicationScope.stopWithheld(); + } + /** Stop keeping the console's configuration fresh (SDK stop). */ stopRemoteConfig(): void { if (this.controller !== null) { @@ -197,6 +207,24 @@ export class DefaultRumMonitor implements RumMonitor { this.applicationScope.handleEvent({ kind: 'keepAlive', attributes: {}, timestampMs: Date.now() }); } + /** + * The app is being flushed — backgrounded, about to exit, or asked to by the + * host. A session that already reported its error releases what it held now + * instead of after its jitter: the process may not live that long. + */ + releaseScheduledWithheld(): void { + this.applicationScope.releaseScheduledWithheld(); + } + + /** + * Tracking consent was refused. The core wipes what it had collected; a + * session holding its events in memory must forget them the same way, or a + * consent granted again later would release what the refusal was meant to end. + */ + forgetWithheld(): void { + this.applicationScope.forgetWithheld(); + } + /** * Internal: a terminating crash was persisted for next-launch replay. Bump the * active view's error/crash counts now (only the crashing process can), so the @@ -255,8 +283,9 @@ export class DefaultRumMonitor implements RumMonitor { /** Internal: used by the unhandled-exception hook to flag errors as unhandled. */ reportError(message: string, source: RumErrorSource, stack: string | undefined, isCrash: boolean, handling: string, attributes?: Record, timestampMs?: number, - countInView: boolean = true): void { + countInView: boolean = true, inherited: boolean = false): void { const e: RumRawEvent = this.raw('addError', attributes); + e.inherited = inherited; if (timestampMs !== undefined) { e.timestampMs = timestampMs; } @@ -289,8 +318,15 @@ export class DefaultRumMonitor implements RumMonitor { this.globalAttributes.clear(); } + /** Whether the running session is one kept on error that has not been released yet. */ + isWithholding(): boolean { + return this.applicationScope.isWithholding(); + } + getCurrentSessionId(callback: (sessionId: string | undefined) => void): void { - callback(this.applicationScope.getCurrentSessionId()); + // A session kept only on error has nothing on the backend until it is + // released: an id handed out before then could never be looked up. + callback(this.applicationScope.isWithholding() ? undefined : this.applicationScope.getCurrentSessionId()); } stopSession(): void { diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index 0170b99..f4f5672 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -64,6 +64,7 @@ const FIELD_REFRESH_ON_FOREGROUND: string = 'refresh_on_foreground'; const FIELD_RUM: string = 'rum'; const FIELD_CUSTOM: string = 'custom'; const FIELD_SESSION_SAMPLE_RATE: string = 'sessionSampleRate'; +const FIELD_SESSION_ON_ERROR: string = 'sessionOnError'; /** * Keeps the stored remote configuration in step with what the console says. @@ -307,14 +308,14 @@ export class RemoteConfigController { const refreshOnForeground: boolean = json[FIELD_REFRESH_ON_FOREGROUND] === true; const before: number | null = this.storedRate(); - const rate: number | null = enabled - ? RemoteConfigController.readRate(RemoteConfigController.objectOf(json[FIELD_RUM])) - : null; + const rumValues: Record | null = enabled ? RemoteConfigController.objectOf(json[FIELD_RUM]) : null; + const rate: number | null = RemoteConfigController.readRate(rumValues); + const sessionOnError: boolean | null = RemoteConfigController.readSwitch(rumValues, FIELD_SESSION_ON_ERROR); // Stored as the raw string: delivery is the platform's job, the meaning // belongs to the host application. const custom: string | null = enabled ? RemoteConfigController.rawObjectOf(json[FIELD_CUSTOM]) : null; const ttl: number = RemoteConfigController.positiveIntOf(json[FIELD_TTL]) ?? DEFAULT_TTL_SECONDS; - if (!this.store.write(new RemoteConfigValues(rate, version, custom, etag, ttl, refreshOnForeground))) { + if (!this.store.write(new RemoteConfigValues(rate, version, custom, etag, ttl, refreshOnForeground, sessionOnError))) { FlashcatLog.d('rum.remoteconfig: configuration exceeds the storage budget; keeping the values already in use'); return ApplyOutcome.UNREADABLE; } @@ -366,6 +367,12 @@ export class RemoteConfigController { return value as number; } + /** A switch the response did not send, or sent as anything but a boolean, stays + * absent so the value passed to init keeps applying. */ + private static readSwitch(rum: Record | null, field: string): boolean | null { + const value: Object | undefined = rum === null ? undefined : rum[field]; + return typeof value === 'boolean' ? value as boolean : null; + } private static objectOf(value: Object | undefined): Record | null { if (value === undefined || typeof value !== 'object' || value === null || Array.isArray(value)) { diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index 76fd21e..69369fa 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -21,6 +21,7 @@ export class RemoteConfigValues { readonly etag: string | null; readonly ttlSeconds: number; readonly refreshOnForeground: boolean; + readonly sessionOnError: boolean | null; constructor( sessionSampleRate: number | null, @@ -28,7 +29,8 @@ export class RemoteConfigValues { custom: string | null = null, etag: string | null = null, ttlSeconds: number = DEFAULT_TTL_SECONDS, - refreshOnForeground: boolean = false + refreshOnForeground: boolean = false, + sessionOnError: boolean | null = null ) { this.sessionSampleRate = sessionSampleRate; this.version = version; @@ -36,25 +38,29 @@ export class RemoteConfigValues { this.etag = etag; this.ttlSeconds = ttlSeconds; this.refreshOnForeground = refreshOnForeground; + this.sessionOnError = sessionOnError; } } /** * The configuration a session was drawn under: the rate actually used at the * draw (the console's where it set one, the init value where it did not, the - * hook's where it overrode both) and the settings version it came from. Events - * carry these instead of the init values, so an audit lines up with the draw - * that kept the session — a session is never re-judged, so the metadata must - * come from its creation, not from whatever has arrived since. + * hook's where it overrode both), whether `sessionOnError` kept the sessions the + * rate missed, and the settings version they came from. Events carry these + * instead of the init values, so an audit lines up with the draw that kept the + * session — a session is never re-judged, so the metadata must come from its + * creation, not from whatever has arrived since. */ export class DrawnConfiguration { /** 0 when no configuration was ever fetched. */ readonly version: number; readonly sessionSampleRate: number; + readonly sessionOnError: boolean; - constructor(version: number, sessionSampleRate: number) { + constructor(version: number, sessionSampleRate: number, sessionOnError: boolean = false) { this.version = version; this.sessionSampleRate = sessionSampleRate; + this.sessionOnError = sessionOnError; } } @@ -64,6 +70,15 @@ const FIELD_CUSTOM: string = 'custom'; const FIELD_ETAG: string = 'etag'; const FIELD_TTL: string = 'ttl'; const FIELD_REFRESH_ON_FOREGROUND: string = 'refresh_on_foreground'; +const FIELD_SESSION_ON_ERROR: string = 'session_on_error'; +/** + * Which fields the entry was written with. An entry written by an SDK that did + * not read every field this one reads carries a validator for a response this + * SDK has never seen: its values are kept, its validator is not, so the next + * request asks for the whole body. Format 2 added `session_on_error`. + */ +const FIELD_FORMAT: string = 'format'; +const RECORD_FORMAT: number = 2; const MAX_RATE: number = 100; /** @@ -120,8 +135,12 @@ export class RemoteConfigStore { if (values.etag !== null) { record[FIELD_ETAG] = values.etag; } + if (values.sessionOnError !== null) { + record[FIELD_SESSION_ON_ERROR] = values.sessionOnError; + } record[FIELD_TTL] = values.ttlSeconds; record[FIELD_REFRESH_ON_FOREGROUND] = values.refreshOnForeground; + record[FIELD_FORMAT] = RECORD_FORMAT; const serialized: string = JSON.stringify(record); if (!RemoteConfigStore.isWithinSizeLimit(serialized)) { return false; @@ -166,7 +185,9 @@ export class RemoteConfigStore { * The SDK version is deliberately left out: including it would discard the * stored values on every SDK upgrade and put the first session after an * upgrade back on the init values. The storage FORMAT version lives in the - * prefix instead, so only a real format change orphans the cache. + * prefix instead, so only a real format change orphans the cache. A field + * added to the entry is not one: the entry's own `format` field retires the + * validator while the values stay in force. */ static buildStoreKey(context: FlashcatContext, host: string, applicationId: string): string { const parts: string[] = [ @@ -189,16 +210,19 @@ export class RemoteConfigStore { } const ttl: number | null = RemoteConfigStore.intOf(record[FIELD_TTL]); const hasRefreshSettings: boolean = ttl !== null && ttl > 0 - && typeof record[FIELD_REFRESH_ON_FOREGROUND] === 'boolean'; + && typeof record[FIELD_REFRESH_ON_FOREGROUND] === 'boolean' + && RemoteConfigStore.intOf(record[FIELD_FORMAT]) === RECORD_FORMAT; // A validator can only be reused with the whole state it validates. If - // refresh settings are missing, keep the rates but ask for a full body. + // refresh settings are missing, or the entry predates a field this SDK + // reads, keep the rates but ask for a full body. return new RemoteConfigValues( RemoteConfigStore.rateOf(record[FIELD_RATE]), RemoteConfigStore.intOf(record[FIELD_VERSION]), RemoteConfigStore.stringOf(record[FIELD_CUSTOM]), hasRefreshSettings ? RemoteConfigStore.stringOf(record[FIELD_ETAG]) : null, ttl !== null && ttl > 0 ? ttl : DEFAULT_TTL_SECONDS, - record[FIELD_REFRESH_ON_FOREGROUND] === true + record[FIELD_REFRESH_ON_FOREGROUND] === true, + typeof record[FIELD_SESSION_ON_ERROR] === 'boolean' ? record[FIELD_SESSION_ON_ERROR] as boolean : null ); } diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index c728d33..3c3c530 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -18,6 +18,7 @@ export class RumApplicationScope implements RumScope { private readonly core: SdkCore; private readonly applicationId: string; private readonly sampleRate: number; + private readonly sessionOnError: boolean; private session: RumSessionScope | null = null; // View active when the previous session expired, restarted (once) in the // replacement session. Held here — not on the session — because a session @@ -42,8 +43,8 @@ export class RumApplicationScope implements RumScope { // Process-lifetime, like the debugging decision it represents. private forced: boolean = false; // A deferred change need not match the running session's draw. Remember the - // last resolved rate so republishing it cannot turn a deferral into a redraw. - private lastResolvedRate: number | null = null; + // last resolved settings so republishing them cannot turn a deferral into a redraw. + private lastResolved: DrawnConfiguration | null = null; private invokingBeforeSampling: boolean = false; constructor( @@ -53,12 +54,14 @@ export class RumApplicationScope implements RumScope { sampleRate: number, remoteConfig: RemoteConfigStore | null = null, beforeSampling: BeforeSamplingCallback | null = null, - onSessionStarted: (() => void) | null = null + onSessionStarted: (() => void) | null = null, + sessionOnError: boolean = false ) { this.featureScope = featureScope; this.core = core; this.applicationId = applicationId; this.sampleRate = sampleRate; + this.sessionOnError = sessionOnError; this.remoteConfig = remoteConfig; this.beforeSampling = beforeSampling; this.onSessionStarted = onSessionStarted; @@ -218,6 +221,32 @@ export class RumApplicationScope implements RumScope { return this.session?.getSessionId(); } + /** Whether the running session is one kept on error that has not been released yet. */ + isWithholding(): boolean { + return this.session !== null && this.session.isWithholding(); + } + + /** The app may be going away: a release the session has earned leaves now. */ + releaseScheduledWithheld(): void { + if (this.session !== null) { + this.session.releaseScheduledWithheld(); + } + } + + /** SDK stop: nothing held may be written through the core afterwards. */ + stopWithheld(): void { + if (this.session !== null) { + this.session.stopWithheld(); + } + } + + /** Tracking consent was refused: nothing collected so far may be released later. */ + forgetWithheld(): void { + if (this.session !== null) { + this.session.forgetWithheld(); + } + } + /** * Draws a new session under the settings that apply RIGHT NOW: what the * console published if anything, the init value otherwise, and finally @@ -228,10 +257,8 @@ export class RumApplicationScope implements RumScope { private startNewSession(nowMs: number): RumSessionScope { const stored: RemoteConfigValues | null = this.remoteConfig !== null ? this.remoteConfig.read() : null; const publishedRate: number | null = stored !== null ? stored.sessionSampleRate : null; - const baseRate: number = publishedRate !== null ? publishedRate : this.sampleRate; - const rate: number = this.askBeforeSampling(baseRate, stored); - this.lastResolvedRate = rate; - const version: number = stored !== null && stored.version !== null ? stored.version : 0; + const resolved: DrawnConfiguration = this.resolve(publishedRate !== null ? publishedRate : this.sampleRate, stored); + this.lastResolved = resolved; // Carried on every session, whether or not the app opted into remote // configuration: the backend weights a session by the rate it reports, and // a session that reports none is counted once — the same as an SDK too old @@ -241,11 +268,9 @@ export class RumApplicationScope implements RumScope { // A forced session reports neither the rate that would have dropped it nor // the version it ignored: it was not drawn by them. Reporting 3 there would // have the console weight one debugging visitor as thirty-three sessions. - const draw: DrawnConfiguration = this.forced - ? new DrawnConfiguration(0, FORCED_SAMPLE_RATE) - : new DrawnConfiguration(version, rate); + const draw: DrawnConfiguration = this.forced ? new DrawnConfiguration(0, FORCED_SAMPLE_RATE) : resolved; const session: RumSessionScope = new RumSessionScope( - this.featureScope, this.core, this.applicationId, rate, nowMs, this.forced, draw); + this.featureScope, this.core, this.applicationId, resolved.sessionSampleRate, nowMs, this.forced, draw); if (this.onSessionStarted !== null) { this.onSessionStarted(); } @@ -253,15 +278,31 @@ export class RumApplicationScope implements RumScope { } /** - * Asks the app's hook for the rate to draw with. Anything unusable — a throw, - * a non-number, a rate outside 0..100 — leaves the incoming rate alone: a - * mistake in the host application must never take a customer's collection - * down with it. + * The settings a draw would use right now: `rate` (the console's, or the init + * value), the console's `sessionOnError` over the init one, and the app's hook + * with the last word on the rate. A rate the hook sets to 0 switches + * `sessionOnError` off too: the hook's contract is that 0 never collects, and + * the switch must not quietly turn that into "collect on error". A rate it + * leaves alone keeps the switch. + */ + private resolve(rate: number, stored: RemoteConfigValues | null): DrawnConfiguration { + const override: number | null = this.askBeforeSampling(rate, stored); + const publishedOnError: boolean | null = stored !== null ? stored.sessionOnError : null; + const onError: boolean = override === 0 ? false : (publishedOnError ?? this.sessionOnError); + const version: number = stored !== null && stored.version !== null ? stored.version : 0; + return new DrawnConfiguration(version, override ?? rate, onError); + } + + /** + * Asks the app's hook for the rate to draw with: its override, or null to keep + * the incoming rate. Anything unusable — a throw, a non-number, a rate outside + * 0..100 — leaves the incoming rate alone: a mistake in the host application + * must never take a customer's collection down with it. */ - private askBeforeSampling(rate: number, stored: RemoteConfigValues | null): number { + private askBeforeSampling(rate: number, stored: RemoteConfigValues | null): number | null { const hook: BeforeSamplingCallback | null = this.beforeSampling; if (hook === null) { - return rate; + return null; } // A hook may accidentally report a RUM event before the new session has // been assigned. Ignore reentrant events and session mutations until it @@ -274,12 +315,12 @@ export class RumApplicationScope implements RumScope { const override: number | undefined = hook(context); if (override === undefined || typeof override !== 'number' || Number.isNaN(override) || override < 0 || override > 100) { - return rate; + return null; } return override as number; } catch (e) { FlashcatLog.e(`rum.app: beforeSampling threw, keeping ${rate}: ${e instanceof Error ? e.message : 'error'}`); - return rate; + return null; } finally { this.invokingBeforeSampling = false; } @@ -306,6 +347,13 @@ export class RumApplicationScope implements RumScope { * case where a move between two non-zero rates is worth a new draw. Even * then a replacement that still cannot collect anything is not worth the * view it costs, so a dark session is left alone when the new rate is zero. + * + * `sessionOnError` takes part in both zero cases. A dark session drawn at + * zero is also owed a new draw when the switch turns on, since it would now + * be kept on error. And a zero rate does not stop a session the switch kept: + * zero plus the switch is the switch's ordinary setting, the one that keeps + * only error sessions — ending those on every first fetch would lose exactly + * the start-up errors the switch exists to catch. */ onConfigurationChanged(activation: string, _before: number | null, after: number | null, nowMs: number): void { if (this.invokingBeforeSampling || this.session === null) { @@ -321,14 +369,22 @@ export class RumApplicationScope implements RumScope { // does at a draw, so an allow-list answers this question too. Asked only // here, and only once: this is an announcement, not a draw. const stored: RemoteConfigValues | null = this.remoteConfig !== null ? this.remoteConfig.read() : null; - const resolvedRate: number = this.askBeforeSampling(nextRate, stored); - const previousResolvedRate: number | null = this.lastResolvedRate; - this.lastResolvedRate = resolvedRate; - if (resolvedRate === previousResolvedRate || resolvedRate === this.session.getDrawnRate()) { + const resolved: DrawnConfiguration = this.resolve(nextRate, stored); + const previous: DrawnConfiguration | null = this.lastResolved; + this.lastResolved = resolved; + if ((previous !== null && previous.sessionSampleRate === resolved.sessionSampleRate + && previous.sessionOnError === resolved.sessionOnError) + || this.session.wasDrawnWith(resolved.sessionSampleRate, resolved.sessionOnError)) { return; // unchanged effective settings must not cause another draw } + // A rate already announced for the next session has not moved in this + // publication, whatever else it carries: a switch that changed beside it + // must not turn that deferral into a redraw. + const rateMoved: boolean = (previous === null || previous.sessionSampleRate !== resolved.sessionSampleRate) + && resolved.sessionSampleRate !== this.session.getDrawnRate(); if (RumApplicationScope.shouldEndSession( - this.session.isSampled(), this.session.getDrawnRate(), resolvedRate, activation)) { + this.session.isSampled(), this.session.getDrawnRate(), resolved.sessionSampleRate, activation, + this.session.isSampledOnError(), resolved.sessionOnError, rateMoved)) { this.stopCurrentSession(nowMs); } } @@ -339,15 +395,33 @@ export class RumApplicationScope implements RumScope { * `drawnRate` is what that session was actually drawn with, which is the only * way to tell a session that could never have been collected from one that * lost a fair draw: only the first is owed another chance when collection is - * switched back on. + * switched back on. `rateMoved` says whether this publication is the one + * that changed the rate: a rate already announced for the next session and + * republished beside a changed switch has not. */ - static shouldEndSession(sampled: boolean, drawnRate: number, nextRate: number, activation: string): boolean { + static shouldEndSession(sampled: boolean, drawnRate: number, nextRate: number, activation: string, + sampledOnError: boolean = false, nextOnError: boolean = false, + rateMoved: boolean = nextRate !== drawnRate): boolean { if (sampled) { - return nextRate === 0 || activation === ACTIVATION_IMMEDIATE; + if (nextRate === 0) { + // A session the switch kept is left running while the switch stays on; + // one drawn by the rate is still stopped — the switch shapes what the + // next draw keeps, it does not exempt a session already collected in full. + return !(sampledOnError && nextOnError); + } + // `immediate` re-decides on a rate that moved; a change to the switch + // alone says nothing about a session the rate already kept. A session the + // switch kept is left alone too: it holds the minute before an error that + // has not happened yet, and a re-draw at the new rate would throw that + // away on a path that is not an emergency stop. + return activation === ACTIVATION_IMMEDIATE && rateMoved && !sampledOnError; } // A dark session is worth replacing only by one that can collect something; // otherwise the replacement is just as dark and the visitor lost a view. - return nextRate > 0 && (drawnRate === 0 || activation === ACTIVATION_IMMEDIATE); + // `immediate` re-decides it on a rate that moved, as above: a session that + // lost a fair draw is not owed another one because the switch changed. + return (drawnRate === 0 && (nextRate > 0 || nextOnError)) + || (nextRate > 0 && rateMoved && activation === ACTIVATION_IMMEDIATE); } /** @@ -362,7 +436,10 @@ export class RumApplicationScope implements RumScope { } this.forced = true; if (this.session !== null && this.session.isSampled()) { - return; // already collecting — nothing to end + // Already collecting — nothing to end. A session kept only on error stops + // waiting for one: the app asked for this visitor now. + this.session.releaseWithheld(); + return; } this.stopCurrentSession(nowMs); } diff --git a/flashcat-rum/src/main/ets/internal/scope/RumScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumScope.ets index 27ffa91..7454800 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumScope.ets @@ -31,6 +31,9 @@ export interface RumRawEvent { // view's crash count (it was already counted in the crashed run's view doc, // and it did not happen in this session). countInView?: boolean; + // An error of a process that already died, replayed into whichever session is + // current because nothing identifies the one it happened in. + inherited?: boolean; // resource specifics (kind === 'startResource' | 'stopResource') url?: string; diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index d0a307c..193607c 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -5,6 +5,7 @@ import { RumViewScope } from './RumViewScope'; import { RumEventAssembler } from '../assembly/RumEventAssembler'; import { writeMapped } from '../RumEventMapperHolder'; import { DrawnConfiguration } from '../remoteconfig/RemoteConfigStore'; +import { WithholdingFeatureScope } from './WithheldEvents'; const SESSION_INACTIVITY_MS: number = 15 * 60 * 1000; // 15 min const SESSION_MAX_DURATION_MS: number = 4 * 60 * 60 * 1000; // 4 h @@ -21,7 +22,12 @@ export class RumSessionScope implements RumScope { private readonly core: SdkCore; private readonly applicationId: string; private readonly sessionId: string; + // Collected: drawn by the rate, forced, or kept on error (see sampledOnError). private readonly sampled: boolean; + // Missed by the rate and kept only if it errors (`sessionOnError`): its scopes + // run as usual, but write through `withheld` until it reports an error. + private readonly sampledOnError: boolean; + private readonly withheld: WithholdingFeatureScope | null; // The settings this session was drawn under, carried onto its view documents. private readonly draw: DrawnConfiguration; private readonly startedAtMs: number; @@ -55,7 +61,6 @@ export class RumSessionScope implements RumScope { forced: boolean = false, draw: DrawnConfiguration = new DrawnConfiguration(0, sampleRate) ) { - this.featureScope = featureScope; this.core = core; this.applicationId = applicationId; this.sessionId = util.generateRandomUUID(true); @@ -63,10 +68,18 @@ export class RumSessionScope implements RumScope { this.lastActivityMs = startedAtMs; // A forced session skips the draw entirely: the app has said this visitor // must be collected, and a coin flip could still say no. - this.sampled = forced || RumSessionScope.decideSampling(sampleRate); + const drawn: boolean = forced || RumSessionScope.decideSampling(sampleRate); + // Only the sessions the rate missed, so a session is never counted by both. + this.sampledOnError = !drawn && draw.sessionOnError; + this.sampled = drawn || this.sampledOnError; + this.withheld = this.sampledOnError ? new WithholdingFeatureScope(featureScope, core, this.sessionId) : null; + this.featureScope = this.withheld ?? featureScope; this.draw = draw; const update: Record = {}; update['session.id'] = this.sessionId; + // An on-error session counts as sampled here: a crash in it is still + // reported into it (with the last view it wrote), even if nothing it held + // made it out before the process died. update['session.sampled'] = this.sampled; this.core.updateFeatureContext('rum', update); } @@ -95,6 +108,13 @@ export class RumSessionScope implements RumScope { // view so it still reaches session_crash_count. this.lastStoppedView.handleEvent(event); } + if (this.withheld !== null) { + if (event.kind === 'markCrash') { + this.withheld.releaseForCrash(); + } else { + this.withheld.settle(); + } + } // Un-publish the session identity so a crash in the gap before the next // session isn't attributed to this dead one (the context is merge-only). const cleared: Record = {}; @@ -138,13 +158,22 @@ export class RumSessionScope implements RumScope { const viewId: string = util.generateRandomUUID(true); this.activeView = new RumViewScope( this.featureScope, this.core, this.applicationId, this.sessionId, - event.key ?? '', event.name ?? '', viewId, event.timestampMs, event.attributes, this.draw); + event.key ?? '', event.name ?? '', viewId, event.timestampMs, event.attributes, this.draw, + this.sampledOnError); return this; } if (!this.sampled) { return this; // session dropped — track lifecycle but emit nothing } + if (event.kind === 'addError' && event.inherited === true && this.withheld !== null && this.withheld.isWithholding()) { + // A crash of a process that already died is not this session's error: it + // must not turn a session the backend has never seen into an error + // session. Nothing identifies the session it happened in, so it goes + // nowhere — as it does when the current session was not drawn at all. + FlashcatLog.d('rum.session: inherited crash not reported — the current session is withheld'); + return this; + } // Late resource completions may belong to an already-closed view held in // the draining set — route by ownership, not blindly to the active view. @@ -181,6 +210,11 @@ export class RumSessionScope implements RumScope { // these were dropped with no trace at any log level. FlashcatLog.d(`rum.session: dropped viewless ${event.kind} — no active view (missing startView?)`); } + if (event.kind === 'markCrash' && this.withheld !== null) { + // The process is about to exit: what was held — the document counting this + // crash included — has to reach storage now, not after a jitter. + this.withheld.releaseForCrash(); + } return this; } @@ -226,11 +260,42 @@ export class RumSessionScope implements RumScope { this.retireView(this.activeView); // keep in-flight resources drainable this.activeView = null; } + if (this.withheld !== null) { + this.withheld.settle(); + } const cleared: Record = {}; cleared['session.id'] = ''; this.core.updateFeatureContext('rum', cleared); } + /** RumMonitor.setForcedSession on a session kept only on error: release it now. */ + releaseWithheld(): void { + if (this.withheld !== null) { + this.withheld.releaseForced(); + } + } + + /** SDK stop: drop what is held and any release still waiting on its jitter. */ + stopWithheld(): void { + if (this.withheld !== null) { + this.withheld.stop(); + } + } + + /** The app may be going away: a release already earned must not wait out its jitter. */ + releaseScheduledWithheld(): void { + if (this.withheld !== null) { + this.withheld.releaseIfScheduled(); + } + } + + /** Tracking consent was refused: what is held is forgotten, as the core forgets its batches. */ + forgetWithheld(): void { + if (this.withheld !== null) { + this.withheld.forget(); + } + } + /** True when a view of this (possibly ended) session still owns `key`. */ ownsResource(key: string): boolean { if (this.activeView !== null && this.activeView.hasPendingResource(key)) { @@ -288,6 +353,20 @@ export class RumSessionScope implements RumScope { return this.sampled; } + isSampledOnError(): boolean { + return this.sampledOnError; + } + + /** Kept on error and not released yet: the backend has no session under this id so far. */ + isWithholding(): boolean { + return this.withheld !== null && this.withheld.isWithholding(); + } + + /** Whether the settings this session was drawn under are exactly these. */ + wasDrawnWith(sessionSampleRate: number, sessionOnError: boolean): boolean { + return this.draw.sessionSampleRate === sessionSampleRate && this.draw.sessionOnError === sessionOnError; + } + private writeViewlessError(raw: RumRawEvent): void { this.featureScope.withWriteContext((context: FlashcatContext, writer: EventWriter) => { const event: Record = RumEventAssembler.error( diff --git a/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets index 3c4fe9e..0fabbc7 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets @@ -29,6 +29,8 @@ export class RumViewScope implements RumScope { // event type the backend builds session rows from), so a session can be // traced back to the settings that decided whether to keep it. private readonly draw: DrawnConfiguration | null; + // Whether the session is kept only because it errors (`sessionOnError`). + private readonly sampledForError: boolean; private actionCount: number = 0; private errorCount: number = 0; private resourceCount: number = 0; @@ -53,7 +55,8 @@ export class RumViewScope implements RumScope { viewId: string, startedAtMs: number, attributes: Record, - draw: DrawnConfiguration | null = null + draw: DrawnConfiguration | null = null, + sampledForError: boolean = false ) { this.featureScope = featureScope; this.core = core; @@ -65,6 +68,7 @@ export class RumViewScope implements RumScope { this.startedAtMs = startedAtMs; this.attributes = attributes; this.draw = draw; + this.sampledForError = sampledForError; // Publish the active view so Logs/Trace/Crash can correlate. The URL is the // RESOLVED one (caller-supplied view.url, e.g. the nav tracker's route path), // not the opaque key — a crash incident snapshots this and must match the @@ -273,7 +277,7 @@ export class RumViewScope implements RumScope { const event: Record = RumEventAssembler.view( context, this.applicationId, this.sessionId, this.viewId, this.viewName, this.startedAtMs, timeSpentNs, actions, errors, resources, crashes, version, isActive, - this.viewAttributes(), this.draw); + this.viewAttributes(), this.draw, this.sampledForError); writeMapped(writer, event, false); }); } diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets new file mode 100644 index 0000000..c3385a4 --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -0,0 +1,537 @@ +import { FeatureScope, FlashcatContext, EventWriter, SdkCore, TrackingConsent, FlashcatLog } from '@flashcatcloud/core'; +import { util } from '@kit.ArkTS'; +import { LastViewSnapshot } from '../LastViewSnapshot'; + +/** + * How much history a withheld session keeps: an error session shows the minute + * leading up to its error, and a session that never errors holds no more than that. + */ +export const WITHHELD_BUFFER_DURATION_MS: number = 60 * 1000; +/** Memory bound for detail events (views excluded). Over it the least valuable go first. */ +export const WITHHELD_BUFFER_BYTES_LIMIT: number = 64 * 1024; +export const WITHHELD_BUFFER_EVENTS_LIMIT: number = 200; +/** + * Views are the containers their events hang from — the backend builds the session + * row out of view events — so they stay out of the detail budget and are only + * bounded against pathological navigation counts. + */ +export const WITHHELD_BUFFER_VIEWS_LIMIT: number = 50; +/** + * Correlated failures make every client release at the same instant, right when + * the backend is already under strain. Releases are spread over this window. + */ +export const WITHHELD_BUFFER_RELEASE_MAX_DELAY_MS: number = 3000; + +const ENCODER: util.TextEncoder = new util.TextEncoder(); + +/** What is dropped first when the buffer is over budget. Lower goes first. */ +enum EvictionTier { + /** Requests that succeeded without complaint. */ + FIRST = 0, + /** Actions, failed requests and everything else: they explain what the user did. */ + LAST = 1, + /** Errors are why the session is kept at all; even then the newest goes first. */ + LAST_RESORT = 2 +} + +/** + * What is held is the event's serialized form, taken as it was written: an + * event object shares the attribute values the app passed in, and the app may + * go on changing those. A collected session uploads what the values were at the + * write; an on-error session must release the same, not what they became. + */ +class HeldEvent { + readonly line: string; + /** '' for an error raised before any view started: it has no container to lose. */ + readonly viewId: string; + readonly timeMs: number; + readonly bytes: number; + readonly tier: EvictionTier; + + constructor(line: string, viewId: string, timeMs: number, bytes: number, tier: EvictionTier) { + this.line = line; + this.viewId = viewId; + this.timeMs = timeMs; + this.bytes = bytes; + this.tier = tier; + } +} + +class HeldView { + readonly line: string; + /** The view's start date, which orders the release. */ + readonly date: number; + /** Its document version: a document is superseded only by a newer one. */ + readonly version: number; + + constructor(line: string, date: number, version: number) { + this.line = line; + this.date = date; + this.version = version; + } +} + +/** + * The events of one on-error session, held in memory until the session reports an + * error or ends. Holds the latest document of each view plus a rolling minute of + * detail events, and hands them over in the order the backend needs to rebuild + * the session from them. + */ +export class WithheldEventBuffer { + /** Latest document per view, in the order they were last updated. */ + private views: Map = new Map(); + private details: HeldEvent[] = []; + private bytes: number = 0; + private droppedCount: number = 0; + private currentViewId: string = ''; + private currentViewDate: number = -Infinity; + /** When the release was scheduled; 0 while none is. Freezes the window, see prune. */ + private frozenAtMs: number = 0; + + /** + * Holds `line`, the serialized form of `event` as it was written; `bytes` is + * its size, which views are not charged for. Returns false when the event was + * dropped rather than held. + */ + hold(event: Record, line: string, bytes: number, nowMs: number): boolean { + const viewId: string = WithheldEventBuffer.viewIdOf(event); + if (event['type'] === 'view') { + // Upsert: a view document is cumulative, so the newest supersedes the rest. + // Newest by document version, not by arrival: a document is assembled + // before its mapper runs, and a mapper that reports into the SDK has the + // next version written first. Deleting first moves the entry to the back, + // so the oldest entry is the least recently updated one. + const date: number = typeof event['date'] === 'number' ? event['date'] as number : 0; + const version: number = WithheldEventBuffer.documentVersionOf(event); + const held: HeldView | undefined = this.views.get(viewId); + if (held === undefined || version >= held.version) { + this.views.delete(viewId); + this.views.set(viewId, new HeldView(line, date, version)); + } + // The view's own start date decides which one is current, not arrival order: + // a late update of a view that already ended must not make it current again, + // or prune would drop the view the next error hangs from. + if (date >= this.currentViewDate) { + this.currentViewDate = date; + this.currentViewId = viewId; + } + while (this.views.size > WITHHELD_BUFFER_VIEWS_LIMIT) { + const oldest: string = this.views.keys().next().value as string; + const evicted: HeldView = this.views.get(oldest) as HeldView; + this.views.delete(oldest); + if (oldest === this.currentViewId) { + // The view in progress is never evicted: move it to the back instead. + this.views.set(oldest, evicted); + } + } + this.prune(nowMs); + return true; + } + if (bytes > WITHHELD_BUFFER_BYTES_LIMIT) { + // Could never be part of a release, and making room for it would evict the + // whole minute before it. Keep the history instead. + this.droppedCount += 1; + return false; + } + this.details.push(new HeldEvent(line, viewId, nowMs, bytes, WithheldEventBuffer.tierOf(event))); + this.bytes += bytes; + this.prune(nowMs); + while (this.details.length > WITHHELD_BUFFER_EVENTS_LIMIT || this.bytes > WITHHELD_BUFFER_BYTES_LIMIT) { + if (!this.evictOne()) { + break; + } + } + return true; + } + + /** + * A release was scheduled: stop the window from moving. The timer carrying the + * release can be held back while the app is in the background, and pruning + * against a later time would throw away exactly the minute before the error. + */ + freeze(nowMs: number): void { + if (this.frozenAtMs === 0) { + this.frozenAtMs = nowMs; + } + } + + /** + * Writes everything still held: views oldest first (the backend creates the + * session row from the first view it receives), then errors, then the rest + * oldest first. A release often happens right before the app goes away, and + * only the first writes are sure to leave — the error must not ride in the last. + * + * Every detail goes out whether or not its view is held: views only decide + * the order. An error before the first view, with view tracking off, or whose + * view document the mapper dropped is still the error the session is kept for. + */ + release(writer: EventWriter, nowMs: number, sessionId: string): void { + this.prune(nowMs); + const views: HeldView[] = []; + this.views.forEach((view: HeldView) => { + views.push(view); + }); + views.sort((left: HeldView, right: HeldView) => left.date - right.date); + const ordered: string[] = views.map((view: HeldView) => view.line); + for (const held of this.details) { + if (held.tier === EvictionTier.LAST_RESORT) { + ordered.push(held.line); + } + } + for (const held of this.details) { + if (held.tier !== EvictionTier.LAST_RESORT) { + ordered.push(held.line); + } + } + let persisted: number = 0; + for (let i: number = 0; i < ordered.length; i++) { + // One batch, uploaded as soon as the last event is in. A write storage + // refuses is gone, as it is for a collected session: nothing retries it. + if (writer.write(JSON.parse(ordered[i]) as Record, i === ordered.length - 1)) { + persisted += 1; + } + } + FlashcatLog.d(`rum.withheld: released session=${sessionId} ${this.describe()} persisted=${persisted} failed=${ordered.length - persisted}`); + this.clear(); + } + + /** What is held right now, for the log lines that account for it. */ + describe(): string { + return `views=${this.views.size} details=${this.details.length} dropped=${this.droppedCount} detail_bytes=${this.bytes}`; + } + + clear(): void { + this.views = new Map(); + this.details = []; + this.bytes = 0; + this.droppedCount = 0; + this.currentViewId = ''; + this.currentViewDate = -Infinity; + this.frozenAtMs = 0; + } + + /** Drops what aged out of the window, then every non-current view left without detail. */ + private prune(nowMs: number): void { + const now: number = this.frozenAtMs > 0 ? this.frozenAtMs : nowMs; + const oldestAllowedMs: number = now - WITHHELD_BUFFER_DURATION_MS; + let cutoff: number = 0; + while (cutoff < this.details.length && this.details[cutoff].timeMs < oldestAllowedMs) { + this.bytes -= this.details[cutoff].bytes; + this.droppedCount += 1; + cutoff += 1; + } + if (cutoff > 0) { + this.details = this.details.slice(cutoff); + } + const withDetail: Set = new Set(); + for (const held of this.details) { + withDetail.add(held.viewId); + } + const stale: string[] = []; + this.views.forEach((_view: HeldView, id: string) => { + if (id !== this.currentViewId && !withDetail.has(id)) { + stale.push(id); + } + }); + for (const id of stale) { + this.views.delete(id); + } + } + + private evictOne(): boolean { + const tiers: EvictionTier[] = [EvictionTier.FIRST, EvictionTier.LAST]; + for (const tier of tiers) { + const index: number = this.details.findIndex((held: HeldEvent) => held.tier === tier); + if (index !== -1) { + this.evictAt(index); + return true; + } + } + // Only errors left: drop the newest, so an error storm never pushes out the + // first error — the one that released the buffer. + for (let index: number = this.details.length - 1; index >= 0; index--) { + if (this.details[index].tier === EvictionTier.LAST_RESORT) { + this.evictAt(index); + return true; + } + } + return false; + } + + private evictAt(index: number): void { + this.bytes -= this.details[index].bytes; + this.droppedCount += 1; + this.details.splice(index, 1); + } + + private static tierOf(event: Record): EvictionTier { + const type: Object | undefined = event['type']; + if (type === 'error') { + return EvictionTier.LAST_RESORT; + } + if (type === 'resource') { + // A request that failed is part of how the error happened; one that succeeded rarely is. + const resource: Object | undefined = event['resource']; + const status: Object | undefined = typeof resource === 'object' && resource !== null ? + (resource as Record)['status_code'] : undefined; + const code: number = typeof status === 'number' ? status as number : -1; + return code === 0 || code >= 400 ? EvictionTier.LAST : EvictionTier.FIRST; + } + return EvictionTier.LAST; + } + + private static documentVersionOf(event: Record): number { + const dd: Object | undefined = event['_dd']; + const version: Object | undefined = typeof dd === 'object' && dd !== null ? + (dd as Record)['document_version'] : undefined; + return typeof version === 'number' ? version as number : 0; + } + + private static viewIdOf(event: Record): string { + const view: Object | undefined = event['view']; + if (typeof view !== 'object' || view === null) { + return ''; + } + const id: Object | undefined = (view as Record)['id']; + return typeof id === 'string' ? id as string : ''; + } +} + +/** + * Deterministic per session, so a client always spreads to the same offset. + * Multiplicative rather than a running sum: session ids are same-length strings + * over one small alphabet, and summing their character codes lands nearly every + * session within a few hundred milliseconds of the same value. + */ +export function computeReleaseDelay(sessionId: string): number { + let hash: number = 0; + for (let i: number = 0; i < sessionId.length; i++) { + hash = Math.imul(hash, 31) + sessionId.charCodeAt(i); + } + return Math.abs(hash) % WITHHELD_BUFFER_RELEASE_MAX_DELAY_MS; +} + +/** + * The write path of a session kept only if it errors (`sessionOnError`). Its + * scopes run exactly like a collected session's — the mapper included — but what + * they write is held here instead of reaching storage, until: + * - the session writes an error of its own (after the mapper, so an error the app + * dropped releases nothing): released after a per-session jitter; + * - the process is about to die of a crash, or the app forces the session: + * released at once; + * - the session ends: released if it errored, thrown away otherwise. A thrown-away + * session keeps dropping whatever its still-draining views write afterwards, so + * a request that completes after the session ended cannot resurrect it. + * A refused tracking consent forgets everything held, the error included, and + * the session starts withholding afresh. After a release the session writes + * straight through, like any collected one. + */ +export class WithholdingFeatureScope implements FeatureScope { + private readonly delegate: FeatureScope; + private readonly core: SdkCore; + private readonly sessionId: string; + /** Null once released or discarded. */ + private buffer: WithheldEventBuffer | null = new WithheldEventBuffer(); + private discarded: boolean = false; + private hasError: boolean = false; + private releaseTimerId: number = -1; + /** Writes in progress: the mapper runs inside one, and may end the session from there. */ + private writing: number = 0; + private settleAfterWrite: boolean = false; + + constructor(delegate: FeatureScope, core: SdkCore, sessionId: string) { + this.delegate = delegate; + this.core = core; + this.sessionId = sessionId; + } + + withWriteContext(callback: (context: FlashcatContext, writer: EventWriter) => void): void { + this.delegate.withWriteContext((context: FlashcatContext, writer: EventWriter) => { + this.writing += 1; + try { + callback(context, new WithholdingWriter(this, writer)); + } finally { + this.writing -= 1; + if (this.writing === 0 && this.settleAfterWrite) { + this.settleAfterWrite = false; + this.settle(); + } + } + }); + } + + sendEvent(event: Record): void { + this.delegate.sendEvent(event); + } + + /** Still holding: nothing of this session has reached storage, so the backend has no session under its id. */ + isWithholding(): boolean { + return this.buffer !== null; + } + + /** The process is about to die of a crash: whatever is held must reach storage now. */ + releaseForCrash(): void { + this.hasError = true; + this.releaseNow(); + } + + /** RumMonitor.setForcedSession: the app asked for this visitor now, not on an error. */ + releaseForced(): void { + this.releaseNow(); + } + + /** The app may be about to go away: a release already scheduled leaves now. + * One not yet earned is kept — the app usually comes back. */ + releaseIfScheduled(): void { + if (this.releaseTimerId !== -1) { + this.releaseNow(); + } + } + + /** SDK stop: nothing held may reach the core any more, earned or not. */ + stop(): void { + const buffer: WithheldEventBuffer | null = this.buffer; + this.cancelTimer(); + this.buffer = null; + this.discarded = true; + if (buffer !== null) { + // What was held is gone for good: nothing of this session may be rebuilt + // on the next launch either. + LastViewSnapshot.invalidateFor(this.sessionId); + } + } + + /** + * Tracking consent was refused: everything collected so far is forgotten, the + * error included, exactly as the core wipes the batches it had collected. The + * session goes on withholding, so a later consent and a later error release + * only what was collected after both. + */ + forget(): void { + const buffer: WithheldEventBuffer | null = this.buffer; + if (buffer === null) { + return; + } + this.cancelTimer(); + buffer.clear(); + this.hasError = false; + } + + /** The session ended: what it earned goes out, the rest is thrown away for good. */ + settle(): void { + const buffer: WithheldEventBuffer | null = this.buffer; + if (buffer === null) { + return; + } + if (this.writing > 0) { + // The session is being ended from inside a write — the app's mapper may + // be looking at this session's first error. Whether that error is kept + // is known only once the write lands, so the decision waits for it. + this.settleAfterWrite = true; + return; + } + if (this.hasError) { + this.releaseNow(); + return; + } + this.cancelTimer(); + this.buffer = null; + this.discarded = true; + // The backend never saw this session; its last view must not let a crash + // replayed on the next launch build it after all. + LastViewSnapshot.invalidateFor(this.sessionId); + FlashcatLog.d(`rum.withheld: discarded session=${this.sessionId} ${buffer.describe()} (ended without an error)`); + } + + /** @internal the write path of {@link WithholdingWriter}. */ + route(writer: EventWriter, event: Record, forceFlush: boolean): boolean { + if (this.discarded) { + return false; + } + const buffer: WithheldEventBuffer | null = this.buffer; + if (buffer === null) { + return writer.write(event, forceFlush); + } + if (this.core.getTrackingConsent() === TrackingConsent.NOT_GRANTED) { + // Every write is dropped under a refused consent. The core announces the + // refusal too; this covers a core that predates the announcement. + this.forget(); + return false; + } + const line: string = JSON.stringify(event); + // Views stay out of the byte budget, so only details pay for the measurement. + const bytes: number = event['type'] === 'view' ? 0 : ENCODER.encodeInto(line).byteLength; + if (!this.isOwnError(event)) { + return buffer.hold(event, line, bytes, Date.now()); + } + if (bytes > WITHHELD_BUFFER_BYTES_LIMIT) { + // An error too large for the buffer goes out on its own rather than + // evicting the history it explains. Only an error storage accepted earns + // the release: a history uploaded for an error the backend will never + // see is a session nobody can explain. + if (!writer.write(event, true)) { + return false; + } + this.hasError = true; + this.scheduleRelease(buffer); + return true; + } + this.hasError = true; + this.scheduleRelease(buffer); + return buffer.hold(event, line, bytes, Date.now()); + } + + private isOwnError(event: Record): boolean { + if (event['type'] !== 'error') { + return false; + } + const session: Object | undefined = event['session']; + return typeof session === 'object' && session !== null + && (session as Record)['id'] === this.sessionId; + } + + private scheduleRelease(buffer: WithheldEventBuffer): void { + if (this.releaseTimerId !== -1) { + return; + } + buffer.freeze(Date.now()); + this.releaseTimerId = setTimeout(() => { + this.releaseTimerId = -1; + this.releaseNow(); + }, computeReleaseDelay(this.sessionId)); + } + + private releaseNow(): void { + const buffer: WithheldEventBuffer | null = this.buffer; + if (buffer === null) { + return; + } + this.cancelTimer(); + this.buffer = null; + this.delegate.withWriteContext((_context: FlashcatContext, writer: EventWriter) => { + buffer.release(writer, Date.now(), this.sessionId); + }); + } + + private cancelTimer(): void { + if (this.releaseTimerId !== -1) { + clearTimeout(this.releaseTimerId); + this.releaseTimerId = -1; + } + } +} + +/** Hands each write of a withholding session's scopes to the session's decision. */ +class WithholdingWriter implements EventWriter { + private readonly owner: WithholdingFeatureScope; + private readonly writer: EventWriter; + + constructor(owner: WithholdingFeatureScope, writer: EventWriter) { + this.owner = owner; + this.writer = writer; + } + + write(event: Record, forceFlush?: boolean): boolean { + return this.owner.route(this.writer, event, forceFlush === true); + } +} diff --git a/flashcat-rum/src/test/List.test.ets b/flashcat-rum/src/test/List.test.ets index 9292db0..dd0075f 100644 --- a/flashcat-rum/src/test/List.test.ets +++ b/flashcat-rum/src/test/List.test.ets @@ -7,6 +7,7 @@ import schemaAlignmentTests from './SchemaAlignment.test'; import resourceKindTests from './ResourceKind.test'; import crashAttributionTests from './CrashAttribution.test'; import remoteConfigTests from './RemoteConfig.test'; +import sessionOnErrorTests from './SessionOnError.test'; export default function testsuite(): void { crashReportTests(); @@ -15,6 +16,7 @@ export default function testsuite(): void { resourceKindTests(); crashAttributionTests(); remoteConfigTests(); + sessionOnErrorTests(); describe('flashcat-rum', (): void => { it('defaultMonitorIsNoOp', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const monitor = GlobalRumMonitor.get(); diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets new file mode 100644 index 0000000..ad348ee --- /dev/null +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -0,0 +1,1794 @@ +import { describe, expect, it, Level, Size, TestType } from '@ohos/hypium'; +import { + FlashcatContext, FeatureScope, EventWriter, SdkCore, TrackingConsent, + Feature, FeatureEventReceiver, UserInfo, IntakeTarget +} from '@flashcatcloud/core'; +import { RumApplicationScope } from '../main/ets/internal/scope/RumApplicationScope'; +import { RumRawEvent } from '../main/ets/internal/scope/RumScope'; +import { + WithheldEventBuffer, computeReleaseDelay, WITHHELD_BUFFER_BYTES_LIMIT, WITHHELD_BUFFER_EVENTS_LIMIT, + WITHHELD_BUFFER_VIEWS_LIMIT, WITHHELD_BUFFER_DURATION_MS, WITHHELD_BUFFER_RELEASE_MAX_DELAY_MS +} from '../main/ets/internal/scope/WithheldEvents'; +import { RemoteConfigStore, RemoteConfigValues } from '../main/ets/internal/remoteconfig/RemoteConfigStore'; +import { RemoteConfigController, RemoteConfigChangeListener } from '../main/ets/internal/remoteconfig/RemoteConfigController'; +import { RemoteConfigFetcher, RemoteConfigResponse } from '../main/ets/internal/remoteconfig/RemoteConfigFetcher'; +import { RumEventMapperHolder } from '../main/ets/internal/RumEventMapperHolder'; +import { LastViewSnapshot } from '../main/ets/internal/LastViewSnapshot'; +import { RumFeature } from '../main/ets/internal/RumFeature'; +import { BeforeSamplingContext } from '../main/ets/RumTypes'; +import { RumConfiguration, RumConfigurationBuilder } from '../main/ets/RumConfiguration'; +import { DefaultRumMonitor } from '../main/ets/internal/monitor/DefaultRumMonitor'; +import { RumActionType, RumErrorSource } from '../main/ets/RumTypes'; +import { errorManager } from '@kit.AbilityKit'; + +const MIN_15: number = 15 * 60 * 1000; + +function sleep(ms: number): Promise { + return new Promise((resolve: () => void) => setTimeout(resolve, ms)); +} + +function testContext(): FlashcatContext { + return { + env: 'prod', + variant: '', + service: 'svc', + version: '1.0.0', + bundleId: 'com.example.demo', + source: 'harmony', + sdkVersion: '0.6.0', + device: { + brand: 'HUAWEI', model: 'Pura', osName: 'HarmonyOS', osVersion: 'NEXT', + apiVersion: 18, deviceType: 'phone' + }, + user: {}, + anonymousId: 'anon-device-1', + network: { status: 'connected', interfaces: ['wifi'] }, + featureContext: {} + }; +} + +class FakeCore implements SdkCore { + readonly name: string = 'test'; + readonly featureContext: Record = {}; + readonly settings: Map = new Map(); + consent: TrackingConsent = TrackingConsent.GRANTED; + + /** What getFeature hands out: the RUM scope (storage commands included) and the crash feature. */ + rumScope: CapturingScope | null = null; + crashScope: FakeCrashScope | null = null; + + registerFeature(_feature: Feature): void {} + getFeature(featureName: string): FeatureScope | null { + if (featureName === 'rum') { + return this.rumScope; + } + if (featureName === 'crash') { + return this.crashScope; + } + return null; + } + setEventReceiver(_featureName: string, _receiver: FeatureEventReceiver): void {} + removeEventReceiver(_featureName: string): void {} + updateFeatureContext(_featureName: string, update: Record): void { + for (const k of Object.keys(update)) { + this.featureContext[k] = update[k]; + } + } + getContext(): FlashcatContext { + return testContext(); + } + getTrackingConsent(): TrackingConsent { + return this.consent; + } + isActive(): boolean { + return true; + } + setUserInfo(_user: UserInfo): void {} + clearUserInfo(): void {} + getIntakeTarget(): IntakeTarget { + return { host: 'https://intake.example.com', clientToken: 'token' }; + } + readSetting(key: string): string | null { + const value: string | undefined = this.settings.get(key); + return value === undefined ? null : value; + } + writeSetting(key: string, value: string | null): void { + if (value === null) { + this.settings.delete(key); + } else { + this.settings.set(key, value); + } + } +} + +/** Records what reaches storage, and which writes asked for an upload. */ +class CapturingScope implements FeatureScope { + readonly written: Array> = []; + readonly flushes: boolean[] = []; + /** Storage's per-event size limit: a larger event is refused, as the core's persistence refuses it. */ + rejectAbove: number = Number.MAX_SAFE_INTEGER; + + withWriteContext(callback: (context: FlashcatContext, writer: EventWriter) => void): void { + callback(testContext(), new CapturingWriter(this)); + } + + /** The last-view snapshot file the crash storage would hold. */ + lastView: string = ''; + lastViewDeletes: number = 0; + + sendEvent(event: Record): void { + const type: Object | undefined = event['type']; + if (type === 'crash_storage_write_last_view') { + this.lastView = event['serialized'] as string; + } else if (type === 'crash_storage_read_last_view') { + event['serialized'] = this.lastView; + } else if (type === 'crash_storage_delete_last_view') { + this.lastView = ''; + this.lastViewDeletes += 1; + } + } + + ofType(type: string): Array> { + return this.written.filter((e: Record) => e['type'] === type); + } +} + +class CapturingWriter implements EventWriter { + private readonly scope: CapturingScope; + + constructor(scope: CapturingScope) { + this.scope = scope; + } + + write(event: Record, forceFlush?: boolean): boolean { + if (JSON.stringify(event).length > this.scope.rejectAbove) { + return false; + } + this.scope.written.push(event); + this.scope.flushes.push(forceFlush === true); + return true; + } +} + +/** + * Stands in for the crash feature on the bus: answers the policy pull and the + * live crash callback the way CrashFeature does, minus the process exit. For a + * terminating policy it persists nothing but asks RUM, as the real feature does, + * whether the event the next launch would replay survives the app's mapper. + */ +class FakeCrashScope implements FeatureScope { + readonly controls: Array> = []; + readonly probes: Array> = []; + policy: string = 'report_then_exit'; + /** The restart the loop guard would allow: the replay carries `crash.recovered`. */ + recover: boolean = false; + rum: RumFeature | null = null; + private readonly core: FakeCore; + + constructor(core: FakeCore) { + this.core = core; + } + + withWriteContext(_callback: (context: FlashcatContext, writer: EventWriter) => void): void {} + + sendEvent(event: Record): void { + const type: Object | undefined = event['type']; + if (type === 'js_crash_policy_request') { + event['policy'] = this.policy; + return; + } + if (type !== 'js_crash_callback') { + return; + } + this.controls.push(event); + if (event['callback'] !== 'unhandled' || this.policy === 'observe_only') { + // No incident: the live report is the only report. + event['report_async'] = true; + event['should_exit'] = false; + return; + } + // What CrashEventMapper.fromPendingIncident rebuilds from the persisted + // record: name/message/stack parts, the crash time, the ids the record + // took from the RUM context, and the recovery outcome. + const text: string = event['text'] as string; + const lines: string[] = text.split('\n'); + const name: string = lines[0].substring('Error name:'.length); + const message: string = lines[1].substring('Error message:'.length); + const stack: string = lines.slice(3).join('\n'); + const probe: Record = { + 'type': 'crash_mapper_probe', + 'target': 'rum', + 'crash_type': 'JsError', + 'crash_kind': 'crash', + 'message': `${name}: ${message}`, + 'stack': stack, + 'timestamp_ms': event['timestamp_ms'] as number, + 'incident_id': 'incident-under-test', + 'fingerprint': 'fp', + 'original_session_id': (this.core.featureContext['session.id'] ?? '') as string, + 'original_view_id': (this.core.featureContext['view.id'] ?? '') as string, + 'original_view_name': (this.core.featureContext['view.name'] ?? '') as string, + 'original_view_url': (this.core.featureContext['view.url'] ?? '') as string + }; + if (this.recover) { + probe['crash.recovered'] = true; + } + this.probes.push(probe); + if (this.rum !== null) { + this.rum.onReceive(probe); + } + const kept: boolean = probe['kept'] !== false; + event['suppressed'] = !kept; + event['pending_written'] = kept; + event['report_async'] = false; + event['should_exit'] = false; // the test process stays alive + } +} + +function fireUnhandledException(text: string): void { + const observers: Record = (errorManager as Object as Record)['lastObservers'] as Record; + (observers['error'] as errorManager.ErrorObserver).onUnhandledException(text); +} + +function fireUnhandledRejection(reason: Object): void { + const observers: Record = (errorManager as Object as Record)['lastObservers'] as Record; + (observers['unhandledRejection'] as errorManager.UnhandledRejectionObserver)(reason, Promise.resolve()); +} + +/** A RUM feature wired to the fake core, with its monitor attached. */ +function featureWith(core: FakeCore, scope: CapturingScope, rate: number, sessionOnError: boolean): DefaultRumMonitor { + core.rumScope = scope; + const configuration: RumConfiguration = + new RumConfigurationBuilder('app-1').setSessionSampleRate(rate).setSessionOnError(sessionOnError).build(); + const feature: RumFeature = new RumFeature(core, configuration); + feature.onInitialize(); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration); + monitor.stopKeepAlive(); + feature.attachMonitor(monitor); + if (core.crashScope !== null) { + core.crashScope.rum = feature; + } + return monitor; +} + +/** A RUM feature on a rate-0 + switch configuration. */ +function onErrorFeature(core: FakeCore, scope: CapturingScope): DefaultRumMonitor { + return featureWith(core, scope, 0, true); +} + +/** A mapper that drops crashes (or, with `on`, events carrying that context key) and counts what it saw. */ +class CountingMapper { + crashesSeen: number = 0; + errorsSeen: number = 0; + readonly dropKey: string; + + constructor(dropKey: string = '') { + this.dropKey = dropKey; + } + + install(): void { + RumEventMapperHolder.configure((event: Record): Record | null => { + if (event['type'] !== 'error') { + return event; + } + const error: Record = event['error'] as Record; + const context: Record = (event['context'] ?? {}) as Record; + if (error['is_crash'] === true) { + this.crashesSeen += 1; + } else { + this.errorsSeen += 1; + } + if (this.dropKey.length > 0) { + return context[this.dropKey] === true ? null : event; + } + return error['is_crash'] === true ? null : event; + }); + } +} + +const CRASH_TEXT: string = 'Error name:Error\nError message:fatal\nStacktrace:\nat a (b.ets:1:1)'; + +class SilentFetcher implements RemoteConfigFetcher { + fetch(_url: string, _ifNoneMatch: string | null): Promise { + return Promise.resolve({ code: 304, body: '', etag: null } as RemoteConfigResponse); + } +} + +function raw(kind: string, timestampMs: number, key?: string): RumRawEvent { + const e: RumRawEvent = { kind, attributes: {}, timestampMs }; + if (key !== undefined) { + e.key = key; + } + return e; +} + +function startView(app: RumApplicationScope, atMs: number, key: string = 'home'): void { + const e: RumRawEvent = raw('startView', atMs, key); + e.name = key; + app.handleEvent(e); +} + +function addError(app: RumApplicationScope, atMs: number, message: string = 'boom'): void { + const e: RumRawEvent = raw('addError', atMs); + e.errorMessage = message; + e.errorSource = 'source'; + app.handleEvent(e); +} + +function addAction(app: RumApplicationScope, atMs: number, name: string = 'tap'): void { + const e: RumRawEvent = raw('addAction', atMs); + e.name = name; + e.actionType = 'tap'; + e.actionId = `${name}-${atMs}`; + app.handleEvent(e); +} + +function sessionOf(event: Record): Record { + return event['session'] as Record; +} + +function sampleRateOf(event: Record): Object | undefined { + const dd: Record = event['_dd'] as Record; + const configuration: Record | undefined = dd['configuration'] as Record; + return configuration === undefined ? undefined : configuration['session_sample_rate']; +} + +function newStore(core: FakeCore): RemoteConfigStore { + return new RemoteConfigStore(core, RemoteConfigStore.buildStoreKey(core.getContext(), 'https://intake.example.com', 'app-1')); +} + +/** A scope that keeps only the sessions that error (rate 0 + `sessionOnError`). */ +function onErrorApp(scope: CapturingScope, core: FakeCore = new FakeCore(), + store: RemoteConfigStore | null = null): RumApplicationScope { + return new RumApplicationScope(scope, core, 'app-1', 0, store, null, null, true); +} + +function resource(status: number): Record { + return { + 'type': 'resource', + 'view': { 'id': 'v1' } as Record, + 'resource': { 'status_code': status } as Record + }; +} + +function statusOf(event: Record): number { + return (event['resource'] as Record)['status_code'] as number; +} + +function detail(type: string, viewId: string = 'v1', tag: string = ''): Record { + return { 'type': type, 'view': { 'id': viewId } as Record, 'tag': tag }; +} + +function view(viewId: string, dateMs: number): Record { + return { 'type': 'view', 'date': dateMs, 'view': { 'id': viewId } as Record }; +} + +/** Holds `event` as the write path would, with the size the test wants it charged for. */ +function hold(buffer: WithheldEventBuffer, event: Record, bytes: number, nowMs: number): boolean { + return buffer.hold(event, JSON.stringify(event), bytes, nowMs); +} + +function released(buffer: WithheldEventBuffer, nowMs: number): Array> { + const scope: CapturingScope = new CapturingScope(); + scope.withWriteContext((_context: FlashcatContext, writer: EventWriter) => { + buffer.release(writer, nowMs); + }); + return scope.written; +} + +export default function sessionOnErrorTests(): void { + describe('rum-session-on-error-draw', (): void => { + it('withholdsEverythingFromASessionThatNeverErrors', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core); + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 10); + app.stopCurrentSession(now + 20); + app.releaseScheduledWithheld(); + expect(scope.written.length).assertEqual(0); + // Tracked as far as the crash module is concerned: a crash in it is still reported. + expect(core.featureContext['session.sampled']).assertEqual(true); + }); + + it('releasesTheMinuteBeforeTheErrorInOrderWithMarkers', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const now: number = Date.now(); + startView(app, now, 'first'); + addAction(app, now + 10, 'a1'); + startView(app, now + 20, 'second'); + addAction(app, now + 30, 'a2'); + addError(app, now + 40); + expect(scope.written.length).assertEqual(0); // behind the jitter + app.releaseScheduledWithheld(); // the app is flushed: what was earned leaves now + + const types: string[] = scope.written.map((e: Record) => e['type'] as string); + // Views oldest first, then the error, then the rest oldest first. + expect(types.join(',')).assertEqual('view,view,error,action,action'); + const views: Array> = scope.ofType('view'); + expect((views[0]['view'] as Record)['name']).assertEqual('first'); + for (const v of views) { + expect(sessionOf(v)['sampled_for_error']).assertEqual(true); + expect(sampleRateOf(v)).assertEqual(0); + } + // Markers ride on views only. + expect(sessionOf(scope.ofType('error')[0])['sampled_for_error']).assertUndefined(); + // One batch, uploaded once the last event is in. + expect(scope.flushes[scope.flushes.length - 1]).assertTrue(); + + // From now on the session writes straight through. + addAction(app, now + 50, 'after'); + expect(scope.ofType('action').length).assertEqual(3); + }); + + it('aSampledSessionCarriesNoMarkerAndItsDrawnRate', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 100, null, null, null, true); + startView(app, Date.now()); + const v: Record = scope.ofType('view')[0]; + expect(sessionOf(v)['sampled_for_error']).assertUndefined(); + expect(sampleRateOf(v)).assertEqual(100); + }); + + it('reportsRateZeroWhateverRateTheSessionLostItsDrawAt', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // Rate 1 + switch: nearly every session is an on-error one, and each must + // stand for itself, not for a hundred sessions. + let found: boolean = false; + for (let attempt: number = 0; attempt < 20 && !found; attempt++) { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 1, null, null, null, true); + startView(app, Date.now()); + if (scope.written.length > 0) { + continue; // won the 1% draw + } + addError(app, Date.now()); + app.releaseScheduledWithheld(); + const v: Record = scope.ofType('view')[0]; + expect(sampleRateOf(v)).assertEqual(0); + expect(sessionOf(v)['sampled_for_error']).assertEqual(true); + found = true; + } + expect(found).assertTrue(); + }); + + it('isOffByDefaultAndForAnUnsampledSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(new RumConfigurationBuilder('app').build().sessionOnError).assertFalse(); + expect(new RumConfigurationBuilder('app').setSessionOnError(true).build().sessionOnError).assertTrue(); + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0); + startView(app, Date.now()); + addError(app, Date.now()); + app.releaseScheduledWithheld(); + expect(scope.written.length).assertEqual(0); + expect(core.featureContext['session.sampled']).assertEqual(false); + }); + + it('aRateTheHookSetsToZeroSwitchesItOff', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const zero: (context: BeforeSamplingContext) => number | undefined = + (_context: BeforeSamplingContext): number | undefined => 0; + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 50, null, zero, null, true); + startView(app, Date.now()); + addError(app, Date.now()); + app.releaseScheduledWithheld(); + expect(scope.written.length).assertEqual(0); + + // Negative control: a hook that leaves the rate alone keeps the switch. + const untouched: (context: BeforeSamplingContext) => number | undefined = + (_context: BeforeSamplingContext): number | undefined => undefined; + const kept: CapturingScope = new CapturingScope(); + const app2: RumApplicationScope = new RumApplicationScope( + kept, new FakeCore(), 'app-1', 0, null, untouched, null, true); + startView(app2, Date.now()); + addError(app2, Date.now()); + app2.releaseScheduledWithheld(); + expect(kept.ofType('error').length).assertEqual(1); + }); + + it('releasesOnItsOwnAfterTheSessionJitter', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addError(app, Date.now()); + const delay: number = computeReleaseDelay(app.getCurrentSessionId() as string); + expect(scope.written.length).assertEqual(0); + await sleep(delay + 150); + expect(scope.ofType('view').length).assertEqual(1); + expect(scope.ofType('error').length).assertEqual(1); + }); + }); + + describe('rum-session-on-error-triggers', (): void => { + it('anErrorTheMapperDropsReleasesNothing', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + RumEventMapperHolder.configure((event: Record): Record | null => + event['type'] === 'error' ? null : event); + try { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addError(app, Date.now()); + app.releaseScheduledWithheld(); + app.stopCurrentSession(Date.now()); + expect(scope.written.length).assertEqual(0); + } finally { + RumEventMapperHolder.reset(); + } + // Negative control: the same session shape with the error kept releases. + const kept: CapturingScope = new CapturingScope(); + const app2: RumApplicationScope = onErrorApp(kept); + startView(app2, Date.now()); + addError(app2, Date.now()); + app2.stopCurrentSession(Date.now()); + expect(kept.ofType('error').length).assertEqual(1); + }); + + it('aMapperThatEndsTheSessionOnItsErrorStillReleasesIt', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + // An app that ends the session from inside its mapper, say on an + // authentication error, and keeps the error. + RumEventMapperHolder.configure((event: Record): Record | null => { + if (event['type'] === 'error') { + app.stopCurrentSession(Date.now()); + } + return event; + }); + try { + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 1); + addError(app, now + 2); + expect(app.getCurrentSessionId()).assertUndefined(); + // Ended with an error on record: released at once, history included. + expect(scope.ofType('error').length).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + // The view's final document, then the one counting the error — written + // straight through once released, as a collected session's would be. + expect(scope.written[0]['type']).assertEqual('view'); + expect(scope.ofType('view').length).assertEqual(2); + } finally { + RumEventMapperHolder.reset(); + } + }); + + it('aFailedRequestIsAnErrorThatReleases', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const now: number = Date.now(); + startView(app, now); + app.handleEvent(raw('startResource', now, 'r1')); + const failed: RumRawEvent = raw('stopResourceWithError', now + 5, 'r1'); + failed.errorMessage = 'timeout'; + app.handleEvent(failed); + app.releaseScheduledWithheld(); + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('aCrashReleasesAtOnceWithTheCountingDocument', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addAction(app, Date.now()); + app.handleEvent(raw('markCrash', Date.now())); + // Synchronous: no jitter, the process is about to exit. + const views: Array> = scope.ofType('view'); + expect(views.length).assertEqual(1); + const crash: Record = (views[0]['view'] as Record)['crash'] as Record; + expect(crash['count']).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + }); + + it('anInheritedCrashDoesNotMakeAWithheldSessionAnErrorSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A crash of a process that already died, with nothing to attribute it + // to, is replayed into the current session. One that withholds its + // events is not made an error session by it. + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + monitor.stopKeepAlive(); + monitor.startView('home', 'home'); + monitor.reportError('NativeCrash', RumErrorSource.SOURCE, '#00 pc', true, 'unhandled', {}, undefined, true, true); + monitor.releaseScheduledWithheld(); + expect(scope.written.length).assertEqual(0); + monitor.stopSession(); + expect(scope.written.length).assertEqual(0); + // Negative controls: a session collected on its own merits takes it — + // one the rate drew, and one the switch kept that has errored itself. + const sampledCore: FakeCore = new FakeCore(); + const sampled: CapturingScope = new CapturingScope(); + const monitor2: DefaultRumMonitor = new DefaultRumMonitor(sampledCore, sampled, + new RumConfigurationBuilder('app-1').setSessionSampleRate(100).setSessionOnError(true).build()); + monitor2.stopKeepAlive(); + monitor2.startView('home', 'home'); + monitor2.reportError('NativeCrash', RumErrorSource.SOURCE, '#00 pc', true, 'unhandled', {}, undefined, true, true); + expect(sampled.ofType('error').length).assertEqual(1); + const releasedCore: FakeCore = new FakeCore(); + const released: CapturingScope = new CapturingScope(); + const monitor3: DefaultRumMonitor = new DefaultRumMonitor(releasedCore, released, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + monitor3.stopKeepAlive(); + monitor3.startView('home', 'home'); + monitor3.addError('own', RumErrorSource.SOURCE); + monitor3.releaseScheduledWithheld(); + monitor3.reportError('NativeCrash', RumErrorSource.SOURCE, '#00 pc', true, 'unhandled', {}, undefined, true, true); + expect(released.ofType('error').length).assertEqual(2); + }); + + it('setForcedSessionReleasesWithoutAnError', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + const id: string = app.getCurrentSessionId() as string; + app.forceSession(Date.now()); + expect(app.getCurrentSessionId()).assertEqual(id); // kept, not replaced + expect(scope.ofType('view').length).assertEqual(1); + expect(sessionOf(scope.ofType('view')[0])['sampled_for_error']).assertEqual(true); + addAction(app, Date.now()); + expect(scope.ofType('action').length).assertEqual(1); + }); + + it('aFlushSendsAScheduledReleaseAtOnceAndTheTimerAddsNothing', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addError(app, Date.now()); + const delay: number = computeReleaseDelay(app.getCurrentSessionId() as string); + expect(scope.written.length).assertEqual(0); // scheduled, waiting on its jitter + app.releaseScheduledWithheld(); + const released: number = scope.written.length; + expect(released).assertEqual(2); // view + error, without waiting + await sleep(delay + 150); + expect(scope.written.length).assertEqual(released); // the cancelled timer wrote nothing more + }); + + it('aSessionThatExpiresWhileItsReleaseWaitsIsReleasedOnceAtExpiry', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const t0: number = Date.now(); + startView(app, t0); + addError(app, t0 + 1); + const first: string = app.getCurrentSessionId() as string; + const delay: number = computeReleaseDelay(first); + expect(scope.written.length).assertEqual(0); + startView(app, t0 + MIN_15 + 1000, 'later'); // detects the expiry + const atExpiry: number = scope.written.length; + expect(scope.ofType('error').length).assertEqual(1); + for (const e of scope.written) { + expect(sessionOf(e)['id']).assertEqual(first); // the new session holds its own + } + await sleep(delay + 150); + expect(scope.written.length).assertEqual(atExpiry); + }); + + it('aCrashThatDetectsExpiryReleasesTheExpiredSessionAtOnce', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const t0: number = Date.now(); + startView(app, t0); + addAction(app, t0 + 1); + const first: string = app.getCurrentSessionId() as string; + // Backgrounded past the inactivity limit, then the process dies of a crash. + app.handleEvent(raw('markCrash', t0 + MIN_15 + 1000)); + const views: Array> = scope.ofType('view'); + expect(views.length).assertEqual(1); + const crash: Record = (views[0]['view'] as Record)['crash'] as Record; + expect(crash['count']).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + for (const e of scope.written) { + expect(sessionOf(e)['id']).assertEqual(first); + } + }); + + it('aKeepAliveThatDetectsExpiryThrowsAnUnerroredSessionAway', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const t0: number = Date.now(); + startView(app, t0); + addAction(app, t0 + 1); + const first: string = app.getCurrentSessionId() as string; + // The flush on backgrounding, after the session sat idle past its limit. + app.handleEvent(raw('keepAlive', t0 + MIN_15 + 1000)); + app.releaseScheduledWithheld(); + expect(scope.written.length).assertEqual(0); + // The next session's error releases only its own events. + addError(app, t0 + MIN_15 + 2000); + app.releaseScheduledWithheld(); + expect(scope.ofType('error').length).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(0); + for (const e of scope.written) { + expect(sessionOf(e)['id'] === first).assertFalse(); + } + }); + + it('aFlushKeepsWhatHasNotYetEarnedItsRelease', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 1, 'before-background'); + app.releaseScheduledWithheld(); // backgrounded, no error yet + expect(scope.written.length).assertEqual(0); + addError(app, now + 2); + app.releaseScheduledWithheld(); + // The history from before the background is still there. + expect(scope.ofType('action').length).assertEqual(1); + }); + }); + + describe('rum-session-on-error-viewless', (): void => { + it('releasesAnErrorRaisedBeforeAnyView', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + addError(app, Date.now(), 'before any view'); + app.releaseScheduledWithheld(); + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('releasesTheErrorWhenTheMapperDroppedEveryViewDocument', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + RumEventMapperHolder.configure((event: Record): Record | null => + event['type'] === 'view' ? null : event); + try { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addAction(app, Date.now()); + addError(app, Date.now()); + app.releaseScheduledWithheld(); + expect(scope.ofType('view').length).assertEqual(0); + expect(scope.ofType('error').length).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + } finally { + RumEventMapperHolder.reset(); + } + }); + + it('releasesDetailsWhoseViewIsNotHeld', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + hold(buffer, detail('error', 'unknown-view', 'error'), 10, now); + hold(buffer, detail('action', 'unknown-view', 'act'), 10, now); + const out: Array> = released(buffer, now); + expect(out.map((e: Record) => e['tag'] as string).join(',')).assertEqual('error,act'); + }); + + it('stillDropsAViewLeftWithoutDetail', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + hold(buffer, view('old', now), 50, now); + hold(buffer, view('current', now + 1), 50, now + 1); + hold(buffer, detail('error', 'current', 'error'), 10, now + 2); + const out: Array> = released(buffer, now + 3); + const ids: string[] = out.filter((e: Record) => e['type'] === 'view') + .map((e: Record) => (e['view'] as Record)['id'] as string); + expect(ids.join(',')).assertEqual('current'); + }); + }); + + describe('rum-session-on-error-session-id', (): void => { + it('reportsNoSessionIdWhileWithheldAndTheRealOneOnceReleased', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + monitor.stopKeepAlive(); + monitor.startView('home', 'home'); + let reported: string | undefined = 'unset'; + monitor.getCurrentSessionId((id: string | undefined): void => { + reported = id; + }); + // Withheld: the backend has no session under this id, so the app gets none. + expect(reported).assertUndefined(); + monitor.addError('boom', RumErrorSource.SOURCE); + monitor.getCurrentSessionId((id: string | undefined): void => { + reported = id; + }); + expect(reported).assertUndefined(); // still behind the jitter + monitor.releaseScheduledWithheld(); + monitor.getCurrentSessionId((id: string | undefined): void => { + reported = id; + }); + expect(reported).assertEqual(core.featureContext['session.id'] as string); + expect(sessionOf(scope.ofType('view')[0])['id']).assertEqual(reported); + + // Negative control: a session the rate collects reports its id from the start. + const sampledCore: FakeCore = new FakeCore(); + const sampled: DefaultRumMonitor = new DefaultRumMonitor(sampledCore, new CapturingScope(), + new RumConfigurationBuilder('app-1').setSessionSampleRate(100).setSessionOnError(true).build()); + sampled.stopKeepAlive(); + sampled.startView('home', 'home'); + sampled.getCurrentSessionId((id: string | undefined): void => { + reported = id; + }); + expect(reported).assertEqual(sampledCore.featureContext['session.id'] as string); + }); + }); + + describe('rum-session-on-error-stop', (): void => { + it('sdkStopCancelsAReleaseStillWaitingOnItsJitter', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const delays: number[] = []; + const scopes: CapturingScope[] = []; + for (const stop of [true, false]) { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + monitor.stopKeepAlive(); + monitor.startView('home', 'home'); + monitor.addError('boom', RumErrorSource.SOURCE); + // The public API withholds the id along with the events; the context carries it. + delays.push(computeReleaseDelay(core.featureContext['session.id'] as string)); + if (stop) { + monitor.stopWithheld(); // what RumFeature.onStop does + } + scopes.push(scope); + } + await sleep(Math.max(delays[0], delays[1]) + 150); + expect(scopes[0].written.length).assertEqual(0); + // Negative control: the same session without the stop releases on its timer. + expect(scopes[1].ofType('error').length).assertEqual(1); + }); + }); + + describe('rum-session-on-error-discard', (): void => { + it('anExpiredSessionThatNeverErredIsThrownAwayWithItsStragglers', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const t0: number = Date.now(); + startView(app, t0); + app.handleEvent(raw('startResource', t0 + 1, 'slow')); + const first: string = app.getCurrentSessionId() as string; + // Inactivity expiry: the next event mints a new session. + startView(app, t0 + MIN_15 + 1000, 'later'); + expect(app.getCurrentSessionId() === first).assertFalse(); + // The slow request finishes after its session was discarded: dropped. + const done: RumRawEvent = raw('stopResource', t0 + MIN_15 + 2000, 'slow'); + done.statusCode = 200; + app.handleEvent(done); + // The new session errors: only its own events are released. + addError(app, t0 + MIN_15 + 3000); + app.releaseScheduledWithheld(); + for (const e of scope.written) { + expect(sessionOf(e)['id'] === first).assertFalse(); + } + expect(scope.ofType('resource').length).assertEqual(0); + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('aStragglerOfAReleasedSessionStillGoesOut', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const t0: number = Date.now(); + startView(app, t0); + app.handleEvent(raw('startResource', t0 + 1, 'slow')); + addError(app, t0 + 2); + const first: string = app.getCurrentSessionId() as string; + startView(app, t0 + MIN_15 + 1000, 'later'); // expiry releases the errored session at once + expect(scope.ofType('error').length).assertEqual(1); + const done: RumRawEvent = raw('stopResource', t0 + MIN_15 + 2000, 'slow'); + done.statusCode = 200; + app.handleEvent(done); + const resources: Array> = scope.ofType('resource'); + expect(resources.length).assertEqual(1); + expect(sessionOf(resources[0])['id']).assertEqual(first); + }); + + it('stopSessionDiscardsAnUnerroredSessionAndReleasesAnErroredOne', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + app.stopCurrentSession(Date.now()); + expect(scope.written.length).assertEqual(0); + addAction(app, Date.now()); // mints the next session + addError(app, Date.now()); + app.stopCurrentSession(Date.now()); // released at once, no jitter to wait out + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('aRefusedConsentDropsWhatWasHeld', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core); + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 1, 'consented'); + core.consent = TrackingConsent.NOT_GRANTED; + addAction(app, now + 2, 'refused'); + core.consent = TrackingConsent.GRANTED; + addError(app, now + 3); + app.releaseScheduledWithheld(); + // Nothing collected before or during the refusal survives it. + expect(scope.ofType('action').length).assertEqual(0); + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('aRefusedConsentForgetsWhatWasHeldEvenWhenNothingIsWrittenMeanwhile', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core); + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 1, 'consented'); + // Refused and granted again with no RUM write in between: only the + // core's announcement can tell the session. + core.consent = TrackingConsent.NOT_GRANTED; + app.forgetWithheld(); + core.consent = TrackingConsent.GRANTED; + addError(app, now + 2); + app.releaseScheduledWithheld(); + expect(scope.ofType('action').length).assertEqual(0); + expect(scope.ofType('error').length).assertEqual(1); + // The view is released as the error's container, from its post-consent document only. + expect(scope.ofType('view').length).assertEqual(1); + }); + + it('theCoreAnnouncementOfARefusalReachesTheSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // The whole path from the bus message the core sends to the buffer. + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const configuration: RumConfiguration = + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build(); + const feature: RumFeature = new RumFeature(core, configuration); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration); + monitor.stopKeepAlive(); + feature.attachMonitor(monitor); + monitor.startView('home', 'home'); + monitor.addAction(RumActionType.TAP, 'before-refusal'); + core.consent = TrackingConsent.NOT_GRANTED; + feature.onReceive({ 'type': 'consent_revoked' } as Record); + core.consent = TrackingConsent.GRANTED; + monitor.addError('boom', RumErrorSource.SOURCE); + monitor.releaseScheduledWithheld(); + expect(scope.ofType('action').length).assertEqual(0); + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('aRefusedConsentForgetsTheErrorThatHadEarnedARelease', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core); + const now: number = Date.now(); + startView(app, now); + addError(app, now + 1); + const delay: number = computeReleaseDelay(app.getCurrentSessionId() as string); + core.consent = TrackingConsent.NOT_GRANTED; + app.forgetWithheld(); + core.consent = TrackingConsent.GRANTED; + // The error went with the refusal: the session is back to waiting for one. + addAction(app, now + 2, 'after'); + await sleep(delay + 150); + expect(scope.written.length).assertEqual(0); // the cancelled timer released nothing + app.stopCurrentSession(now + 3); + expect(scope.written.length).assertEqual(0); // ended without an error on record + }); + }); + + describe('rum-session-on-error-buffer', (): void => { + it('evictsSuccessfulRequestsFirstAndKeepsTheFirstError', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + const now: number = 1_000_000; + hold(buffer, view('v1', now), 50, now); + const first: Record = detail('error', 'v1', 'first-error'); + hold(buffer, first, 2000, now); + hold(buffer, resource(500), 4000, now); + for (let i: number = 0; i < 40; i++) { + hold(buffer, resource(200), 2000, now + i); + } + hold(buffer, detail('action', 'v1', 'act'), 2000, now + 50); + const out: Array> = released(buffer, now + 60); + expect(out.findIndex((e: Record) => e['tag'] === 'first-error')).assertEqual(1); // right behind the view + // The failed request and the action outlive the successful requests, + // which give up exactly what the 64 KiB budget needs: (65536 - 8000) / 2000. + expect(out.filter((e: Record) => e['tag'] === 'act').length).assertEqual(1); + expect(out.filter((e: Record) => e['type'] === 'resource' && statusOf(e) === 500).length) + .assertEqual(1); + expect(out.filter((e: Record) => e['type'] === 'resource' && statusOf(e) === 200).length) + .assertEqual(Math.floor((WITHHELD_BUFFER_BYTES_LIMIT - 8000) / 2000)); + }); + + it('dropsTheNewestErrorWhenOnlyErrorsAreLeft', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + const now: number = 1_000_000; + hold(buffer, view('v1', now), 50, now); + for (let i: number = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT + 5; i++) { + hold(buffer, detail('error', 'v1', `e${i}`), 10, now + i); + } + const out: Array> = released(buffer, now + 300); + const errors: Array> = out.filter((e: Record) => e['type'] === 'error'); + expect(errors.length).assertEqual(WITHHELD_BUFFER_EVENTS_LIMIT); + // Exactly the oldest ones survive, in order: the five newest were evicted. + const expected: string[] = []; + for (let i: number = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT; i++) { + expected.push(`e${i}`); + } + expect(errors.map((e: Record) => e['tag'] as string).join(',')).assertEqual(expected.join(',')); + }); + + it('keepsOnlyTheLastMinuteUntilAReleaseFreezesTheWindow', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + hold(buffer, view('v1', now), 50, now); + hold(buffer, detail('action', 'v1', 'old'), 10, now); + hold(buffer, detail('action', 'v1', 'recent'), 10, now + WITHHELD_BUFFER_DURATION_MS); + hold(buffer, detail('error', 'v1', 'error'), 10, now + WITHHELD_BUFFER_DURATION_MS + 1); + let out: Array> = released(buffer, now + WITHHELD_BUFFER_DURATION_MS + 1); + expect(out.filter((e: Record) => e['tag'] === 'old').length).assertEqual(0); + expect(out.filter((e: Record) => e['tag'] === 'recent').length).assertEqual(1); + + // The release timer fires late (backgrounded app): the window stays where + // the release was scheduled, so the minute before the error survives. + const frozen: WithheldEventBuffer = new WithheldEventBuffer(); + hold(frozen, view('v1', now), 50, now); + hold(frozen, detail('action', 'v1', 'before'), 10, now); + hold(frozen, detail('error', 'v1', 'error'), 10, now + 1000); + frozen.freeze(now + 1000); + out = released(frozen, now + 10 * WITHHELD_BUFFER_DURATION_MS); + expect(out.filter((e: Record) => e['tag'] === 'before').length).assertEqual(1); + }); + + it('boundsViewsButNeverDropsTheCurrentOne', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + // The current view starts first and keeps being updated late. + for (let i: number = 0; i < WITHHELD_BUFFER_VIEWS_LIMIT + 10; i++) { + hold(buffer, view(`v${i}`, now + i), 50, now + i); + hold(buffer, detail('action', `v${i}`), 10, now + i); + } + // A late update of an old view must not make it current again. + hold(buffer, view('v20', now + 20), 50, now + 100); + hold(buffer, detail('error', `v${WITHHELD_BUFFER_VIEWS_LIMIT + 9}`, 'error'), 10, now + 101); + const out: Array> = released(buffer, now + 102); + const views: Array> = out.filter((e: Record) => e['type'] === 'view'); + expect(views.length <= WITHHELD_BUFFER_VIEWS_LIMIT).assertTrue(); + const ids: string[] = views.map((e: Record) => (e['view'] as Record)['id'] as string); + expect(ids.indexOf(`v${WITHHELD_BUFFER_VIEWS_LIMIT + 9}`) !== -1).assertTrue(); + expect(out.filter((e: Record) => e['tag'] === 'error').length).assertEqual(1); + // Oldest first, whatever order they were updated in. + for (let i: number = 1; i < views.length; i++) { + expect((views[i]['date'] as number) >= (views[i - 1]['date'] as number)).assertTrue(); + } + }); + + it('aLateUpdateOfAnEndedViewDoesNotMakeItCurrentAgain', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + hold(buffer, view('ended', now), 50, now); + hold(buffer, detail('action', 'ended', 'act'), 10, now + 1); + hold(buffer, view('current', now + 2), 50, now + 2); // in progress, no detail of its own yet + // The ended view's final document arrives after the current view started. + hold(buffer, view('ended', now), 50, now + 3); + // Prune keeps the current view only by being current: the detail-less + // one that survives must be `current`, not `ended`. + hold(buffer, detail('error', 'ended', 'error'), 10, now + 4); + const out: Array> = released(buffer, now + 5); + const ids: string[] = out.filter((e: Record) => e['type'] === 'view') + .map((e: Record) => (e['view'] as Record)['id'] as string); + expect(ids.join(',')).assertEqual('ended,current'); + + // Negative control: with the late update to a view that never had detail + // and is not current, that view is dropped. + const other: WithheldEventBuffer = new WithheldEventBuffer(); + hold(other, view('stale', now), 50, now); + hold(other, view('current', now + 2), 50, now + 2); + hold(other, view('stale', now), 50, now + 3); + hold(other, detail('error', 'current', 'error'), 10, now + 4); + const ids2: string[] = released(other, now + 5).filter((e: Record) => e['type'] === 'view') + .map((e: Record) => (e['view'] as Record)['id'] as string); + expect(ids2.join(',')).assertEqual('current'); + }); + + it('keepsTheNewestDocumentVersionOfAViewWhateverOrderTheyArriveIn', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A document is assembled before its mapper runs; a mapper that reports + // into the SDK has the next version written first. + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + const v3: Record = view('v1', now); + v3['_dd'] = { 'document_version': 3 } as Record; + const v2: Record = view('v1', now); + v2['_dd'] = { 'document_version': 2 } as Record; + hold(buffer, v3, 50, now + 1); + hold(buffer, v2, 50, now + 2); + hold(buffer, detail('error', 'v1', 'error'), 10, now + 3); + const views: Array> = released(buffer, now + 4) + .filter((e: Record) => e['type'] === 'view'); + expect(views.length).assertEqual(1); + expect((views[0]['_dd'] as Record)['document_version']).assertEqual(3); + // Negative control: in order, the later version supersedes. + const ordered: WithheldEventBuffer = new WithheldEventBuffer(); + hold(ordered, v2, 50, now + 1); + hold(ordered, v3, 50, now + 2); + hold(ordered, detail('error', 'v1', 'error'), 10, now + 3); + const kept: Array> = released(ordered, now + 4) + .filter((e: Record) => e['type'] === 'view'); + expect((kept[0]['_dd'] as Record)['document_version']).assertEqual(3); + }); + + it('dropsADetailTooLargeToEverBeReleasedAndKeepsTheHistory', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + hold(buffer, view('v1', now), 50, now); + hold(buffer, detail('action', 'v1', 'kept'), 10, now); + expect(hold(buffer, detail('action', 'v1', 'huge'), WITHHELD_BUFFER_BYTES_LIMIT + 1, now)).assertFalse(); + const out: Array> = released(buffer, now); + expect(out.filter((e: Record) => e['tag'] === 'kept').length).assertEqual(1); + }); + + it('anOversizedErrorGoesOutAtOnceAndTheHistoryBehindTheJitter', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addAction(app, Date.now()); + addError(app, Date.now(), 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT + 10)); + expect(scope.written.length).assertEqual(1); + expect(scope.written[0]['type']).assertEqual('error'); + app.releaseScheduledWithheld(); + expect(scope.ofType('view').length).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + }); + + it('releasesWhatWasWrittenNotWhatTheAppChangedAfterwards', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const now: number = Date.now(); + startView(app, now); + const payload: Record = { 'value': 'small' }; + const e: RumRawEvent = raw('addAction', now + 1); + e.name = 'tap'; + e.actionType = 'tap'; + e.actionId = 'a1'; + e.attributes = { 'nested': payload }; + app.handleEvent(e); + // The app keeps changing the object it passed in. A collected session + // would have uploaded the value at the write; so must this one. + payload['value'] = 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT * 2); + addError(app, now + 2); + app.releaseScheduledWithheld(); + const action: Record = scope.ofType('action')[0]; + const nested: Record = (action['context'] as Record)['nested'] as Record; + expect(nested['value']).assertEqual('small'); + }); + + it('anOversizedErrorStorageRefusesReleasesNothing', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + scope.rejectAbove = WITHHELD_BUFFER_BYTES_LIMIT * 2; + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + addAction(app, Date.now()); + addError(app, Date.now(), 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT * 3)); + app.releaseScheduledWithheld(); + expect(scope.written.length).assertEqual(0); // no error reached storage, so no history either + app.stopCurrentSession(Date.now()); + expect(scope.written.length).assertEqual(0); + // Negative control: an oversized error storage accepts releases the history. + const kept: CapturingScope = new CapturingScope(); + kept.rejectAbove = WITHHELD_BUFFER_BYTES_LIMIT * 4; + const app2: RumApplicationScope = onErrorApp(kept); + startView(app2, Date.now()); + addError(app2, Date.now(), 'x'.repeat(WITHHELD_BUFFER_BYTES_LIMIT * 3)); + app2.releaseScheduledWithheld(); + expect(kept.ofType('error').length).assertEqual(1); + expect(kept.ofType('view').length).assertEqual(1); + }); + + it('spreadsReleasesAcrossTheJitterWindow', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + let min: number = WITHHELD_BUFFER_RELEASE_MAX_DELAY_MS; + let max: number = 0; + for (let i: number = 0; i < 200; i++) { + const id: string = `${(0x10000000 + i * 7919).toString(16)}-1b2c-4d5e-8f90-a1b2c3d4e5f6`; + const delay: number = computeReleaseDelay(id); + expect(delay >= 0 && delay < WITHHELD_BUFFER_RELEASE_MAX_DELAY_MS).assertTrue(); + expect(computeReleaseDelay(id)).assertEqual(delay); // deterministic per session + min = Math.min(min, delay); + max = Math.max(max, delay); + } + // A character-code sum would bunch these into a few hundred milliseconds. + expect(max - min > 2000).assertTrue(); + }); + }); + + describe('rum-session-on-error-remote', (): void => { + const ignore: RemoteConfigChangeListener = (_a: string, _b: number | null, _c: number | null): void => {}; + + it('readsTheConsoleSwitchAndKeepsTheInitValueWhenAbsent', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', ignore); + controller.apply('{"version":1,"enabled":true,"rum":{"sessionSampleRate":0,"sessionOnError":true}}', null); + expect((store.read() as RemoteConfigValues).sessionOnError).assertEqual(true); + controller.apply('{"version":2,"enabled":true,"rum":{"sessionSampleRate":0,"sessionOnError":false}}', null); + expect((store.read() as RemoteConfigValues).sessionOnError).assertEqual(false); + controller.apply('{"version":3,"enabled":true,"rum":{"sessionSampleRate":0,"sessionOnError":"yes"}}', null); + expect((store.read() as RemoteConfigValues).sessionOnError).assertNull(); + controller.apply('{"version":4,"enabled":false,"rum":{"sessionOnError":true}}', null); + expect((store.read() as RemoteConfigValues).sessionOnError).assertNull(); + }); + + it('theConsoleSwitchWinsOverInitAtTheNextDraw', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null, 300, false, true)); + const scope: CapturingScope = new CapturingScope(); + // Init says off; the console says on. + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); + startView(app, Date.now()); + addError(app, Date.now()); + app.releaseScheduledWithheld(); + expect(scope.ofType('error').length).assertEqual(1); + + // And the other way round: init on, console off. + const core2: FakeCore = new FakeCore(); + const store2: RemoteConfigStore = newStore(core2); + store2.write(new RemoteConfigValues(0, 1, null, null, 300, false, false)); + const scope2: CapturingScope = new CapturingScope(); + const app2: RumApplicationScope = onErrorApp(scope2, core2, store2); + startView(app2, Date.now()); + addError(app2, Date.now()); + app2.releaseScheduledWithheld(); + expect(scope2.written.length).assertEqual(0); + }); + + it('rateZeroWithTheSwitchOnDoesNotStopTheSessionItKept', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app, Date.now()); + const id: string = app.getCurrentSessionId() as string; + // First fetch of a fresh install: the console says exactly what init said. + controller.apply('{"version":1,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":0,"sessionOnError":true}}', null); + expect(app.getCurrentSessionId()).assertEqual(id); + // The start-up error still releases. + addError(app, Date.now()); + app.releaseScheduledWithheld(); + expect(scope.ofType('error').length).assertEqual(1); + }); + + it('turningTheSwitchOffAtRateZeroStopsTheSessionItKept', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app, Date.now()); + controller.apply('{"version":1,"enabled":true,"rum":{"sessionSampleRate":0,"sessionOnError":false}}', null); + expect(app.getCurrentSessionId()).assertUndefined(); + expect(scope.written.length).assertEqual(0); // ended without an error: discarded + }); + + it('anImmediateSwitchOnlyChangeKeepsASessionTheRateKept', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(RumApplicationScope.shouldEndSession(true, 100, 100, 'immediate', false, true)).assertFalse(); + // Negative control: a rate that moved is still re-decided. + expect(RumApplicationScope.shouldEndSession(true, 100, 50, 'immediate', false, true)).assertTrue(); + + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app, Date.now()); + const id: string = app.getCurrentSessionId() as string; + controller.apply('{"version":1,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":100,"sessionOnError":true}}', null); + expect(app.getCurrentSessionId()).assertEqual(id); + }); + + it('anImmediateSwitchOnlyChangeKeepsADarkSessionTheRateLost', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(RumApplicationScope.shouldEndSession(false, 20, 20, 'immediate', false, true)).assertFalse(); + // Negative control: a rate that moved is still re-decided. + expect(RumApplicationScope.shouldEndSession(false, 20, 50, 'immediate', false, true)).assertTrue(); + + // A session that lost its draw at 1% is not owed another one because + // the console turned the switch on with `immediate`. + let found: boolean = false; + for (let attempt: number = 0; attempt < 20 && !found; attempt++) { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 1, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app, Date.now()); + if (scope.written.length > 0) { + continue; // won the 1% draw + } + const id: string = app.getCurrentSessionId() as string; + controller.apply('{"version":1,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":1,"sessionOnError":true}}', null); + expect(app.getCurrentSessionId()).assertEqual(id); + found = true; + } + expect(found).assertTrue(); + }); + + it('aSwitchChangeDoesNotTurnADeferredRateIntoAnImmediateRedraw', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // Rate 50 was announced for the next session; republished beside a + // changed switch under `immediate`, it is still the same announcement. + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app, Date.now()); + const id: string = app.getCurrentSessionId() as string; + controller.apply('{"version":1,"enabled":true,"activation":"next_session",' + + '"rum":{"sessionSampleRate":50,"sessionOnError":false}}', null); + expect(app.getCurrentSessionId()).assertEqual(id); + controller.apply('{"version":2,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":50,"sessionOnError":true}}', null); + expect(app.getCurrentSessionId()).assertEqual(id); + // Negative control: a rate that really moved under `immediate` is re-decided. + controller.apply('{"version":3,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":60,"sessionOnError":true}}', null); + expect(app.getCurrentSessionId()).assertUndefined(); + + // The same for a session the switch kept: turning the switch off beside + // a deferred rate does not throw its history away. + let found: boolean = false; + for (let attempt: number = 0; attempt < 20 && !found; attempt++) { + const core2: FakeCore = new FakeCore(); + const store2: RemoteConfigStore = newStore(core2); + const scope2: CapturingScope = new CapturingScope(); + const app2: RumApplicationScope = new RumApplicationScope(scope2, core2, 'app-1', 1, store2, null, null, true); + const controller2: RemoteConfigController = new RemoteConfigController( + store2, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app2.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app2, Date.now()); + if (scope2.written.length > 0) { + continue; // won the 1% draw + } + const id2: string = app2.getCurrentSessionId() as string; + controller2.apply('{"version":1,"enabled":true,"activation":"next_session",' + + '"rum":{"sessionSampleRate":50,"sessionOnError":true}}', null); + controller2.apply('{"version":2,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":50,"sessionOnError":false}}', null); + expect(app2.getCurrentSessionId()).assertEqual(id2); + addError(app2, Date.now()); + app2.releaseScheduledWithheld(); + expect(scope2.ofType('error').length).assertEqual(1); + found = true; + } + expect(found).assertTrue(); + }); + + it('aCacheWrittenByAnSdkWithoutTheSwitchKeepsItsRateButNotItsValidator', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // An entry written before the switch existed carries a validator for a + // response whose switch was never read; a 304 against it would keep the + // switch unknown for as long as the console leaves the configuration + // alone. The rate it stored — an emergency stop, say — stays in force. + const core: FakeCore = new FakeCore(); + const key: string = RemoteConfigStore.buildStoreKey(core.getContext(), 'https://intake.example.com', 'app-1'); + core.writeSetting(`${key}.values`, '{"rate":0,"version":10,"etag":"\\"v10\\"","ttl":300,"refresh_on_foreground":false}'); + const legacy: RemoteConfigValues = newStore(core).read() as RemoteConfigValues; + expect(legacy.sessionSampleRate).assertEqual(0); + expect(legacy.version).assertEqual(10); + expect(legacy.etag).assertNull(); + expect(legacy.sessionOnError).assertNull(); + // Negative control: an entry this SDK wrote keeps its validator. + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 11, null, '"v11"', 300, false, true)); + const current: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(current.etag).assertEqual('"v11"'); + expect(current.sessionOnError).assertEqual(true); + }); + + it('decidesTheRunningSessionByTheStopTree', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // Kept by the switch, rate 0 with the switch on: the switch's ordinary setting. + expect(RumApplicationScope.shouldEndSession(true, 0, 0, 'next_session', true, true)).assertFalse(); + expect(RumApplicationScope.shouldEndSession(true, 20, 0, 'immediate', true, true)).assertFalse(); + // Kept by the switch, which is now off at rate 0: stopped. + expect(RumApplicationScope.shouldEndSession(true, 0, 0, 'next_session', true, false)).assertTrue(); + // Drawn by the rate: an emergency stop even with the switch on. + expect(RumApplicationScope.shouldEndSession(true, 100, 0, 'next_session', false, true)).assertTrue(); + // Dark and drawn at 0: the switch turning on is owed a new draw. + expect(RumApplicationScope.shouldEndSession(false, 0, 0, 'next_session', false, true)).assertTrue(); + // Dark after a fair draw: the draw is locked for the session. + expect(RumApplicationScope.shouldEndSession(false, 20, 20, 'next_session', false, true)).assertFalse(); + }); + + it('aDarkSessionDrawnAtZeroIsRedrawnWhenTheSwitchTurnsOn', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + startView(app, Date.now()); + controller.apply('{"version":1,"enabled":true,"rum":{"sessionSampleRate":0,"sessionOnError":true}}', null); + expect(app.getCurrentSessionId()).assertUndefined(); + startView(app, Date.now()); + addError(app, Date.now()); + app.releaseScheduledWithheld(); + expect(scope.ofType('error').length).assertEqual(1); + }); + }); + + describe('rum-session-on-error-crash', (): void => { + it('snapshotsTheWithheldViewSoANextLaunchCrashStillReportsIt', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + let stored: string = ''; + LastViewSnapshot.configure((serialized: string): void => { + stored = serialized; + }, (): string => stored, (): void => { + stored = ''; + }); + try { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + expect(scope.written.length).assertEqual(0); // nothing uploaded... + const snapshot: Record = LastViewSnapshot.read() as Record; + expect(snapshot['type']).assertEqual('view'); // ...but the local snapshot is kept + // What a native crash replays next launch: the crash-marked view keeps the markers. + const crashed: Record = RumFeature.viewEventWithCrash(snapshot); + expect(sessionOf(crashed)['sampled_for_error']).assertEqual(true); + expect(sampleRateOf(crashed)).assertEqual(0); + expect(sessionOf(crashed)['id']).assertEqual(app.getCurrentSessionId() as string); + } finally { + LastViewSnapshot.reset(); + } + }); + }); + + describe('rum-session-on-error-discard-and-crash', (): void => { + it('throwingASessionAwayAlsoThrowsAwayItsLastViewSnapshot', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + let stored: string = ''; + let deletes: number = 0; + LastViewSnapshot.configure((serialized: string): void => { + stored = serialized; + }, (): string => stored, (): void => { + stored = ''; + deletes += 1; + }); + try { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + startView(app, Date.now()); + expect(stored.length > 0).assertTrue(); + app.stopCurrentSession(Date.now()); // ended without an error: discarded + expect(stored).assertEqual(''); + expect(deletes).assertEqual(1); + + // Negative control: a released session keeps its snapshot... + const kept: CapturingScope = new CapturingScope(); + const app2: RumApplicationScope = onErrorApp(kept); + startView(app2, Date.now()); + addError(app2, Date.now()); + app2.stopCurrentSession(Date.now()); + expect(stored.length > 0).assertTrue(); + expect(deletes).assertEqual(1); + // ...and a discard never touches another session's snapshot: app4 is + // thrown away by an SDK stop (no final view write) while app3's view + // is the one on file. + const other: CapturingScope = new CapturingScope(); + const app3: RumApplicationScope = onErrorApp(other); + startView(app3, Date.now()); + const otherView: string = stored; + const stale: CapturingScope = new CapturingScope(); + const app4: RumApplicationScope = onErrorApp(stale); + startView(app4, Date.now()); + LastViewSnapshot.record(JSON.parse(otherView) as Record); // app3's view is the newest again + app4.stopWithheld(); + expect(stored).assertEqual(otherView); + expect(deletes).assertEqual(1); + // The owner's discard takes it, final document included. + app3.stopCurrentSession(Date.now()); + expect(stored).assertEqual(''); + expect(deletes).assertEqual(2); + } finally { + LastViewSnapshot.reset(); + } + }); + + it('aNextLaunchCrashCannotResurrectAThrownAwaySession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // Run 1: a session kept on error, thrown away without one. + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = onErrorFeature(core, scope); + monitor.startView('home', 'home'); + const discarded: string = core.featureContext['session.id'] as string; + expect(scope.lastView.length > 0).assertTrue(); + monitor.stopSession(); + expect(scope.written.length).assertEqual(0); + // Run 2: the process died of a native crash in the gap; the replay carries no ids + // (the JS incident path leaves `session.sampled` true and the id empty: same replay). + const next: CapturingScope = new CapturingScope(); + next.lastView = scope.lastView; // whatever run 1 left in the crash storage + const monitor2: DefaultRumMonitor = onErrorFeature(core, next); + monitor2.startView('home', 'home'); + const feature: RumFeature = new RumFeature(core, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + feature.onInitialize(); + feature.attachMonitor(monitor2); + feature.onReceive({ 'type': 'crash_report', 'message': 'NativeCrash', 'stack': '#00 pc', 'crash_kind': 'crash', + 'timestamp_ms': Date.now() - 1000 } as Record); + for (const e of next.written) { + expect(sessionOf(e)['id'] === discarded).assertFalse(); + } + // Negative control: a session that errored keeps its snapshot, and the crash lands in it. + const core2: FakeCore = new FakeCore(); + const scope2: CapturingScope = new CapturingScope(); + const monitor3: DefaultRumMonitor = onErrorFeature(core2, scope2); + monitor3.startView('home', 'home'); + const released: string = core2.featureContext['session.id'] as string; + monitor3.addError('boom', RumErrorSource.SOURCE); + monitor3.stopSession(); + const snapshot: string = scope2.lastView; + expect(snapshot.length > 0).assertTrue(); + const next2: CapturingScope = new CapturingScope(); + next2.lastView = snapshot; + const feature2: RumFeature = new RumFeature(core2, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + core2.rumScope = next2; + feature2.onInitialize(); + feature2.onReceive({ 'type': 'crash_report', 'message': 'NativeCrash', 'stack': '#00 pc', 'crash_kind': 'crash', + 'timestamp_ms': Date.now() - 1000 } as Record); + expect(next2.ofType('error').length).assertEqual(1); + expect(sessionOf(next2.ofType('error')[0])['id']).assertEqual(released); + }); + + it('aTerminatingCrashTheMapperDropsReleasesNothing', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + RumEventMapperHolder.configure((event: Record): Record | null => + event['type'] === 'error' ? null : event); + try { + const core: FakeCore = new FakeCore(); + core.crashScope = new FakeCrashScope(core); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = onErrorFeature(core, scope); + monitor.startView('home', 'home'); + monitor.addAction(RumActionType.TAP, 'before'); + fireUnhandledException('Error name:Error\nError message:fatal\nStacktrace:\nat a (b.ets:1:1)'); + expect(scope.written.length).assertEqual(0); + // The crash feature was told, so nothing is persisted for a replay either. + expect(core.crashScope.controls.length).assertEqual(1); + expect(core.crashScope.controls[0]['suppressed']).assertEqual(true); + } finally { + RumEventMapperHolder.reset(); + } + // Negative control: a crash the mapper keeps releases the history at once. + const core2: FakeCore = new FakeCore(); + core2.crashScope = new FakeCrashScope(core2); + const kept: CapturingScope = new CapturingScope(); + const monitor2: DefaultRumMonitor = onErrorFeature(core2, kept); + monitor2.startView('home', 'home'); + monitor2.addAction(RumActionType.TAP, 'before'); + fireUnhandledException('Error name:Error\nError message:fatal\nStacktrace:\nat a (b.ets:1:1)'); + expect(kept.ofType('view').length >= 1).assertTrue(); + expect(kept.ofType('action').length).assertEqual(1); + expect(core2.crashScope.controls[0]['suppressed'] === true).assertFalse(); + }); + + it('anUncaughtExceptionWithoutTheCrashFeatureReleasesAtOnce', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = onErrorFeature(core, scope); + monitor.startView('home', 'home'); + monitor.addAction(RumActionType.TAP, 'before'); + fireUnhandledException('Error name:Error\nError message:fatal\nStacktrace:\nat a (b.ets:1:1)'); + // No jitter: the process may not live that long. + expect(scope.ofType('error').length).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + // Negative control: an unhandled rejection keeps the jitter. + const core2: FakeCore = new FakeCore(); + const quiet: CapturingScope = new CapturingScope(); + const monitor2: DefaultRumMonitor = onErrorFeature(core2, quiet); + monitor2.startView('home', 'home'); + fireUnhandledRejection(new Error('later')); + expect(quiet.written.length).assertEqual(0); + monitor2.releaseScheduledWithheld(); + expect(quiet.ofType('error').length).assertEqual(1); + }); + + it('anImmediateRateRiseKeepsASessionTheSwitchKept', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(RumApplicationScope.shouldEndSession(true, 0, 50, 'immediate', true, true)).assertFalse(); + // Negative control: a session the rate drew is still re-decided. + expect(RumApplicationScope.shouldEndSession(true, 20, 50, 'immediate', false, true)).assertTrue(); + + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope, core, store); + const controller: RemoteConfigController = new RemoteConfigController( + store, new SilentFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(activation, before, after, Date.now()); + }); + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 1, 'held'); + const id: string = core.featureContext['session.id'] as string; + controller.apply('{"version":1,"enabled":true,"activation":"immediate",' + + '"rum":{"sessionSampleRate":50,"sessionOnError":true}}', null); + expect(core.featureContext['session.id']).assertEqual(id); // kept, history included + addError(app, now + 2); + app.releaseScheduledWithheld(); + expect(scope.ofType('action').length).assertEqual(1); + expect(scope.ofType('error').length).assertEqual(1); + }); + }); + + describe('rum-session-on-error-crash-mapper', (): void => { + it('observeOnlyReportsTheLiveErrorOnceThroughTheMapperWhateverTheSwitch', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A mapper that drops crashes but keeps ordinary errors: OBSERVE_ONLY + // reports the exception as an ordinary error, exactly once, and no + // incident is ever involved — with the switch off... + const mapper: CountingMapper = new CountingMapper(); + mapper.install(); + try { + const core: FakeCore = new FakeCore(); + core.crashScope = new FakeCrashScope(core); + core.crashScope.policy = 'observe_only'; + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = featureWith(core, scope, 100, false); + monitor.startView('home', 'home'); + fireUnhandledException(CRASH_TEXT); + expect(scope.ofType('error').length).assertEqual(1); + expect(mapper.crashesSeen).assertEqual(0); + expect(mapper.errorsSeen).assertEqual(1); + expect(core.crashScope.probes.length).assertEqual(0); + expect(core.crashScope.controls[0]['suppressed'] === true).assertFalse(); + // ...and with the switch on, where the session was withholding. + const core2: FakeCore = new FakeCore(); + core2.crashScope = new FakeCrashScope(core2); + core2.crashScope.policy = 'observe_only'; + const scope2: CapturingScope = new CapturingScope(); + const monitor2: DefaultRumMonitor = featureWith(core2, scope2, 0, true); + monitor2.startView('home', 'home'); + fireUnhandledException(CRASH_TEXT); + expect(scope2.ofType('error').length).assertEqual(1); + expect(mapper.crashesSeen).assertEqual(0); + expect(core2.crashScope.probes.length).assertEqual(0); + } finally { + RumEventMapperHolder.reset(); + } + }); + + it('aTerminatingCrashInACollectedSessionLeavesTheMapperToTheReplay', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // Not withheld: nothing the crash does in this process depends on the + // mapper, so it is not asked now — the replay asks it once, as before. + const mapper: CountingMapper = new CountingMapper(); + mapper.install(); + try { + const core: FakeCore = new FakeCore(); + core.crashScope = new FakeCrashScope(core); + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = featureWith(core, scope, 100, false); + monitor.startView('home', 'home'); + fireUnhandledException(CRASH_TEXT); + expect(mapper.crashesSeen).assertEqual(0); + expect(core.crashScope.controls[0]['suppressed'] === true).assertFalse(); + expect(core.crashScope.controls[0]['pending_written']).assertEqual(true); + // The crash is counted on the view, as on a run without the switch. + const views: Array> = scope.ofType('view'); + const last: Record = views[views.length - 1]; + expect(((last['view'] as Record)['crash'] as Record)['count']).assertEqual(1); + } finally { + RumEventMapperHolder.reset(); + } + }); + + it('theCrashTheMapperJudgesIsTheOneTheReplayWillCarry', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A mapper keyed on a field only the replayed event carries + // (`crash.recovered`): the verdict at crash time must be the replay's. + const mapper: CountingMapper = new CountingMapper('crash.recovered'); + mapper.install(); + try { + const core: FakeCore = new FakeCore(); + core.crashScope = new FakeCrashScope(core); + core.crashScope.recover = true; + const scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = onErrorFeature(core, scope); + monitor.startView('home', 'home'); + monitor.addAction(RumActionType.TAP, 'before'); + fireUnhandledException(CRASH_TEXT); + expect(mapper.crashesSeen).assertEqual(1); + expect(core.crashScope.controls[0]['suppressed']).assertEqual(true); + expect(scope.written.length).assertEqual(0); // no release, nothing billed + // Negative control: the same crash without a recovery is kept and released. + const core2: FakeCore = new FakeCore(); + core2.crashScope = new FakeCrashScope(core2); + const kept: CapturingScope = new CapturingScope(); + const monitor2: DefaultRumMonitor = onErrorFeature(core2, kept); + monitor2.startView('home', 'home'); + monitor2.addAction(RumActionType.TAP, 'before'); + fireUnhandledException(CRASH_TEXT); + expect(core2.crashScope.controls[0]['suppressed'] === true).assertFalse(); + expect(kept.ofType('action').length).assertEqual(1); + } finally { + RumEventMapperHolder.reset(); + } + }); + }); +} diff --git a/scripts/unit-node/shims/kit-ability.js b/scripts/unit-node/shims/kit-ability.js index 3883569..c273ff8 100644 --- a/scripts/unit-node/shims/kit-ability.js +++ b/scripts/unit-node/shims/kit-ability.js @@ -1,7 +1,14 @@ // Minimal runtime shapes; types are erased by transpilation. +// Observers are kept so a test can fire the OS callbacks (uncaught exception, +// unhandled rejection) the way the runtime would. +const lastObservers = {}; const errorManager = { - on: () => 1, - off: () => {} + on: (type, observer) => { + lastObservers[type] = observer; + return 1; + }, + off: () => {}, + lastObservers }; const bundleManager = { BundleFlag: { GET_BUNDLE_INFO_WITH_APPLICATION: 1 },