From cd92a56e7214bb9fae0fad93e0056a38e81e7c9c Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 3 Oct 2026 19:14:51 -0700 Subject: [PATCH 01/12] feat(rum): add sessionOnError to keep unsampled sessions that report an error A session missed by sessionSampleRate can now be kept in memory and uploaded only when it reports an error of its own: - RumConfigurationBuilder.setSessionOnError (default false) and the remote configuration key rum.sessionOnError (absent keeps the init value). A rate the beforeSampling hook sets to 0 turns it off. - Such a session runs its scopes as usual, but writes through a withholding writer: a rolling 60 s buffer (64 KiB / 200 detail events, 50 views), eviction by tier (successful requests first, errors last and newest first), released views oldest first, then errors, then the rest. - The first error that survives the event mapper releases the buffer after a per-session jitter of up to 3 s; the window freezes when the release is scheduled. A flush (backgrounding, Flashcat.flush, a crash policy exit) releases a scheduled release at once; a terminating ArkTS crash and setForcedSession release immediately. - A session that ends without an error is discarded, including the late writes of its still-draining views. A refused consent drops the buffer. - View documents of such sessions carry session.sampled_for_error and report _dd.configuration.session_sample_rate 0. - The session counts as sampled for crash attribution, and its views are still snapshotted locally, so a native crash or freeze reported on the next launch is delivered with its last view. - Remote configuration: rate 0 does not stop a session the switch kept while the switch stays on; a dark session drawn at rate 0 is redrawn when the switch turns on. --- .../src/main/ets/RumConfiguration.ets | 38 +- .../src/main/ets/internal/RumFeature.ets | 4 +- .../internal/assembly/RumEventAssembler.ets | 22 +- .../internal/monitor/DefaultRumMonitor.ets | 12 +- .../remoteconfig/RemoteConfigController.ets | 15 +- .../remoteconfig/RemoteConfigStore.ets | 25 +- .../internal/scope/RumApplicationScope.ets | 99 ++- .../ets/internal/scope/RumSessionScope.ets | 58 +- .../main/ets/internal/scope/RumViewScope.ets | 8 +- .../ets/internal/scope/WithheldEvents.ets | 426 ++++++++++ flashcat-rum/src/test/List.test.ets | 2 + flashcat-rum/src/test/SessionOnError.test.ets | 771 ++++++++++++++++++ 12 files changed, 1427 insertions(+), 53 deletions(-) create mode 100644 flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets create mode 100644 flashcat-rum/src/test/SessionOnError.test.ets diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index f9c2533..a09d60e 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,34 @@ 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 nothing 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, actions, resources and errors) + * is uploaded, after a delay of up to 3 s that spreads a fleet-wide failure, + * and the rest of the session is collected normally. A session that ends + * without an error is thrown away with everything it held. An uncaught ArkTS + * exception that terminates the process releases what was held before the + * process exits; 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 +192,8 @@ export class RumConfigurationBuilder { this.eventMapper, this.trackErrors, this.remoteConfigurationEnabled, - this.beforeSampling + this.beforeSampling, + this.sessionOnError ); } } diff --git a/flashcat-rum/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index d2f8ac4..5661a62 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -274,10 +274,12 @@ 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(); } } } 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..0f21893 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(); } @@ -197,6 +198,15 @@ 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(); + } + /** * 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 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..6908395 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,7 @@ 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'; const MAX_RATE: number = 100; /** @@ -120,6 +127,9 @@ 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; const serialized: string = JSON.stringify(record); @@ -198,7 +208,8 @@ export class RemoteConfigStore { 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..8c948cd 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,13 @@ export class RumApplicationScope implements RumScope { return this.session?.getSessionId(); } + /** The app may be going away: a release the session has earned leaves now. */ + releaseScheduledWithheld(): void { + if (this.session !== null) { + this.session.releaseScheduledWithheld(); + } + } + /** * 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 +238,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 +249,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 +259,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 +296,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 +328,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 +350,17 @@ 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 } 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)) { this.stopCurrentSession(nowMs); } } @@ -341,13 +373,21 @@ export class RumApplicationScope implements RumScope { * lost a fair draw: only the first is owed another chance when collection is * switched back on. */ - 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): 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); + } + return activation === ACTIVATION_IMMEDIATE; } // 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); + return (drawnRate === 0 && (nextRate > 0 || nextOnError)) + || (nextRate > 0 && activation === ACTIVATION_IMMEDIATE); } /** @@ -362,7 +402,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/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index d0a307c..873c9e8 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,7 +158,8 @@ 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; } @@ -181,6 +202,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 +252,28 @@ 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(); + } + } + + /** 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(); + } + } + /** 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 +331,15 @@ export class RumSessionScope implements RumScope { return this.sampled; } + isSampledOnError(): boolean { + return this.sampledOnError; + } + + /** 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..36778d1 --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -0,0 +1,426 @@ +import { FeatureScope, FlashcatContext, EventWriter, SdkCore, TrackingConsent, FlashcatLog } from '@flashcatcloud/core'; +import { util } from '@kit.ArkTS'; + +/** + * 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 +} + +class HeldEvent { + readonly event: Record; + /** '' 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(event: Record, viewId: string, timeMs: number, bytes: number, tier: EvictionTier) { + this.event = event; + this.viewId = viewId; + this.timeMs = timeMs; + this.bytes = bytes; + this.tier = tier; + } +} + +/** + * 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; + + /** Returns false when the event was dropped rather than held. */ + hold(event: Record, bytes: number, nowMs: number): boolean { + const viewId: string = WithheldEventBuffer.viewIdOf(event); + if (event['type'] === 'view') { + // Upsert: a view document is cumulative, so the latest supersedes the rest. + // Deleting first moves it to the back, so the oldest entry is the least + // recently updated one. + this.views.delete(viewId); + this.views.set(viewId, event); + // 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. + const date: number = typeof event['date'] === 'number' ? event['date'] as number : 0; + 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: Record = this.views.get(oldest) as Record; + 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(event, 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. + */ + release(writer: EventWriter, nowMs: number): void { + this.prune(nowMs); + const releasable: HeldEvent[] = this.details.filter((held: HeldEvent) => + held.viewId.length === 0 || this.views.has(held.viewId)); + const ordered: Array> = []; + this.views.forEach((view: Record) => { + ordered.push(view); + }); + ordered.sort((left: Record, right: Record) => + (left['date'] as number) - (right['date'] as number)); + const viewCount: number = ordered.length; + for (const held of releasable) { + if (held.tier === EvictionTier.LAST_RESORT) { + ordered.push(held.event); + } + } + for (const held of releasable) { + if (held.tier !== EvictionTier.LAST_RESORT) { + ordered.push(held.event); + } + } + for (let i: number = 0; i < ordered.length; i++) { + // One batch, uploaded as soon as the last event is in. + writer.write(ordered[i], i === ordered.length - 1); + } + FlashcatLog.d(`rum.withheld: released views=${viewCount} events=${releasable.length} ` + + `dropped=${this.droppedCount + this.details.length - releasable.length} bytes=${this.bytes}`); + this.clear(); + } + + 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: Record, 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 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. + * 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; + + 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) => { + callback(context, new WithholdingWriter(this, writer)); + }); + } + + sendEvent(event: Record): void { + this.delegate.sendEvent(event); + } + + 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(); + } + } + + /** The session ended: what it earned goes out, the rest is thrown away for good. */ + settle(): void { + if (this.buffer === null) { + return; + } + if (this.hasError) { + this.releaseNow(); + return; + } + this.cancelTimer(); + this.buffer = null; + this.discarded = true; + FlashcatLog.d(`rum.withheld: discarded session=${this.sessionId} (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, and what was collected + // before goes with it, exactly as the core wipes collected batches. + buffer.clear(); + return false; + } + // Views stay out of the byte budget, so only details pay for the measurement. + const bytes: number = event['type'] === 'view' ? 0 : ENCODER.encodeInto(JSON.stringify(event)).byteLength; + if (this.isOwnError(event)) { + this.hasError = true; + this.scheduleRelease(buffer); + if (bytes > WITHHELD_BUFFER_BYTES_LIMIT) { + // The session has earned its release; an error too large for the buffer + // goes out on its own rather than evicting the history it explains. + return writer.write(event, true); + } + } + return buffer.hold(event, 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()); + }); + } + + 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..5871dd9 --- /dev/null +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -0,0 +1,771 @@ +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 { RumConfigurationBuilder } from '../main/ets/RumConfiguration'; + +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; + + registerFeature(_feature: Feature): void {} + getFeature(_featureName: string): FeatureScope | null { + 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[] = []; + + withWriteContext(callback: (context: FlashcatContext, writer: EventWriter) => void): void { + callback(testContext(), new CapturingWriter(this)); + } + + sendEvent(_event: Record): void {} + + 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 { + this.scope.written.push(event); + this.scope.flushes.push(forceFlush === true); + return true; + } +} + +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 }; +} + +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('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('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('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-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); + }); + }); + + 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; + buffer.hold(view('v1', now), 50, now); + const first: Record = detail('error', 'v1', 'first-error'); + buffer.hold(first, 2000, now); + buffer.hold(resource(500), 4000, now); + for (let i: number = 0; i < 40; i++) { + buffer.hold(resource(200), 2000, now + i); + } + buffer.hold(detail('action', 'v1', 'act'), 2000, now + 50); + const out: Array> = released(buffer, now + 60); + expect(out.indexOf(first)).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; + buffer.hold(view('v1', now), 50, now); + for (let i: number = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT + 5; i++) { + buffer.hold(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); + expect(errors[0]['tag']).assertEqual('e0'); + }); + + it('keepsOnlyTheLastMinuteUntilAReleaseFreezesTheWindow', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + buffer.hold(view('v1', now), 50, now); + buffer.hold(detail('action', 'v1', 'old'), 10, now); + buffer.hold(detail('action', 'v1', 'recent'), 10, now + WITHHELD_BUFFER_DURATION_MS); + buffer.hold(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(); + frozen.hold(view('v1', now), 50, now); + frozen.hold(detail('action', 'v1', 'before'), 10, now); + frozen.hold(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++) { + buffer.hold(view(`v${i}`, now + i), 50, now + i); + buffer.hold(detail('action', `v${i}`), 10, now + i); + } + // A late update of an old view must not make it current again. + buffer.hold(view('v20', now + 20), 50, now + 100); + buffer.hold(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('dropsADetailTooLargeToEverBeReleasedAndKeepsTheHistory', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const now: number = 1_000_000; + const buffer: WithheldEventBuffer = new WithheldEventBuffer(); + buffer.hold(view('v1', now), 50, now); + buffer.hold(detail('action', 'v1', 'kept'), 10, now); + expect(buffer.hold(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('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('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); + 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(); + } + }); + }); +} From c1811c19073c6731dafb40a00c3b9b61f257fe6c Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 3 Oct 2026 19:14:54 -0700 Subject: [PATCH 02/12] test(demo): add sessionOnError e2e scenarios Launch parameter `soe 1` enables sessionOnError; scenarios soe, soe-quiet, soe-secret (error dropped by the mapper) and native-crash drive the release, discard, mapper and crash paths from the command line. The demo mapper now also drops error events whose message contains "secret". --- entry/src/main/ets/common/DemoSdk.ets | 15 ++++++- .../main/ets/entryability/EntryAbility.ets | 2 + entry/src/main/ets/pages/Index.ets | 39 ++++++++++++++++++- 3 files changed, 53 insertions(+), 3 deletions(-) diff --git a/entry/src/main/ets/common/DemoSdk.ets b/entry/src/main/ets/common/DemoSdk.ets index f14af5c..991ad97 100644 --- a/entry/src/main/ets/common/DemoSdk.ets +++ b/entry/src/main/ets/common/DemoSdk.ets @@ -52,10 +52,11 @@ 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): void { if (DemoSdk.initialized) { return; } @@ -93,6 +94,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); @@ -149,7 +151,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 +182,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..8df2af4 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -119,6 +119,8 @@ 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'); 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..a6d62f1 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -108,8 +108,9 @@ 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'; DemoSdk.init(this.context, this.useProd, this.customEndpoint, false, trackErrors, pacing, - rcMode, rcInitRate); + rcMode, rcInitRate, sessionOnError); } catch (e) { this.append(e instanceof Error ? e.message : 'SDK initialization failed'); return; @@ -188,6 +189,13 @@ 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(() => { @@ -232,6 +240,35 @@ 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. + */ + 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 { + 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; From 20b2e69dcc64d5b0e1168c6bb90f959eea2ad022 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 4 Oct 2026 01:15:19 -0700 Subject: [PATCH 03/12] fix(rum): keep a withheld error whose view is not held, and settle sessionOnError edge cases - Release every held detail whether or not its view document is in the buffer: views only decide the order. An error raised before the first view, with view tracking off, or whose view document the event mapper dropped was otherwise lost together with the release it triggered. - An `immediate` remote change ends a sampled session only when the rate moved; a change to sessionOnError alone no longer ends a session the rate kept. - SDK stop drops what a withheld session holds and cancels a release still waiting on its jitter, so it never fires through a stopped core (the pre-stop flush that would release it is skipped under a refused consent). - Remove the unused WithholdingFeatureScope.isWithholding(). --- .../src/main/ets/internal/RumFeature.ets | 1 + .../internal/monitor/DefaultRumMonitor.ets | 9 ++ .../internal/scope/RumApplicationScope.ets | 11 +- .../ets/internal/scope/RumSessionScope.ets | 7 + .../ets/internal/scope/WithheldEvents.ets | 25 ++-- flashcat-rum/src/test/SessionOnError.test.ets | 139 ++++++++++++++++++ 6 files changed, 181 insertions(+), 11 deletions(-) diff --git a/flashcat-rum/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index 5661a62..e9da50d 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -228,6 +228,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 diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index 0f21893..bfd4f2e 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -73,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) { diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 8c948cd..ea5f0d5 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -228,6 +228,13 @@ export class RumApplicationScope implements RumScope { } } + /** SDK stop: nothing held may be written through the core afterwards. */ + stopWithheld(): void { + if (this.session !== null) { + this.session.stopWithheld(); + } + } + /** * Draws a new session under the settings that apply RIGHT NOW: what the * console published if anything, the init value otherwise, and finally @@ -382,7 +389,9 @@ export class RumApplicationScope implements RumScope { // next draw keeps, it does not exempt a session already collected in full. return !(sampledOnError && nextOnError); } - return activation === ACTIVATION_IMMEDIATE; + // `immediate` re-decides on a rate that moved; a change to the switch + // alone says nothing about a session the rate already kept. + return activation === ACTIVATION_IMMEDIATE && nextRate !== drawnRate; } // 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. diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index 873c9e8..d8b6b4c 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -267,6 +267,13 @@ export class RumSessionScope implements RumScope { } } + /** 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) { diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index 36778d1..264e5b9 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -129,11 +129,13 @@ export class WithheldEventBuffer { * 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): void { this.prune(nowMs); - const releasable: HeldEvent[] = this.details.filter((held: HeldEvent) => - held.viewId.length === 0 || this.views.has(held.viewId)); const ordered: Array> = []; this.views.forEach((view: Record) => { ordered.push(view); @@ -141,12 +143,12 @@ export class WithheldEventBuffer { ordered.sort((left: Record, right: Record) => (left['date'] as number) - (right['date'] as number)); const viewCount: number = ordered.length; - for (const held of releasable) { + for (const held of this.details) { if (held.tier === EvictionTier.LAST_RESORT) { ordered.push(held.event); } } - for (const held of releasable) { + for (const held of this.details) { if (held.tier !== EvictionTier.LAST_RESORT) { ordered.push(held.event); } @@ -155,8 +157,8 @@ export class WithheldEventBuffer { // One batch, uploaded as soon as the last event is in. writer.write(ordered[i], i === ordered.length - 1); } - FlashcatLog.d(`rum.withheld: released views=${viewCount} events=${releasable.length} ` + - `dropped=${this.droppedCount + this.details.length - releasable.length} bytes=${this.bytes}`); + FlashcatLog.d(`rum.withheld: released views=${viewCount} events=${this.details.length} ` + + `dropped=${this.droppedCount} bytes=${this.bytes}`); this.clear(); } @@ -303,10 +305,6 @@ export class WithholdingFeatureScope implements FeatureScope { this.delegate.sendEvent(event); } - 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; @@ -326,6 +324,13 @@ export class WithholdingFeatureScope implements FeatureScope { } } + /** SDK stop: nothing held may reach the core any more, earned or not. */ + stop(): void { + this.cancelTimer(); + this.buffer = null; + this.discarded = true; + } + /** The session ended: what it earned goes out, the rest is thrown away for good. */ settle(): void { if (this.buffer === null) { diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 5871dd9..042cbdd 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -17,6 +17,8 @@ import { LastViewSnapshot } from '../main/ets/internal/LastViewSnapshot'; import { RumFeature } from '../main/ets/internal/RumFeature'; import { BeforeSamplingContext } from '../main/ets/RumTypes'; import { RumConfigurationBuilder } from '../main/ets/RumConfiguration'; +import { DefaultRumMonitor } from '../main/ets/internal/monitor/DefaultRumMonitor'; +import { RumErrorSource } from '../main/ets/RumTypes'; const MIN_15: number = 15 * 60 * 1000; @@ -404,6 +406,41 @@ export default function sessionOnErrorTests(): void { 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('aFlushKeepsWhatHasNotYetEarnedItsRelease', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const scope: CapturingScope = new CapturingScope(); @@ -420,6 +457,86 @@ export default function sessionOnErrorTests(): void { }); }); + 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(); + buffer.hold(detail('error', 'unknown-view', 'error'), 10, now); + buffer.hold(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(); + buffer.hold(view('old', now), 50, now); + buffer.hold(view('current', now + 1), 50, now + 1); + buffer.hold(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-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 scope: CapturingScope = new CapturingScope(); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(new FakeCore(), scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build()); + monitor.stopKeepAlive(); + monitor.startView('home', 'home'); + monitor.addError('boom', RumErrorSource.SOURCE); + monitor.getCurrentSessionId((id: string | undefined): void => { + delays.push(computeReleaseDelay(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 => { @@ -708,6 +825,28 @@ export default function sessionOnErrorTests(): void { 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('decidesTheRunningSessionByTheStopTree', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { // Kept by the switch, rate 0 with the switch on: the switch's ordinary setting. From 01434976ccd5f77de8ffe31d53808d483526a690 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 02:42:45 -0700 Subject: [PATCH 04/12] fix(rum): forget withheld events on consent revocation and hold them serialized A refused tracking consent wiped the batches on disk and the last-view snapshot, but never reached the events a sessionOnError session still held in memory: they were cleared only when a RUM write happened while consent was refused. Consent refused and granted again with no write in between let a later error release the history collected before the refusal; the opposite timing (an error scheduled, then a refusal before its jitter ran out) detached the buffer through a writer that rejects everything, after which the session wrote straight through with its error gone. The core now announces the refusal to RUM, and the session forgets what it held, cancels the pending release and goes back to waiting for an error. The buffer now keeps the serialized form of each event, measured once at the write, instead of the live object: the app may keep changing the attribute values it passed in, and a collected session uploads what the values were at the write, so an on-error session must release the same. The held size is now exactly the released size. A switch-only change under `immediate` activation no longer redraws a dark session that lost a fair nonzero draw: the dark branch now requires the rate to have moved, as the sampled branch already did. --- .../src/main/ets/internal/FlashcatCore.ets | 3 + flashcat-core/src/test/ConsentPolicy.test.ets | 20 ++ .../src/main/ets/RumConfiguration.ets | 5 +- .../src/main/ets/internal/RumFeature.ets | 7 + .../internal/monitor/DefaultRumMonitor.ets | 9 + .../internal/scope/RumApplicationScope.ets | 11 +- .../ets/internal/scope/RumSessionScope.ets | 7 + .../ets/internal/scope/WithheldEvents.ets | 97 ++++++--- flashcat-rum/src/test/SessionOnError.test.ets | 204 +++++++++++++++--- 9 files changed, 303 insertions(+), 60 deletions(-) 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/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-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index a09d60e..e96d490 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -160,8 +160,9 @@ export class RumConfigurationBuilder { * Such a session is collected in memory and nothing 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, actions, resources and errors) - * is uploaded, after a delay of up to 3 s that spreads a fleet-wide failure, - * and the rest of the session is collected normally. A session that ends + * 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. An uncaught ArkTS * exception that terminates the process releases what was held before the * process exits; a native crash or a freeze, reported on the next launch, is diff --git a/flashcat-rum/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index e9da50d..9de42c0 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -282,6 +282,13 @@ export class RumFeature implements Feature, FeatureEventReceiver { 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(); + } } } diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index bfd4f2e..de23ac2 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -216,6 +216,15 @@ export class DefaultRumMonitor implements RumMonitor { 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 diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index ea5f0d5..d3e42af 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -235,6 +235,13 @@ export class RumApplicationScope implements RumScope { } } + /** 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 @@ -395,8 +402,10 @@ export class RumApplicationScope implements RumScope { } // 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. + // `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 && activation === ACTIVATION_IMMEDIATE); + || (nextRate > 0 && nextRate !== drawnRate && activation === ACTIVATION_IMMEDIATE); } /** diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index d8b6b4c..fa45847 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -281,6 +281,13 @@ export class RumSessionScope implements RumScope { } } + /** 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)) { diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index 264e5b9..89180dc 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -33,16 +33,22 @@ enum EvictionTier { 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 event: Record; + 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(event: Record, viewId: string, timeMs: number, bytes: number, tier: EvictionTier) { - this.event = event; + constructor(line: string, viewId: string, timeMs: number, bytes: number, tier: EvictionTier) { + this.line = line; this.viewId = viewId; this.timeMs = timeMs; this.bytes = bytes; @@ -50,6 +56,17 @@ class HeldEvent { } } +class HeldView { + readonly line: string; + /** The view's start date, which orders the release. */ + readonly date: number; + + constructor(line: string, date: number) { + this.line = line; + this.date = date; + } +} + /** * 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 @@ -58,7 +75,7 @@ class HeldEvent { */ export class WithheldEventBuffer { /** Latest document per view, in the order they were last updated. */ - private views: Map> = new Map>(); + private views: Map = new Map(); private details: HeldEvent[] = []; private bytes: number = 0; private droppedCount: number = 0; @@ -67,26 +84,30 @@ export class WithheldEventBuffer { /** When the release was scheduled; 0 while none is. Freezes the window, see prune. */ private frozenAtMs: number = 0; - /** Returns false when the event was dropped rather than held. */ - hold(event: Record, bytes: number, nowMs: number): boolean { + /** + * 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 latest supersedes the rest. // Deleting first moves it 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; this.views.delete(viewId); - this.views.set(viewId, event); + this.views.set(viewId, new HeldView(line, date)); // 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. - const date: number = typeof event['date'] === 'number' ? event['date'] as number : 0; 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: Record = this.views.get(oldest) as Record; + 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. @@ -102,7 +123,7 @@ export class WithheldEventBuffer { this.droppedCount += 1; return false; } - this.details.push(new HeldEvent(event, viewId, nowMs, bytes, WithheldEventBuffer.tierOf(event))); + 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) { @@ -136,34 +157,33 @@ export class WithheldEventBuffer { */ release(writer: EventWriter, nowMs: number): void { this.prune(nowMs); - const ordered: Array> = []; - this.views.forEach((view: Record) => { - ordered.push(view); + const views: HeldView[] = []; + this.views.forEach((view: HeldView) => { + views.push(view); }); - ordered.sort((left: Record, right: Record) => - (left['date'] as number) - (right['date'] as number)); - const viewCount: number = ordered.length; + 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.event); + ordered.push(held.line); } } for (const held of this.details) { if (held.tier !== EvictionTier.LAST_RESORT) { - ordered.push(held.event); + ordered.push(held.line); } } for (let i: number = 0; i < ordered.length; i++) { // One batch, uploaded as soon as the last event is in. - writer.write(ordered[i], i === ordered.length - 1); + writer.write(JSON.parse(ordered[i]) as Record, i === ordered.length - 1); } - FlashcatLog.d(`rum.withheld: released views=${viewCount} events=${this.details.length} ` + - `dropped=${this.droppedCount} bytes=${this.bytes}`); + FlashcatLog.d(`rum.withheld: released views=${views.length} details=${this.details.length} ` + + `dropped=${this.droppedCount} detail_bytes=${this.bytes}`); this.clear(); } clear(): void { - this.views = new Map>(); + this.views = new Map(); this.details = []; this.bytes = 0; this.droppedCount = 0; @@ -190,7 +210,7 @@ export class WithheldEventBuffer { withDetail.add(held.viewId); } const stale: string[] = []; - this.views.forEach((_view: Record, id: string) => { + this.views.forEach((_view: HeldView, id: string) => { if (id !== this.currentViewId && !withDetail.has(id)) { stale.push(id); } @@ -277,7 +297,9 @@ export function computeReleaseDelay(sessionId: string): number { * - 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. - * After a release the session writes straight through, like any collected one. + * 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; @@ -331,6 +353,22 @@ export class WithholdingFeatureScope implements FeatureScope { this.discarded = true; } + /** + * 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 { if (this.buffer === null) { @@ -356,13 +394,14 @@ export class WithholdingFeatureScope implements FeatureScope { return writer.write(event, forceFlush); } if (this.core.getTrackingConsent() === TrackingConsent.NOT_GRANTED) { - // Every write is dropped under a refused consent, and what was collected - // before goes with it, exactly as the core wipes collected batches. - buffer.clear(); + // 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(JSON.stringify(event)).byteLength; + const bytes: number = event['type'] === 'view' ? 0 : ENCODER.encodeInto(line).byteLength; if (this.isOwnError(event)) { this.hasError = true; this.scheduleRelease(buffer); @@ -372,7 +411,7 @@ export class WithholdingFeatureScope implements FeatureScope { return writer.write(event, true); } } - return buffer.hold(event, bytes, Date.now()); + return buffer.hold(event, line, bytes, Date.now()); } private isOwnError(event: Record): boolean { diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 042cbdd..7c6d637 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -195,6 +195,11 @@ 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) => { @@ -441,6 +446,48 @@ export default function sessionOnErrorTests(): void { 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(); @@ -490,8 +537,8 @@ export default function sessionOnErrorTests(): void { TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const now: number = 1_000_000; const buffer: WithheldEventBuffer = new WithheldEventBuffer(); - buffer.hold(detail('error', 'unknown-view', 'error'), 10, now); - buffer.hold(detail('action', 'unknown-view', 'act'), 10, now); + 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'); }); @@ -500,9 +547,9 @@ export default function sessionOnErrorTests(): void { TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const now: number = 1_000_000; const buffer: WithheldEventBuffer = new WithheldEventBuffer(); - buffer.hold(view('old', now), 50, now); - buffer.hold(view('current', now + 1), 50, now + 1); - buffer.hold(detail('error', 'current', 'error'), 10, now + 2); + 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); @@ -612,6 +659,47 @@ export default function sessionOnErrorTests(): void { 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('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 => { @@ -619,16 +707,16 @@ export default function sessionOnErrorTests(): void { TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const buffer: WithheldEventBuffer = new WithheldEventBuffer(); const now: number = 1_000_000; - buffer.hold(view('v1', now), 50, now); + hold(buffer, view('v1', now), 50, now); const first: Record = detail('error', 'v1', 'first-error'); - buffer.hold(first, 2000, now); - buffer.hold(resource(500), 4000, now); + hold(buffer, first, 2000, now); + hold(buffer, resource(500), 4000, now); for (let i: number = 0; i < 40; i++) { - buffer.hold(resource(200), 2000, now + i); + hold(buffer, resource(200), 2000, now + i); } - buffer.hold(detail('action', 'v1', 'act'), 2000, now + 50); + hold(buffer, detail('action', 'v1', 'act'), 2000, now + 50); const out: Array> = released(buffer, now + 60); - expect(out.indexOf(first)).assertEqual(1); // right behind the view + 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); @@ -642,24 +730,29 @@ export default function sessionOnErrorTests(): void { TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const buffer: WithheldEventBuffer = new WithheldEventBuffer(); const now: number = 1_000_000; - buffer.hold(view('v1', now), 50, now); + hold(buffer, view('v1', now), 50, now); for (let i: number = 0; i < WITHHELD_BUFFER_EVENTS_LIMIT + 5; i++) { - buffer.hold(detail('error', 'v1', `e${i}`), 10, now + 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); - expect(errors[0]['tag']).assertEqual('e0'); + // 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(); - buffer.hold(view('v1', now), 50, now); - buffer.hold(detail('action', 'v1', 'old'), 10, now); - buffer.hold(detail('action', 'v1', 'recent'), 10, now + WITHHELD_BUFFER_DURATION_MS); - buffer.hold(detail('error', 'v1', 'error'), 10, now + WITHHELD_BUFFER_DURATION_MS + 1); + 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); @@ -667,9 +760,9 @@ export default function sessionOnErrorTests(): void { // 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(); - frozen.hold(view('v1', now), 50, now); - frozen.hold(detail('action', 'v1', 'before'), 10, now); - frozen.hold(detail('error', 'v1', 'error'), 10, now + 1000); + 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); @@ -681,12 +774,12 @@ export default function sessionOnErrorTests(): void { 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++) { - buffer.hold(view(`v${i}`, now + i), 50, now + i); - buffer.hold(detail('action', `v${i}`), 10, now + 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. - buffer.hold(view('v20', now + 20), 50, now + 100); - buffer.hold(detail('error', `v${WITHHELD_BUFFER_VIEWS_LIMIT + 9}`, 'error'), 10, now + 101); + 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(); @@ -703,9 +796,9 @@ export default function sessionOnErrorTests(): void { TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const now: number = 1_000_000; const buffer: WithheldEventBuffer = new WithheldEventBuffer(); - buffer.hold(view('v1', now), 50, now); - buffer.hold(detail('action', 'v1', 'kept'), 10, now); - expect(buffer.hold(detail('action', 'v1', 'huge'), WITHHELD_BUFFER_BYTES_LIMIT + 1, now)).assertFalse(); + 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); }); @@ -724,6 +817,29 @@ export default function sessionOnErrorTests(): void { 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('spreadsReleasesAcrossTheJitterWindow', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { let min: number = WITHHELD_BUFFER_RELEASE_MAX_DELAY_MS; @@ -847,6 +963,38 @@ export default function sessionOnErrorTests(): void { 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('decidesTheRunningSessionByTheStopTree', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { // Kept by the switch, rate 0 with the switch on: the switch's ordinary setting. From 1e87c02d4d300aafec5f94a662900726ca16142c Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 03:27:54 -0700 Subject: [PATCH 05/12] fix(rum): decide a withheld session's end after the write that ends it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An app's event mapper runs inside the write of the event it maps, and may end the session from there — on an authentication error, say. For a sessionOnError session the end came before the error reached the buffer: the buffer was thrown away as un-errored, and the error the mapper had just kept was then refused as a straggler of a discarded session. When a write is in progress, the end now waits for it to land and decides on what it left behind. An error too large for the buffer earns the release only once storage has accepted it. A history uploaded for an error the backend never receives is a session nobody can explain. --- .../src/main/ets/RumConfiguration.ets | 3 +- .../ets/internal/scope/WithheldEvents.ets | 38 ++++++++++-- flashcat-rum/src/test/SessionOnError.test.ets | 58 +++++++++++++++++++ 3 files changed, 92 insertions(+), 7 deletions(-) diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index e96d490..21c8f87 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -159,7 +159,8 @@ export class RumConfigurationBuilder { * * Such a session is collected in memory and nothing 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, actions, resources and errors) + * minute leading up to that error (its views, and up to 200 actions, resources + * and errors within 64 KiB, the least telling 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 diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index 89180dc..03d2776 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -310,6 +310,9 @@ export class WithholdingFeatureScope implements FeatureScope { 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; @@ -319,7 +322,16 @@ export class WithholdingFeatureScope implements FeatureScope { withWriteContext(callback: (context: FlashcatContext, writer: EventWriter) => void): void { this.delegate.withWriteContext((context: FlashcatContext, writer: EventWriter) => { - callback(context, new WithholdingWriter(this, writer)); + 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(); + } + } }); } @@ -374,6 +386,13 @@ export class WithholdingFeatureScope implements FeatureScope { if (this.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; @@ -403,13 +422,20 @@ export class WithholdingFeatureScope implements FeatureScope { // 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)) { - this.hasError = true; - this.scheduleRelease(buffer); if (bytes > WITHHELD_BUFFER_BYTES_LIMIT) { - // The session has earned its release; an error too large for the buffer - // goes out on its own rather than evicting the history it explains. - return writer.write(event, true); + // 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()); } diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 7c6d637..9679a71 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -94,6 +94,8 @@ class FakeCore implements SdkCore { 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)); @@ -114,6 +116,9 @@ class CapturingWriter implements EventWriter { } 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; @@ -368,6 +373,36 @@ export default function sessionOnErrorTests(): void { 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(); @@ -840,6 +875,29 @@ export default function sessionOnErrorTests(): void { 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; From 373c571bcea9bcda2d4f4f8c2d73cf929583976a Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 07:30:18 -0700 Subject: [PATCH 06/12] fix(rum): keep a deferred rate deferred when the switch changes beside it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A rate the console announced for the next session, republished under `immediate` beside a changed `sessionOnError`, was re-decided as if the rate had moved in that publication: a collected session ended, and a session kept on error threw its history away. The settings guard is a pair now, so a changed switch reached the decision that previously stopped at the rate alone. The decision now asks whether this publication is the one that moved the rate — different from both the previous resolution and the draw — before `immediate` re-decides. The remote configuration cache moves to format 2. An entry written by an SDK that never read `session_on_error` carries a validator for a response this SDK has not seen; a 304 against it would keep the switch unknown for as long as the console left the configuration alone. --- .../src/main/ets/RumConfiguration.ets | 2 +- .../remoteconfig/RemoteConfigStore.ets | 8 +- .../internal/scope/RumApplicationScope.ets | 18 +++- flashcat-rum/src/test/SessionOnError.test.ets | 99 +++++++++++++++++++ 4 files changed, 119 insertions(+), 8 deletions(-) diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index 21c8f87..5e2a89a 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -157,7 +157,7 @@ export class RumConfigurationBuilder { * 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 nothing is uploaded until it + * 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 telling dropped first when there are more) diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index 6908395..bb58ed7 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -176,13 +176,17 @@ 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. Adding a + * field is such a change: an entry written without it carries a validator + * for a response this SDK never read, and the 304 that validator earns + * would keep the field unknown for as long as the console leaves the + * configuration alone. Format 2 added `session_on_error`. */ static buildStoreKey(context: FlashcatContext, host: string, applicationId: string): string { const parts: string[] = [ RemoteConfigStore.hostOf(host), applicationId, context.service, context.env, context.version ]; - return `_fc_rc_1_${parts.join('|')}`; + return `_fc_rc_2_${parts.join('|')}`; } private static hostOf(url: string): string { diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index d3e42af..10582c3 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -372,9 +372,14 @@ export class RumApplicationScope implements RumScope { || 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(), resolved.sessionSampleRate, activation, - this.session.isSampledOnError(), resolved.sessionOnError)) { + this.session.isSampledOnError(), resolved.sessionOnError, rateMoved)) { this.stopCurrentSession(nowMs); } } @@ -385,10 +390,13 @@ 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, - sampledOnError: boolean = false, nextOnError: boolean = false): boolean { + sampledOnError: boolean = false, nextOnError: boolean = false, + rateMoved: boolean = nextRate !== drawnRate): boolean { if (sampled) { if (nextRate === 0) { // A session the switch kept is left running while the switch stays on; @@ -398,14 +406,14 @@ export class RumApplicationScope implements RumScope { } // `immediate` re-decides on a rate that moved; a change to the switch // alone says nothing about a session the rate already kept. - return activation === ACTIVATION_IMMEDIATE && nextRate !== drawnRate; + return activation === ACTIVATION_IMMEDIATE && rateMoved; } // 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. // `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 && nextRate !== drawnRate && activation === ACTIVATION_IMMEDIATE); + || (nextRate > 0 && rateMoved && activation === ACTIVATION_IMMEDIATE); } /** diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 9679a71..d43507f 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -827,6 +827,35 @@ export default function sessionOnErrorTests(): void { } }); + 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('dropsADetailTooLargeToEverBeReleasedAndKeepsTheHistory', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const now: number = 1_000_000; @@ -1053,6 +1082,76 @@ export default function sessionOnErrorTests(): void { 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('aCacheWrittenByAnSdkWithoutTheSwitchIsNotReadBack', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // An entry of the previous format 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. + const core: FakeCore = new FakeCore(); + const key: string = RemoteConfigStore.buildStoreKey(core.getContext(), 'https://intake.example.com', 'app-1'); + const legacy: string = key.replace('_fc_rc_2_', '_fc_rc_1_'); + expect(legacy === key).assertFalse(); + core.writeSetting(`${legacy}.values`, '{"rate":0,"version":10,"etag":"\\"v10\\"","ttl":300,"refresh_on_foreground":false}'); + expect(newStore(core).read()).assertNull(); + }); + it('decidesTheRunningSessionByTheStopTree', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { // Kept by the switch, rate 0 with the switch on: the switch's ordinary setting. From 37789d0f8fc210a37b7c16d99774740483253ae3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 07:44:06 -0700 Subject: [PATCH 07/12] fix(rum): release a replayed crash at once and keep the newest withheld view document A crash reaches a live session only as a replay of a process that already died, and the crash feature drops its own copy once RUM has taken it. In a sessionOnError session that copy sat in memory behind the jitter, and a process that died before the jitter ran out lost the crash for good. Crashes are spread across the fleet by app launches, not by a jitter, so a withheld session releases on one at once. A view document is assembled before its mapper runs; a mapper that reports into the SDK has the next version written first, and the buffer then replaced it with the older document. The newest document version now wins whatever order they arrive in. The remote configuration entry carries its format so an entry written by an SDK that never read `session_on_error` keeps its rate but retires its validator: the next request asks for the whole body instead of earning a 304 that would leave the switch unknown. The key no longer changes, so a stored emergency stop stays in force across the upgrade. --- .../remoteconfig/RemoteConfigStore.ets | 25 +++-- .../ets/internal/scope/WithheldEvents.ets | 75 +++++++++---- flashcat-rum/src/test/SessionOnError.test.ets | 106 ++++++++++++++++-- 3 files changed, 169 insertions(+), 37 deletions(-) diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index bb58ed7..69369fa 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -71,6 +71,14 @@ 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; /** @@ -132,6 +140,7 @@ export class RemoteConfigStore { } 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; @@ -176,17 +185,15 @@ 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. Adding a - * field is such a change: an entry written without it carries a validator - * for a response this SDK never read, and the 304 that validator earns - * would keep the field unknown for as long as the console leaves the - * configuration alone. Format 2 added `session_on_error`. + * 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[] = [ RemoteConfigStore.hostOf(host), applicationId, context.service, context.env, context.version ]; - return `_fc_rc_2_${parts.join('|')}`; + return `_fc_rc_1_${parts.join('|')}`; } private static hostOf(url: string): string { @@ -203,9 +210,11 @@ 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]), diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index 03d2776..82960a1 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -60,10 +60,13 @@ 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) { + constructor(line: string, date: number, version: number) { this.line = line; this.date = date; + this.version = version; } } @@ -92,12 +95,18 @@ export class WithheldEventBuffer { 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 latest supersedes the rest. - // Deleting first moves it to the back, so the oldest entry is the least - // recently updated one. + // 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; - this.views.delete(viewId); - this.views.set(viewId, new HeldView(line, date)); + 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. @@ -262,6 +271,19 @@ export class WithheldEventBuffer { 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; + } + + /** @internal */ + static isCrash(event: Record): boolean { + const error: Object | undefined = event['error']; + return typeof error === 'object' && error !== null && (error as Record)['is_crash'] === true; + } + private static viewIdOf(event: Record): string { const view: Object | undefined = event['view']; if (typeof view !== 'object' || view === null) { @@ -421,22 +443,37 @@ export class WithholdingFeatureScope implements FeatureScope { 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)) { - 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; + if (!this.isOwnError(event)) { + return buffer.hold(event, line, bytes, Date.now()); + } + // A crash is released at once. It reaches a live session only as a replay + // of a process that already died, spread across the fleet by app launches + // rather than by a jitter — and the process that replays it may not live + // out a jitter either, while the crash feature has already dropped its copy. + const crash: boolean = WithheldEventBuffer.isCrash(event); + 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); + if (crash) { + this.releaseNow(); + } else { + this.scheduleRelease(buffer); + } + return true; + } + this.hasError = true; + if (crash) { + const held: boolean = buffer.hold(event, line, bytes, Date.now()); + this.releaseNow(); + return held; } + this.scheduleRelease(buffer); return buffer.hold(event, line, bytes, Date.now()); } diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index d43507f..50e0949 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -16,9 +16,9 @@ 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 { RumConfigurationBuilder } from '../main/ets/RumConfiguration'; +import { RumConfiguration, RumConfigurationBuilder } from '../main/ets/RumConfiguration'; import { DefaultRumMonitor } from '../main/ets/internal/monitor/DefaultRumMonitor'; -import { RumErrorSource } from '../main/ets/RumTypes'; +import { RumActionType, RumErrorSource } from '../main/ets/RumTypes'; const MIN_15: number = 15 * 60 * 1000; @@ -432,6 +432,34 @@ export default function sessionOnErrorTests(): void { expect(scope.ofType('action').length).assertEqual(1); }); + it('aCrashReportedAsAnErrorReleasesAtOnce', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A crash reaches a live session only as a replay of a process that + // already died; the crash feature drops its copy once reported. + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = onErrorApp(scope); + const now: number = Date.now(); + startView(app, now); + addAction(app, now + 1); + const crash: RumRawEvent = raw('addError', now + 2); + crash.errorMessage = 'replayed crash'; + crash.errorSource = 'source'; + crash.isCrash = true; + app.handleEvent(crash); + // Synchronous: nothing waits on a jitter. + expect(scope.ofType('error').length).assertEqual(1); + expect(scope.ofType('action').length).assertEqual(1); + expect(scope.written[0]['type']).assertEqual('view'); + // Negative control: an ordinary error waits for its jitter. + const quiet: CapturingScope = new CapturingScope(); + const app2: RumApplicationScope = onErrorApp(quiet); + startView(app2, now); + addError(app2, now + 1); + expect(quiet.written.length).assertEqual(0); + app2.releaseScheduledWithheld(); + expect(quiet.ofType('error').length).assertEqual(1); + }); + it('setForcedSessionReleasesWithoutAnError', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const scope: CapturingScope = new CapturingScope(); @@ -716,6 +744,28 @@ export default function sessionOnErrorTests(): void { 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(); @@ -856,6 +906,33 @@ export default function sessionOnErrorTests(): void { 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; @@ -1139,17 +1216,26 @@ export default function sessionOnErrorTests(): void { expect(found).assertTrue(); }); - it('aCacheWrittenByAnSdkWithoutTheSwitchIsNotReadBack', + it('aCacheWrittenByAnSdkWithoutTheSwitchKeepsItsRateButNotItsValidator', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { - // An entry of the previous format 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. + // 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'); - const legacy: string = key.replace('_fc_rc_2_', '_fc_rc_1_'); - expect(legacy === key).assertFalse(); - core.writeSetting(`${legacy}.values`, '{"rate":0,"version":10,"etag":"\\"v10\\"","ttl":300,"refresh_on_foreground":false}'); - expect(newStore(core).read()).assertNull(); + 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', From ab18aac5470a630b0d86340a8d2a23a0bae8681d Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 5 Oct 2026 07:51:39 -0700 Subject: [PATCH 08/12] feat(rum): report no session id while a session is withheld MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A session kept only on error has nothing on the backend until it is released, so an id handed to the app before then could never be looked up — a ticket correlated with it would point at a session that may never exist. getCurrentSessionId reports undefined while the session withholds its events, and the real id from the release on, as the other platforms report a session that is not yet collected. --- flashcat-rum/src/main/ets/RumMonitor.ets | 7 +++ .../internal/monitor/DefaultRumMonitor.ets | 4 +- .../internal/scope/RumApplicationScope.ets | 5 ++ .../ets/internal/scope/RumSessionScope.ets | 5 ++ .../ets/internal/scope/WithheldEvents.ets | 5 ++ flashcat-rum/src/test/SessionOnError.test.ets | 48 +++++++++++++++++-- 6 files changed, 69 insertions(+), 5 deletions(-) diff --git a/flashcat-rum/src/main/ets/RumMonitor.ets b/flashcat-rum/src/main/ets/RumMonitor.ets index 5a12dfa..e7544ee 100644 --- a/flashcat-rum/src/main/ets/RumMonitor.ets +++ b/flashcat-rum/src/main/ets/RumMonitor.ets @@ -34,6 +34,13 @@ export interface RumMonitor { /** Remove ALL global attributes. */ clearAttributes(): void; + /** + * The id of the session being collected, 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. + */ getCurrentSessionId(callback: (sessionId: string | undefined) => void): void; /** diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index de23ac2..729b2c3 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -318,7 +318,9 @@ export class DefaultRumMonitor implements RumMonitor { } 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/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 10582c3..3ced659 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -221,6 +221,11 @@ 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) { diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index fa45847..5a3d8cd 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -349,6 +349,11 @@ export class RumSessionScope implements RumScope { 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; diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index 82960a1..98aabb3 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -361,6 +361,11 @@ export class WithholdingFeatureScope implements FeatureScope { 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; diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 50e0949..57166b5 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -620,21 +620,61 @@ export default function sessionOnErrorTests(): void { }); }); + 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(new FakeCore(), scope, + 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); - monitor.getCurrentSessionId((id: string | undefined): void => { - delays.push(computeReleaseDelay(id as string)); - }); + // 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 } From 1c8eaa1c4e72b2eec62b342480dc756c6e048167 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 8 Oct 2026 02:41:31 -0700 Subject: [PATCH 09/12] fix(rum): keep a thrown-away session out of the next launch's crash replay MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A sessionOnError session thrown away without an error left its last view on file, and a crash replayed on the next launch rebuilt the session from it: one view and the crash, for a session the backend was promised never to see. The snapshot of a session that is thrown away — at its end, or at SDK stop — is deleted with it; a released session's, and any other session's, stays. A crash of a dead process that nothing attributes to a session is no longer written into a session that withholds its events either: it is not that session's error, and it goes nowhere, as it does when the current session was not drawn. An uncaught ArkTS exception now meets the app's event mapper before the crash feature persists it. The error itself is only assembled on the next launch, but what the crash does to the session — counting it, releasing what the session held — happens in the dying process, and an error the mapper drops must do neither. A dropped crash is remembered as consumed so the watcher's copy does not come back, and is never replayed. Without the crash feature, an uncaught exception released a withheld session behind its jitter, which a dying process may not live out; it releases at once now. Unhandled promise rejections keep the jitter. An immediate rate change away from zero no longer ends a session the switch kept: that is not an emergency stop, and the re-draw threw away the minute the session was holding for an error. The release log reports the writes storage accepted and refused; the discard log reports what was thrown away. --- .../src/main/ets/internal/CrashFeature.ets | 16 +- .../main/ets/internal/LastViewSnapshot.ets | 39 ++- .../src/main/ets/internal/RumFeature.ets | 52 ++- .../internal/monitor/DefaultRumMonitor.ets | 3 +- .../internal/scope/RumApplicationScope.ets | 7 +- .../src/main/ets/internal/scope/RumScope.ets | 3 + .../ets/internal/scope/RumSessionScope.ets | 8 + .../ets/internal/scope/WithheldEvents.ets | 53 ++- flashcat-rum/src/test/SessionOnError.test.ets | 320 ++++++++++++++++-- scripts/unit-node/shims/kit-ability.js | 11 +- 10 files changed, 450 insertions(+), 62 deletions(-) diff --git a/flashcat-crash/src/main/ets/internal/CrashFeature.ets b/flashcat-crash/src/main/ets/internal/CrashFeature.ets index 30422c4..3c6434c 100644 --- a/flashcat-crash/src/main/ets/internal/CrashFeature.ets +++ b/flashcat-crash/src/main/ets/internal/CrashFeature.ets @@ -158,13 +158,27 @@ export class CrashFeature implements Feature, FeatureEventReceiver { return; } + // RUM's event mapper dropped this crash: it is not reported, now or on the + // next launch, so no incident is persisted. The fault is still remembered as + // consumed, so the watcher's own copy of it does not come back as a report + // the mapper never saw. + const suppressed: boolean = event['suppressed'] === true; + if (suppressed) { + this.consumed.push(new ConsumedCrashIncident( + util.generateRandomUUID(true), fingerprint, nowMs, nowMs)); + this.consumed = CrashDeduplication.retainRecent(this.consumed, nowMs, CONSUMED_RETENTION_MS); + if (this.isDurableWriteAllowed()) { + this.persistConsumed(); + } + FlashcatLog.d('crash.js: dropped by the RUM event mapper; not persisted'); + } // NOT_GRANTED: no durable crash DATA may be written (the core enforces the // same rule for every event write) — nothing would be reported anyway, so // persisting the stack/ids would just park refused data on disk. Declining // telemetry must NOT change app runtime behavior though: REPORT_AND_RECOVER // still restarts, guarded by the loop history, which holds only crash // timestamps (no user data) and is therefore legitimate to persist. - if (!this.isDurableWriteAllowed()) { + if (suppressed || !this.isDurableWriteAllowed()) { event['pending_written'] = false; event['report_async'] = false; event['should_exit'] = true; 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 9de42c0..c5f15f1 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 @@ -351,7 +357,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 @@ -660,12 +667,20 @@ export class RumFeature implements Feature, FeatureEventReceiver { if (this.activeJsCrashPolicy !== null) { const crash: FeatureScope | null = this.core.getFeature(CRASH_FEATURE_NAME); if (crash !== null) { + // The app's mapper decides now whether this crash is an error at all. + // Its verdict has to come before the incident is persisted: the error + // itself is only assembled on the next launch, but what the crash + // does to the session — count it, release what the session held — + // happens in this process, and an error the mapper drops must do + // neither. + const suppressed: boolean = callback === 'unhandled' && !this.crashSurvivesMapper(stackText, message); const control: Record = { 'type': 'js_crash_callback', 'target': CRASH_FEATURE_NAME, 'callback': callback, 'text': stackText, 'timestamp_ms': Date.now(), + 'suppressed': suppressed, // Safe defaults preserve the legacy report if the receiver is absent. 'report_async': true, 'should_exit': false @@ -674,6 +689,13 @@ export class RumFeature implements Feature, FeatureEventReceiver { if (control['duplicate'] === true) { return; } + if (suppressed) { + 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. @@ -734,11 +756,39 @@ 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'); } } + /** + * Whether the app's event mapper keeps the crash this process is about to die + * of. The error is assembled the way the next launch's replay assembles it, + * stamped with the session and view the crash feature would persist. + */ + private crashSurvivesMapper(stackText: string, message: string): boolean { + const context: FlashcatContext = this.core.getContext(); + const rum: Record = RumFeature.recordValue(context.featureContext[RUM_FEATURE_NAME]); + const viewId: string = RumFeature.asString(rum['view.id']); + const attributes: Record = {}; + attributes['error.category'] = 'Exception'; + attributes['error.source_type'] = 'harmony'; + const viewUrl: string = RumFeature.asString(rum['view.url']); + if (viewUrl.length > 0) { + attributes['view.url'] = viewUrl; + } + const event: Record = RumEventAssembler.error( + context, this.configuration.applicationId, RumFeature.asString(rum['session.id']), + viewId.length > 0 ? viewId : undefined, + viewId.length > 0 ? RumFeature.asString(rum['view.name']) : undefined, + message, RumErrorSource.SOURCE as string, RumFeature.stackFramesOnly(stackText), + true, 'unhandled', Date.now(), attributes); + return RumEventMapperHolder.apply(event) !== null; + } + private reportUnhandledRejection(reason: Object): void { if (this.handlingError) { FlashcatLog.d('rum.error: skipped re-entrant unhandled rejection'); diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index 729b2c3..97783ff 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -283,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; } diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 3ced659..3c3c530 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -410,8 +410,11 @@ export class RumApplicationScope implements RumScope { 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. - return activation === ACTIVATION_IMMEDIATE && rateMoved; + // 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. 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 5a3d8cd..beab03f 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -166,6 +166,14 @@ export class RumSessionScope implements RumScope { 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: dropped an inherited crash — 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. diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index 98aabb3..dd9809b 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -1,5 +1,6 @@ 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 @@ -182,15 +183,23 @@ export class WithheldEventBuffer { 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. - writer.write(JSON.parse(ordered[i]) as Record, i === ordered.length - 1); + // 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 views=${views.length} details=${this.details.length} ` + - `dropped=${this.droppedCount} detail_bytes=${this.bytes}`); + FlashcatLog.d(`rum.withheld: released ${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 = []; @@ -278,12 +287,6 @@ export class WithheldEventBuffer { return typeof version === 'number' ? version as number : 0; } - /** @internal */ - static isCrash(event: Record): boolean { - const error: Object | undefined = event['error']; - return typeof error === 'object' && error !== null && (error as Record)['is_crash'] === true; - } - private static viewIdOf(event: Record): string { const view: Object | undefined = event['view']; if (typeof view !== 'object' || view === null) { @@ -387,9 +390,15 @@ export class WithholdingFeatureScope implements FeatureScope { /** 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); + } } /** @@ -410,7 +419,8 @@ export class WithholdingFeatureScope implements FeatureScope { /** The session ended: what it earned goes out, the rest is thrown away for good. */ settle(): void { - if (this.buffer === null) { + const buffer: WithheldEventBuffer | null = this.buffer; + if (buffer === null) { return; } if (this.writing > 0) { @@ -427,7 +437,10 @@ export class WithholdingFeatureScope implements FeatureScope { this.cancelTimer(); this.buffer = null; this.discarded = true; - FlashcatLog.d(`rum.withheld: discarded session=${this.sessionId} (ended without an error)`); + // 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}. */ @@ -451,11 +464,6 @@ export class WithholdingFeatureScope implements FeatureScope { if (!this.isOwnError(event)) { return buffer.hold(event, line, bytes, Date.now()); } - // A crash is released at once. It reaches a live session only as a replay - // of a process that already died, spread across the fleet by app launches - // rather than by a jitter — and the process that replays it may not live - // out a jitter either, while the crash feature has already dropped its copy. - const crash: boolean = WithheldEventBuffer.isCrash(event); 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 @@ -465,19 +473,10 @@ export class WithholdingFeatureScope implements FeatureScope { return false; } this.hasError = true; - if (crash) { - this.releaseNow(); - } else { - this.scheduleRelease(buffer); - } + this.scheduleRelease(buffer); return true; } this.hasError = true; - if (crash) { - const held: boolean = buffer.hold(event, line, bytes, Date.now()); - this.releaseNow(); - return held; - } this.scheduleRelease(buffer); return buffer.hold(event, line, bytes, Date.now()); } diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 57166b5..4ae6875 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -19,6 +19,7 @@ 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; @@ -52,8 +53,18 @@ class FakeCore implements SdkCore { 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 { + 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 {} @@ -101,7 +112,21 @@ class CapturingScope implements FeatureScope { callback(testContext(), new CapturingWriter(this)); } - sendEvent(_event: Record): void {} + /** 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); @@ -125,6 +150,53 @@ class CapturingWriter implements EventWriter { } } +/** + * Stands in for the crash feature on the bus: answers the policy pull and the + * live crash callback the way CrashFeature does for a persisted incident, minus + * the process exit. + */ +class FakeCrashScope implements FeatureScope { + readonly controls: Array> = []; + + 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'] = 'report_then_exit'; + } else if (type === 'js_crash_callback') { + this.controls.push(event); + const suppressed: boolean = event['suppressed'] === true; + event['pending_written'] = !suppressed; + 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, on a rate-0 + switch configuration. */ +function onErrorFeature(core: FakeCore, scope: CapturingScope): DefaultRumMonitor { + core.rumScope = scope; + const configuration: RumConfiguration = + new RumConfigurationBuilder('app-1').setSessionSampleRate(0).setSessionOnError(true).build(); + const feature: RumFeature = new RumFeature(core, configuration); + feature.onInitialize(); + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration); + monitor.stopKeepAlive(); + feature.attachMonitor(monitor); + return monitor; +} + class SilentFetcher implements RemoteConfigFetcher { fetch(_url: string, _ifNoneMatch: string | null): Promise { return Promise.resolve({ code: 304, body: '', etag: null } as RemoteConfigResponse); @@ -432,32 +504,42 @@ export default function sessionOnErrorTests(): void { expect(scope.ofType('action').length).assertEqual(1); }); - it('aCrashReportedAsAnErrorReleasesAtOnce', + it('anInheritedCrashDoesNotMakeAWithheldSessionAnErrorSession', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { - // A crash reaches a live session only as a replay of a process that - // already died; the crash feature drops its copy once reported. + // 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 app: RumApplicationScope = onErrorApp(scope); - const now: number = Date.now(); - startView(app, now); - addAction(app, now + 1); - const crash: RumRawEvent = raw('addError', now + 2); - crash.errorMessage = 'replayed crash'; - crash.errorSource = 'source'; - crash.isCrash = true; - app.handleEvent(crash); - // Synchronous: nothing waits on a jitter. - expect(scope.ofType('error').length).assertEqual(1); - expect(scope.ofType('action').length).assertEqual(1); - expect(scope.written[0]['type']).assertEqual('view'); - // Negative control: an ordinary error waits for its jitter. - const quiet: CapturingScope = new CapturingScope(); - const app2: RumApplicationScope = onErrorApp(quiet); - startView(app2, now); - addError(app2, now + 1); - expect(quiet.written.length).assertEqual(0); - app2.releaseScheduledWithheld(); - expect(quiet.ofType('error').length).assertEqual(1); + 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', @@ -1320,7 +1402,9 @@ export default function sessionOnErrorTests(): void { let stored: string = ''; LastViewSnapshot.configure((serialized: string): void => { stored = serialized; - }, (): string => stored); + }, (): string => stored, (): void => { + stored = ''; + }); try { const scope: CapturingScope = new CapturingScope(); const app: RumApplicationScope = onErrorApp(scope); @@ -1338,4 +1422,186 @@ export default function sessionOnErrorTests(): void { } }); }); + + 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(); + 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(); + 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); + }); + }); } 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 }, From 9dabc0c8117941632d325935caf2d9ae8dd04c02 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 8 Oct 2026 02:41:31 -0700 Subject: [PATCH 10/12] docs(rum): describe sessionOnError in the changelog and qualify its crash promise The configuration doc now says what an uncaught exception the mapper drops does (nothing), that a thrown-away session is never rebuilt by a later crash, and names the eviction rule plainly. getCurrentSessionId's doc says a session the rate did not draw still reports its id. The demo gains two runs: a session thrown away and then a native crash, and a crash the demo mapper drops. --- entry/src/main/ets/pages/Index.ets | 18 ++++++++++++++- flashcat-rum/CHANGELOG.md | 22 +++++++++++++++++++ .../src/main/ets/RumConfiguration.ets | 18 ++++++++------- flashcat-rum/src/main/ets/RumMonitor.ets | 12 +++++----- 4 files changed, 56 insertions(+), 14 deletions(-) diff --git a/entry/src/main/ets/pages/Index.ets b/entry/src/main/ets/pages/Index.ets index a6d62f1..007c92e 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -201,6 +201,13 @@ struct Index { 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); } } @@ -245,7 +252,8 @@ struct Index { * 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-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(); @@ -261,6 +269,14 @@ struct Index { 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()); diff --git a/flashcat-rum/CHANGELOG.md b/flashcat-rum/CHANGELOG.md index bd471e0..a9d8928 100644 --- a/flashcat-rum/CHANGELOG.md +++ b/flashcat-rum/CHANGELOG.md @@ -1,5 +1,27 @@ # 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. + ## 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 5e2a89a..1850ebc 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -160,15 +160,17 @@ export class RumConfigurationBuilder { * 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 telling dropped first when there are more) - * is uploaded, after a delay of up to 3 s that spreads out the uploads a + * 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. An uncaught ArkTS - * exception that terminates the process releases what was held before the - * process exits; 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. + * 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 diff --git a/flashcat-rum/src/main/ets/RumMonitor.ets b/flashcat-rum/src/main/ets/RumMonitor.ets index e7544ee..a38bf11 100644 --- a/flashcat-rum/src/main/ets/RumMonitor.ets +++ b/flashcat-rum/src/main/ets/RumMonitor.ets @@ -35,11 +35,13 @@ export interface RumMonitor { clearAttributes(): void; /** - * The id of the session being collected, 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. + * 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; From 72a55af6bc95bd1b20c01a42d62f2dc92e3cde2e Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 10 Oct 2026 00:27:15 -0700 Subject: [PATCH 11/12] fix(rum): judge a terminating crash by the event its replay will carry MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The event the app's mapper was asked about at crash time was assembled on its own — untruncated text, the current time, none of the fields the replay adds — so the mapper could keep it now and drop the replayed one later: a session released and counted for an error that never arrived. The crash feature now builds the question from the persisted incident through the same path the replay uses, and RUM answers through the same assembly, so the mapper judges one event twice rather than two events. The question is only asked where its answer changes something in the dying process: a terminating policy, for a session that withholds its events. A session the rate collects, and OBSERVE_ONLY, run the mapper once through the paths they always had — the crash-time check had been dropping an OBSERVE_ONLY error whose mapper refuses crashes but keeps errors, and marking the fault consumed, for customers who never turned the switch on. The crash storage hands back the file name it wrote, so a crash the mapper drops is removed again. The consent log no longer claims a refused consent for a mapper decision; the release log names the session; the inherited-crash log says the crash is not reported. --- .../persistence/CrashPendingStore.ets | 5 + .../src/main/ets/internal/CrashFeature.ets | 64 ++++-- .../src/main/ets/internal/RumFeature.ets | 91 ++++---- .../internal/monitor/DefaultRumMonitor.ets | 5 + .../ets/internal/scope/RumSessionScope.ets | 2 +- .../ets/internal/scope/WithheldEvents.ets | 6 +- flashcat-rum/src/test/SessionOnError.test.ets | 215 ++++++++++++++++-- 7 files changed, 306 insertions(+), 82 deletions(-) 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-crash/src/main/ets/internal/CrashFeature.ets b/flashcat-crash/src/main/ets/internal/CrashFeature.ets index 3c6434c..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. @@ -158,27 +160,13 @@ export class CrashFeature implements Feature, FeatureEventReceiver { return; } - // RUM's event mapper dropped this crash: it is not reported, now or on the - // next launch, so no incident is persisted. The fault is still remembered as - // consumed, so the watcher's own copy of it does not come back as a report - // the mapper never saw. - const suppressed: boolean = event['suppressed'] === true; - if (suppressed) { - this.consumed.push(new ConsumedCrashIncident( - util.generateRandomUUID(true), fingerprint, nowMs, nowMs)); - this.consumed = CrashDeduplication.retainRecent(this.consumed, nowMs, CONSUMED_RETENTION_MS); - if (this.isDurableWriteAllowed()) { - this.persistConsumed(); - } - FlashcatLog.d('crash.js: dropped by the RUM event mapper; not persisted'); - } // NOT_GRANTED: no durable crash DATA may be written (the core enforces the // same rule for every event write) — nothing would be reported anyway, so // persisting the stack/ids would just park refused data on disk. Declining // telemetry must NOT change app runtime behavior though: REPORT_AND_RECOVER // still restarts, guarded by the loop history, which holds only crash // timestamps (no user data) and is therefore legitimate to persist. - if (suppressed || !this.isDurableWriteAllowed()) { + if (!this.isDurableWriteAllowed()) { event['pending_written'] = false; event['report_async'] = false; event['should_exit'] = true; @@ -277,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 @@ -336,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/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index c5f15f1..feb175d 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -267,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; @@ -389,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 @@ -418,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); @@ -667,20 +692,12 @@ export class RumFeature implements Feature, FeatureEventReceiver { if (this.activeJsCrashPolicy !== null) { const crash: FeatureScope | null = this.core.getFeature(CRASH_FEATURE_NAME); if (crash !== null) { - // The app's mapper decides now whether this crash is an error at all. - // Its verdict has to come before the incident is persisted: the error - // itself is only assembled on the next launch, but what the crash - // does to the session — count it, release what the session held — - // happens in this process, and an error the mapper drops must do - // neither. - const suppressed: boolean = callback === 'unhandled' && !this.crashSurvivesMapper(stackText, message); const control: Record = { 'type': 'js_crash_callback', 'target': CRASH_FEATURE_NAME, 'callback': callback, 'text': stackText, 'timestamp_ms': Date.now(), - 'suppressed': suppressed, // Safe defaults preserve the legacy report if the receiver is absent. 'report_async': true, 'should_exit': false @@ -689,7 +706,10 @@ export class RumFeature implements Feature, FeatureEventReceiver { if (control['duplicate'] === true) { return; } - if (suppressed) { + 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); @@ -764,31 +784,6 @@ export class RumFeature implements Feature, FeatureEventReceiver { } } - /** - * Whether the app's event mapper keeps the crash this process is about to die - * of. The error is assembled the way the next launch's replay assembles it, - * stamped with the session and view the crash feature would persist. - */ - private crashSurvivesMapper(stackText: string, message: string): boolean { - const context: FlashcatContext = this.core.getContext(); - const rum: Record = RumFeature.recordValue(context.featureContext[RUM_FEATURE_NAME]); - const viewId: string = RumFeature.asString(rum['view.id']); - const attributes: Record = {}; - attributes['error.category'] = 'Exception'; - attributes['error.source_type'] = 'harmony'; - const viewUrl: string = RumFeature.asString(rum['view.url']); - if (viewUrl.length > 0) { - attributes['view.url'] = viewUrl; - } - const event: Record = RumEventAssembler.error( - context, this.configuration.applicationId, RumFeature.asString(rum['session.id']), - viewId.length > 0 ? viewId : undefined, - viewId.length > 0 ? RumFeature.asString(rum['view.name']) : undefined, - message, RumErrorSource.SOURCE as string, RumFeature.stackFramesOnly(stackText), - true, 'unhandled', Date.now(), attributes); - return RumEventMapperHolder.apply(event) !== null; - } - private reportUnhandledRejection(reason: Object): void { if (this.handlingError) { FlashcatLog.d('rum.error: skipped re-entrant unhandled rejection'); diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index 97783ff..45577e8 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -318,6 +318,11 @@ 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 { // 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. diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index beab03f..193607c 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -171,7 +171,7 @@ export class RumSessionScope implements RumScope { // 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: dropped an inherited crash — the current session is withheld'); + FlashcatLog.d('rum.session: inherited crash not reported — the current session is withheld'); return this; } diff --git a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets index dd9809b..c3385a4 100644 --- a/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets +++ b/flashcat-rum/src/main/ets/internal/scope/WithheldEvents.ets @@ -165,7 +165,7 @@ export class WithheldEventBuffer { * 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): void { + release(writer: EventWriter, nowMs: number, sessionId: string): void { this.prune(nowMs); const views: HeldView[] = []; this.views.forEach((view: HeldView) => { @@ -191,7 +191,7 @@ export class WithheldEventBuffer { persisted += 1; } } - FlashcatLog.d(`rum.withheld: released ${this.describe()} persisted=${persisted} failed=${ordered.length - persisted}`); + FlashcatLog.d(`rum.withheld: released session=${sessionId} ${this.describe()} persisted=${persisted} failed=${ordered.length - persisted}`); this.clear(); } @@ -509,7 +509,7 @@ export class WithholdingFeatureScope implements FeatureScope { this.cancelTimer(); this.buffer = null; this.delegate.withWriteContext((_context: FlashcatContext, writer: EventWriter) => { - buffer.release(writer, Date.now()); + buffer.release(writer, Date.now(), this.sessionId); }); } diff --git a/flashcat-rum/src/test/SessionOnError.test.ets b/flashcat-rum/src/test/SessionOnError.test.ets index 4ae6875..ad348ee 100644 --- a/flashcat-rum/src/test/SessionOnError.test.ets +++ b/flashcat-rum/src/test/SessionOnError.test.ets @@ -152,25 +152,76 @@ class CapturingWriter implements EventWriter { /** * Stands in for the crash feature on the bus: answers the policy pull and the - * live crash callback the way CrashFeature does for a persisted incident, minus - * the process exit. + * 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'] = 'report_then_exit'; - } else if (type === 'js_crash_callback') { - this.controls.push(event); - const suppressed: boolean = event['suppressed'] === true; - event['pending_written'] = !suppressed; - event['report_async'] = false; - event['should_exit'] = false; // the test process stays alive + 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 } } @@ -184,19 +235,59 @@ function fireUnhandledRejection(reason: Object): void { (observers['unhandledRejection'] as errorManager.UnhandledRejectionObserver)(reason, Promise.resolve()); } -/** A RUM feature wired to the fake core, with its monitor attached, on a rate-0 + switch configuration. */ -function onErrorFeature(core: FakeCore, scope: CapturingScope): DefaultRumMonitor { +/** 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(0).setSessionOnError(true).build(); + 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); @@ -1528,7 +1619,7 @@ export default function sessionOnErrorTests(): void { event['type'] === 'error' ? null : event); try { const core: FakeCore = new FakeCore(); - core.crashScope = new FakeCrashScope(); + core.crashScope = new FakeCrashScope(core); const scope: CapturingScope = new CapturingScope(); const monitor: DefaultRumMonitor = onErrorFeature(core, scope); monitor.startView('home', 'home'); @@ -1543,7 +1634,7 @@ export default function sessionOnErrorTests(): void { } // Negative control: a crash the mapper keeps releases the history at once. const core2: FakeCore = new FakeCore(); - core2.crashScope = new FakeCrashScope(); + core2.crashScope = new FakeCrashScope(core2); const kept: CapturingScope = new CapturingScope(); const monitor2: DefaultRumMonitor = onErrorFeature(core2, kept); monitor2.startView('home', 'home'); @@ -1604,4 +1695,100 @@ export default function sessionOnErrorTests(): void { 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(); + } + }); + }); } From a46a0f3acaeea15f326c29ef61328789d770fc96 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sat, 10 Oct 2026 00:27:16 -0700 Subject: [PATCH 12/12] docs(rum): note the limits of error-session capture around crash replay The changelog states that an unattributable crash replayed on a later launch is not reported while the current session withholds, and that the event mapper must be pure because a terminating crash is mapped at crash time and again at replay. The demo gains a crash_policy launch parameter to run the crash feature's OBSERVE_ONLY policy. --- entry/src/main/ets/common/DemoSdk.ets | 5 ++++- entry/src/main/ets/entryability/EntryAbility.ets | 2 ++ entry/src/main/ets/pages/Index.ets | 3 ++- flashcat-rum/CHANGELOG.md | 6 ++++++ 4 files changed, 14 insertions(+), 2 deletions(-) diff --git a/entry/src/main/ets/common/DemoSdk.ets b/entry/src/main/ets/common/DemoSdk.ets index 991ad97..4b21f32 100644 --- a/entry/src/main/ets/common/DemoSdk.ets +++ b/entry/src/main/ets/common/DemoSdk.ets @@ -56,7 +56,8 @@ export class DemoSdk { */ static init(context: common.Context, prod: boolean, customEndpoint: string, recover: boolean = false, trackErrors: boolean = true, pacing: string = 'demo', - remoteConfig: string = '', initSampleRate: number = 100, sessionOnError: boolean = false): void { + remoteConfig: string = '', initSampleRate: number = 100, sessionOnError: boolean = false, + jsCrashPolicy: string = ''): void { if (DemoSdk.initialized) { return; } @@ -107,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()); diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 8df2af4..172f8fc 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -121,6 +121,8 @@ export default class EntryAbility extends UIAbility { 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 007c92e..f2bc39d 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -109,8 +109,9 @@ struct Index { 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, sessionOnError); + rcMode, rcInitRate, sessionOnError, jsCrashPolicy); } catch (e) { this.append(e instanceof Error ? e.message : 'SDK initialization failed'); return; diff --git a/flashcat-rum/CHANGELOG.md b/flashcat-rum/CHANGELOG.md index a9d8928..2165778 100644 --- a/flashcat-rum/CHANGELOG.md +++ b/flashcat-rum/CHANGELOG.md @@ -21,6 +21,12 @@ 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