From bbc40cfd3b6e1a382d1d0f9600902aa57436e69a Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 08:00:39 -0700 Subject: [PATCH 01/10] Read the console's sampling settings on HarmonyOS MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RUM on HarmonyOS collected at whatever rate the app was built with: moving that rate meant shipping a release and waiting for it to roll out, while the other platforms could already turn the knob from the console. The settings are read from the same endpoint the other SDKs use, at the rhythm of the sessions that consume them: once at start-up, once per new session, and on return to the foreground when the console asks for it. What arrives is stored, so the first session of a launch is already drawn under it. Nothing here can hold up initialisation or interrupt collection — a request that fails, times out or comes back unreadable leaves the stored values exactly as they were, because wiping them on a bad minute would swing a fleet back to the values it was built with, the opposite of what someone who moved a knob wants. A published rate applies to the NEXT session drawn; a session already under way is never re-decided. View documents now report the rate the session was actually drawn with plus the settings version, so extrapolation and audits line up with the draw that kept the session rather than with the init values. Two application-facing controls come with it: - setForcedSession() keeps a visitor the rate would have dropped — the support case where someone needs this one user's sessions whatever the fleet is on. - beforeSampling() has the last word on the rate, called synchronously at each draw with the rate that would apply and the console's custom values. A throw or an out-of-range answer is ignored: a mistake in the host application must not take collection down with it. Feature modules get no HarmonyOS Context of their own, so the credentials and the small amount of storage this needs come from the core, which already owns both. The demo app gains a scenario that drives the whole path from the command line. --- entry/src/main/ets/common/DemoSdk.ets | 62 ++- .../main/ets/entryability/EntryAbility.ets | 4 + entry/src/main/ets/pages/Index.ets | 46 +- flashcat-core/Index.ets | 2 +- flashcat-core/src/main/ets/api/SdkCore.ets | 30 ++ .../src/main/ets/internal/FlashcatCore.ets | 52 +- flashcat-rum/Index.ets | 3 +- flashcat-rum/src/main/ets/FlashcatRum.ets | 50 +- .../src/main/ets/RumConfiguration.ets | 45 +- flashcat-rum/src/main/ets/RumMonitor.ets | 23 + flashcat-rum/src/main/ets/RumTypes.ets | 23 + .../src/main/ets/internal/RumFeature.ets | 9 + .../internal/assembly/RumEventAssembler.ets | 20 +- .../internal/monitor/DefaultRumMonitor.ets | 52 +- .../remoteconfig/RemoteConfigController.ets | 317 +++++++++++ .../remoteconfig/RemoteConfigFetcher.ets | 87 ++++ .../remoteconfig/RemoteConfigStore.ets | 201 +++++++ .../internal/scope/RumApplicationScope.ets | 94 +++- .../ets/internal/scope/RumSessionScope.ets | 14 +- .../main/ets/internal/scope/RumViewScope.ets | 12 +- flashcat-rum/src/test/List.test.ets | 2 + flashcat-rum/src/test/RemoteConfig.test.ets | 490 ++++++++++++++++++ .../src/test/SessionLifecycle.test.ets | 17 +- 23 files changed, 1625 insertions(+), 30 deletions(-) create mode 100644 flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets create mode 100644 flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets create mode 100644 flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets create mode 100644 flashcat-rum/src/test/RemoteConfig.test.ets diff --git a/entry/src/main/ets/common/DemoSdk.ets b/entry/src/main/ets/common/DemoSdk.ets index d9346e3..eb567e5 100644 --- a/entry/src/main/ets/common/DemoSdk.ets +++ b/entry/src/main/ets/common/DemoSdk.ets @@ -1,9 +1,11 @@ import { common } from '@kit.AbilityKit'; +import { hilog } from '@kit.PerformanceAnalysisKit'; import { Flashcat, Configuration, ConfigurationBuilder, FlashcatSite, TrackingConsent, UserInfo, UploadFrequency, BatchSize, BatchProcessingLevel } from '@flashcatcloud/core'; -import { FlashcatRum, RumConfigurationBuilder, GlobalRumMonitor, RumMonitor } from '@flashcatcloud/rum'; +import { FlashcatRum, RumConfigurationBuilder, GlobalRumMonitor, RumMonitor, + BeforeSamplingContext } from '@flashcatcloud/rum'; import { FlashcatTrace, TraceConfigurationBuilder } from '@flashcatcloud/trace'; import { FlashcatCrash, CrashConfigurationBuilder, JsCrashPolicy } from '@flashcatcloud/crash'; import { DemoConfig, DemoConfigLoader } from './DemoConfig'; @@ -43,8 +45,17 @@ export class DemoSdk { * comparing settings means one app run per setting — hence a launch * parameter rather than a runtime toggle. */ + /** + * @param remoteConfig '' → off (init values rule); 'on' → read the console's + * configuration; 'vip' → same, plus a beforeSampling allow-list that keeps + * this device's sessions when the console's `custom.vip` names it. + * @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. + */ static init(context: common.Context, prod: boolean, customEndpoint: string, - recover: boolean = false, trackErrors: boolean = true, pacing: string = 'demo'): void { + recover: boolean = false, trackErrors: boolean = true, pacing: string = 'demo', + remoteConfig: string = '', initSampleRate: number = 100): void { if (DemoSdk.initialized) { return; } @@ -76,14 +87,20 @@ export class DemoSdk { } Flashcat.initialize(context, builder.build(), TrackingConsent.GRANTED); - FlashcatRum.enable(new RumConfigurationBuilder(demoConfig.applicationId) - .setSessionSampleRate(100) + const rumBuilder: RumConfigurationBuilder = new RumConfigurationBuilder(demoConfig.applicationId) + .setSessionSampleRate(initSampleRate) .setTrackUserInteractions(true) // phase 2: auto TAP actions (A3) .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) - .setEventMapper(DemoSdk.demoEventMapper) // phase 2 R3: PII scrubbing / drop - .build()); + .setEventMapper(DemoSdk.demoEventMapper); // phase 2 R3: PII scrubbing / drop + if (remoteConfig.length > 0) { + rumBuilder.setRemoteConfigurationEnabled(true); + } + if (remoteConfig === 'vip') { + rumBuilder.setBeforeSampling(DemoSdk.vipAllowList); + } + FlashcatRum.enable(rumBuilder.build()); FlashcatTrace.enable(new TraceConfigurationBuilder().setSampleRate(100).build()); const crashBuilder: CrashConfigurationBuilder = new CrashConfigurationBuilder(); if (recover) { @@ -97,6 +114,39 @@ export class DemoSdk { (recover ? ' · crash recovery' : ''); } + /** Identifies this device to the console's allow-list. */ + static readonly DEMO_USER_ID: string = 'harmony-e2e'; + + /** + * Demo beforeSampling hook: keep every session of a device the console named + * in `custom.vip`, whatever rate the fleet is on. This is the shape a support + * team uses — the console publishes the list, no app release involved. + */ + static vipAllowList(context: BeforeSamplingContext): number | undefined { + const custom: Record | null = context.custom; + const listed: boolean = DemoSdk.isListed(custom, DemoSdk.DEMO_USER_ID); + hilog.info(0x0000, 'FCRC', 'beforeSampling rate=%{public}s vipListed=%{public}s', + `${context.sessionSampleRate}`, `${listed}`); + return listed ? 100 : undefined; + } + + private static isListed(custom: Record | null, id: string): boolean { + if (custom === null) { + return false; + } + const vip: Object | undefined = custom['vip']; + if (!Array.isArray(vip)) { + return false; + } + const entries: Array = vip as Array; + for (let i: number = 0; i < entries.length; i++) { + if (entries[i] === id) { + return true; + } + } + return false; + } + static monitor(): RumMonitor { return GlobalRumMonitor.get(); } diff --git a/entry/src/main/ets/entryability/EntryAbility.ets b/entry/src/main/ets/entryability/EntryAbility.ets index 4972b6f..59fc3e0 100644 --- a/entry/src/main/ets/entryability/EntryAbility.ets +++ b/entry/src/main/ets/entryability/EntryAbility.ets @@ -115,6 +115,10 @@ export default class EntryAbility extends UIAbility { EntryAbility.forwardParam(params, 'netbench_events', 'netbenchEvents'); EntryAbility.forwardParam(params, 'netbench_pacing', 'netbenchPacing'); EntryAbility.forwardParam(params, 'netbench_body_bytes', 'netbenchBodyBytes'); + // Remote-configuration knobs: which mode RUM is enabled in, and the rate + // 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'); 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 90cacae..f4966ac 100644 --- a/entry/src/main/ets/pages/Index.ets +++ b/entry/src/main/ets/pages/Index.ets @@ -3,7 +3,8 @@ import { taskpool } from '@kit.ArkTS'; import { router } from '@kit.ArkUI'; import { hiAppEvent, hilog } from '@kit.PerformanceAnalysisKit'; import { rcp } from '@kit.RemoteCommunicationKit'; -import { FlashcatRum, RumActionType, RumErrorSource, RumResourceKind, RumResourceMethod } from '@flashcatcloud/rum'; +import { FlashcatRum, RumActionType, RumErrorSource, RumResourceKind, RumResourceMethod, + RumMonitor } from '@flashcatcloud/rum'; import { FlashcatTrace, FlashcatHttp } from '@flashcatcloud/trace'; import { http } from '@kit.NetworkKit'; import { TrackingConsent } from '@flashcatcloud/core'; @@ -105,7 +106,10 @@ struct Index { private initSdkE2e(trackErrors: boolean): void { try { const pacing: string = AppStorage.get('netbenchPacing') ?? 'demo'; // SDK init only - DemoSdk.init(this.context, this.useProd, this.customEndpoint, false, trackErrors, pacing); + const rcMode: string = AppStorage.get('rcMode') ?? ''; + const rcInitRate: number = Number.parseInt(AppStorage.get('rcInitRate') ?? '100', 10); + DemoSdk.init(this.context, this.useProd, this.customEndpoint, false, trackErrors, pacing, + rcMode, rcInitRate); } catch (e) { this.append(e instanceof Error ? e.message : 'SDK initialization failed'); return; @@ -182,6 +186,8 @@ struct Index { const endpoint: string = AppStorage.get('e2eEndpoint') ?? ''; this.append('e2e: netbench started'); this.runNetBench(`${endpoint}/e2e/echo`); + } else if (name === 'remoteconfig') { + this.runRemoteConfigScenario(); } else if (name === 'crash') { this.append('e2e: crashing in 800ms'); setTimeout(() => { @@ -190,6 +196,42 @@ struct Index { } } + /** + * Remote-configuration end-to-end run, driven entirely from the command line. + * + * Two sessions on purpose: the first is drawn under whatever the console + * published (and may well be dropped — that is the point), the second is + * taken by force. Comparing the two in the backend is what shows the console + * rate actually reached the draw instead of the value this build shipped + * with, and every step is logged under FCRC so a run can be read back from + * hilog alone. + */ + private runRemoteConfigScenario(): void { + const monitor: RumMonitor = DemoSdk.monitor(); + const custom: Record | null = monitor.getRemoteConfig(); + hilog.info(0x0000, 'FCRC', 'custom=%{public}s', custom === null ? 'null' : JSON.stringify(custom)); + + monitor.startView('rc-drawn', 'RcDrawn'); + monitor.addAction(RumActionType.TAP, 'rc-drawn-tap'); + monitor.getCurrentSessionId((sessionId: string | undefined): void => { + hilog.info(0x0000, 'FCRC', 'drawn session=%{public}s', sessionId ?? 'none'); + }); + this.append('e2e: remoteconfig — drawn session emitted'); + + setTimeout(() => { + monitor.setForcedSession(); + monitor.startView('rc-forced', 'RcForced'); + monitor.addAction(RumActionType.TAP, 'rc-forced-tap'); + monitor.getCurrentSessionId((sessionId: string | undefined): void => { + hilog.info(0x0000, 'FCRC', 'forced session=%{public}s', sessionId ?? 'none'); + }); + const after: Record | null = monitor.getRemoteConfig(); + hilog.info(0x0000, 'FCRC', 'custom after force=%{public}s', + after === null ? 'null' : JSON.stringify(after)); + this.append('e2e: remoteconfig — forced session emitted'); + }, 3000); + } + aboutToDisappear(): void { if (!this.faultWatcherRegistered) { return; diff --git a/flashcat-core/Index.ets b/flashcat-core/Index.ets index 7acb682..289ba94 100644 --- a/flashcat-core/Index.ets +++ b/flashcat-core/Index.ets @@ -6,7 +6,7 @@ export { Configuration, ConfigurationBuilder } from './src/main/ets/config/Confi export { UploadFrequency, BatchSize, BatchProcessingLevel } from './src/main/ets/config/Batching'; export { FlashcatSite } from './src/main/ets/FlashcatSite'; export { TrackingConsent } from './src/main/ets/privacy/TrackingConsent'; -export { SdkCore } from './src/main/ets/api/SdkCore'; +export { SdkCore, IntakeTarget } from './src/main/ets/api/SdkCore'; // Feature SDK authoring surface — used by flashcat-rum / flashcat-trace / // flashcat-crash, not by app developers directly. (LOGS_FEATURE_NAME is diff --git a/flashcat-core/src/main/ets/api/SdkCore.ets b/flashcat-core/src/main/ets/api/SdkCore.ets index 0bd5ee6..c1d6fa7 100644 --- a/flashcat-core/src/main/ets/api/SdkCore.ets +++ b/flashcat-core/src/main/ets/api/SdkCore.ets @@ -2,6 +2,18 @@ import { Feature, FeatureScope, FeatureEventReceiver } from './feature/Feature'; import { FlashcatContext, UserInfo } from './context/FlashcatContext'; import { TrackingConsent } from '../privacy/TrackingConsent'; +/** + * Where a feature that calls a NON-batch endpoint has to send its request, and + * what identifies the caller there. Handed out separately from + * {@link FlashcatContext} on purpose: the client token is a credential, and the + * context is snapshotted into every event. + */ +export interface IntakeTarget { + /** Intake host, honouring a configured custom endpoint. No trailing slash. */ + readonly host: string; + readonly clientToken: string; +} + /** * The running SDK instance handed to features. Returned by * `Flashcat.initialize` / `Flashcat.getInstance`. Equivalent to Android's `SdkCore`. @@ -35,6 +47,24 @@ export interface SdkCore { */ isActive(): boolean; + /** + * Intake host + client token, for a feature that calls an endpoint of its own + * (the RUM remote-configuration endpoint) rather than the batch pipeline. + */ + getIntakeTarget(): IntakeTarget; + + /** + * Small persistent settings store, shared by every feature and keyed by + * whatever the caller passes. Feature modules receive no HarmonyOS Context of + * their own, so anything they need across launches goes through the core. + * Returns null when the key was never written or storage is unavailable — + * a caller must behave as if it had never stored anything. + */ + readSetting(key: string): string | null; + + /** Persist (or, with a null value, remove) one settings entry. Best-effort. */ + writeSetting(key: string, value: string | null): void; + /** Set / clear the identified user. */ setUserInfo(user: UserInfo): void; /** Clear the identified user (equivalent to setUserInfo({})). */ diff --git a/flashcat-core/src/main/ets/internal/FlashcatCore.ets b/flashcat-core/src/main/ets/internal/FlashcatCore.ets index 5e689c1..7002817 100644 --- a/flashcat-core/src/main/ets/internal/FlashcatCore.ets +++ b/flashcat-core/src/main/ets/internal/FlashcatCore.ets @@ -2,8 +2,9 @@ import { common, bundleManager, ApplicationStateChangeCallback } from '@kit.Abil import { fileIo as fs } from '@kit.CoreFileKit'; import { preferences } from '@kit.ArkData'; import { Configuration } from '../config/Configuration'; +import { intakeEndpoint } from '../FlashcatSite'; import { TrackingConsent } from '../privacy/TrackingConsent'; -import { SdkCore } from '../api/SdkCore'; +import { SdkCore, IntakeTarget } from '../api/SdkCore'; import { Feature, FeatureScope, FeatureEventReceiver, EventWriter, RUM_FEATURE_NAME } from '../api/feature/Feature'; import { FlashcatContext, UserInfo } from '../api/context/FlashcatContext'; import { ContextProvider } from './context/ContextProvider'; @@ -17,6 +18,9 @@ import { FlashcatLog } from './FlashcatLog'; const PREFERENCES_NAME: string = 'flashcat_sdk'; const CONSENT_KEY: string = 'tracking_consent'; +// Namespace for feature settings, so a feature key can never collide with a +// core-owned entry in the shared Preferences file. +const SETTING_PREFIX: string = 'setting.'; /** * Default SdkCore implementation. Owns the context provider, message bus, and the @@ -112,7 +116,13 @@ export class FlashcatCore implements SdkCore { } try { this.appStateCallback = { - onApplicationForeground: () => {}, + onApplicationForeground: () => { + // The only moment a feature can trust that time has passed: an + // in-process timer may not have run for hours while backgrounded. + // RUM decides whether this is worth a request (see the console's + // refresh_on_foreground) — the core just says when. + this.bus.send(RUM_FEATURE_NAME, { 'type': 'app_foreground' } as Record); + }, onApplicationBackground: () => { this.flushAll(); } @@ -242,6 +252,44 @@ export class FlashcatCore implements SdkCore { } } + getIntakeTarget(): IntakeTarget { + const host: string = this.configuration.customEndpoint.length > 0 + ? this.configuration.customEndpoint + : intakeEndpoint(this.configuration.site); + return { host: host, clientToken: this.configuration.clientToken }; + } + + readSetting(key: string): string | null { + try { + const prefs: preferences.Preferences = + preferences.getPreferencesSync(this.context, { name: PREFERENCES_NAME }); + const value: preferences.ValueType = prefs.getSync(SETTING_PREFIX + key, ''); + // An empty string reads as "never written": callers store JSON, which is + // never empty, and this keeps the absent case a single value. + return typeof value === 'string' && value.length > 0 ? value : null; + } catch (_e) { + // Storage unavailable — the caller must behave as if nothing was stored. + return null; + } + } + + writeSetting(key: string, value: string | null): void { + try { + const prefs: preferences.Preferences = + preferences.getPreferencesSync(this.context, { name: PREFERENCES_NAME }); + if (value === null) { + prefs.deleteSync(SETTING_PREFIX + key); + } else { + prefs.putSync(SETTING_PREFIX + key, value); + } + prefs.flush((_err) => { + // best-effort durability, exactly like the consent record above + }); + } catch (_e) { + // best-effort: a settings write must never surface into the host app + } + } + stop(): void { this.active = false; // Final view refresh before shutdown — without it the last view keeps the diff --git a/flashcat-rum/Index.ets b/flashcat-rum/Index.ets index 4422b26..e00bc59 100644 --- a/flashcat-rum/Index.ets +++ b/flashcat-rum/Index.ets @@ -3,4 +3,5 @@ export { FlashcatRum } from './src/main/ets/FlashcatRum'; export { RumConfiguration, RumConfigurationBuilder } from './src/main/ets/RumConfiguration'; export { RumMonitor, GlobalRumMonitor } from './src/main/ets/RumMonitor'; -export { RumActionType, RumErrorSource, RumResourceKind, RumResourceMethod, RumEventMapper } from './src/main/ets/RumTypes'; +export { RumActionType, RumErrorSource, RumResourceKind, RumResourceMethod, RumEventMapper, + BeforeSamplingContext, BeforeSamplingCallback } from './src/main/ets/RumTypes'; diff --git a/flashcat-rum/src/main/ets/FlashcatRum.ets b/flashcat-rum/src/main/ets/FlashcatRum.ets index fa13caf..03fd3c6 100644 --- a/flashcat-rum/src/main/ets/FlashcatRum.ets +++ b/flashcat-rum/src/main/ets/FlashcatRum.ets @@ -1,4 +1,5 @@ -import { Flashcat, SdkCore, FeatureScope, RUM_FEATURE_NAME, FlashcatLog } from '@flashcatcloud/core'; +import { Flashcat, SdkCore, FeatureScope, FlashcatContext, IntakeTarget, + RUM_FEATURE_NAME, FlashcatLog } from '@flashcatcloud/core'; import { RumConfiguration } from './RumConfiguration'; import { GlobalRumMonitor } from './RumMonitor'; import { RumFeature } from './internal/RumFeature'; @@ -6,6 +7,9 @@ import { DefaultRumMonitor } from './internal/monitor/DefaultRumMonitor'; import { RumAutoInstrumentation } from './internal/RumAutoInstrumentation'; import { RumNavigationTracker, NavContext } from './internal/RumNavigationTracker'; import { RumEventMapperHolder } from './internal/RumEventMapperHolder'; +import { RemoteConfigStore } from './internal/remoteconfig/RemoteConfigStore'; +import { RemoteConfigController } from './internal/remoteconfig/RemoteConfigController'; +import { HttpRemoteConfigFetcher, buildConfigUrl } from './internal/remoteconfig/RemoteConfigFetcher'; /** * Enables Real User Monitoring. Call after `Flashcat.initialize`. @@ -43,13 +47,55 @@ export class FlashcatRum { // monitor: attachMonitor synchronously triggers the pending-crash replay, // and a replayed crash error must pass through the mapper like any event. RumEventMapperHolder.configure(configuration.eventMapper); - const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration); + + // Remote configuration is off unless the app asked for it, and everything + // about it is best-effort: whatever fails here, RUM still collects with the + // values this configuration was built with. + let store: RemoteConfigStore | null = null; + let controller: RemoteConfigController | null = null; + if (configuration.remoteConfigurationEnabled) { + try { + const intake: IntakeTarget = core.getIntakeTarget(); + const context: FlashcatContext = core.getContext(); + store = new RemoteConfigStore( + core, RemoteConfigStore.buildStoreKey(context, intake.host, configuration.applicationId)); + controller = new RemoteConfigController( + store, + new HttpRemoteConfigFetcher(`flashcat-sdk-harmony/${context.sdkVersion}`), + buildConfigUrl(intake.host, intake.clientToken, context), + configuration.sessionSampleRate, + // Looked up when it fires rather than captured now: the monitor is + // registered after this is built, and by the time a response comes + // back it is there. + (): void => GlobalRumMonitor.get().stopSession() + ); + } catch (e) { + // Whatever went wrong here, the app keeps collecting with the values it + // was initialised with — never a reason to fail enable(). + store = null; + controller = null; + FlashcatLog.e(`rum.remoteconfig: not started (${FlashcatRum.errorText(e)}); init values apply`); + } + } + + const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration, store, controller); feature.attachMonitor(monitor); GlobalRumMonitor.register(monitor); // Phase 2: make the auto-instrumentation toggles consultable by the tap / // navigation trackers. Inert unless the corresponding flag is enabled. RumAutoInstrumentation.configure(configuration); + + // Started only after the monitor is registered: the response may ask for the + // session to be restarted, and that goes through the global monitor. + if (controller !== null) { + controller.start(); + } + } + + + private static errorText(e: Object): string { + return e instanceof Error ? e.message : 'error'; } /** diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index a8872cd..e9e4065 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -1,4 +1,4 @@ -import { RumEventMapper } from './RumTypes'; +import { RumEventMapper, BeforeSamplingCallback } from './RumTypes'; /** * RUM feature configuration. Built via {@link RumConfigurationBuilder}. @@ -13,6 +13,9 @@ export class RumConfiguration { readonly trackFrustrations: boolean; readonly trackErrors: boolean; // AUTO error capture (crashes unaffected) readonly eventMapper: RumEventMapper | null; // phase 2 R3: PII scrubbing / drop + /** Whether to read the sampling settings the console publishes. Default false. */ + readonly remoteConfigurationEnabled: boolean; + readonly beforeSampling: BeforeSamplingCallback | null; constructor( applicationId: string, @@ -22,7 +25,9 @@ export class RumConfiguration { trackNetworkRequests: boolean, trackFrustrations: boolean, eventMapper: RumEventMapper | null = null, - trackErrors: boolean = true + trackErrors: boolean = true, + remoteConfigurationEnabled: boolean = false, + beforeSampling: BeforeSamplingCallback | null = null ) { this.applicationId = applicationId; this.sessionSampleRate = sessionSampleRate; @@ -32,6 +37,8 @@ export class RumConfiguration { this.trackFrustrations = trackFrustrations; this.trackErrors = trackErrors; this.eventMapper = eventMapper; + this.remoteConfigurationEnabled = remoteConfigurationEnabled; + this.beforeSampling = beforeSampling; } } @@ -47,6 +54,8 @@ export class RumConfigurationBuilder { private trackFrustrations: boolean = false; private trackErrors: boolean = true; private eventMapper: RumEventMapper | null = null; + private remoteConfigurationEnabled: boolean = false; + private beforeSampling: BeforeSamplingCallback | null = null; constructor(applicationId: string) { this.applicationId = applicationId; @@ -107,6 +116,34 @@ export class RumConfigurationBuilder { return this; } + /** + * Read the sampling settings published for this application in the console. + * Default false — nothing is fetched, and every rate stays exactly what this + * builder was given. + * + * A published rate applies to the NEXT session drawn, never to the one + * running: a session is decided once, at its creation. Values already + * fetched survive a restart, so the first session of a launch is drawn under + * them; when nothing has ever been fetched, or the console has the feature + * switched off, the init values apply. + */ + setRemoteConfigurationEnabled(enabled: boolean): RumConfigurationBuilder { + this.remoteConfigurationEnabled = enabled; + return this; + } + + /** + * Have the last word on session sampling. Called synchronously each time a + * new session is about to be drawn, with the rate that would apply and the + * console's custom values; return a rate to override, or nothing to leave it + * alone. Typical use is an allow-list: keep every session of the handful of + * users you are debugging while the fleet stays at a low rate. + */ + setBeforeSampling(callback: BeforeSamplingCallback): RumConfigurationBuilder { + this.beforeSampling = callback; + return this; + } + build(): RumConfiguration { return new RumConfiguration( this.applicationId, @@ -116,7 +153,9 @@ export class RumConfigurationBuilder { this.trackNetworkRequests, this.trackFrustrations, this.eventMapper, - this.trackErrors + this.trackErrors, + this.remoteConfigurationEnabled, + this.beforeSampling ); } } diff --git a/flashcat-rum/src/main/ets/RumMonitor.ets b/flashcat-rum/src/main/ets/RumMonitor.ets index fe9f121..c1b7a3d 100644 --- a/flashcat-rum/src/main/ets/RumMonitor.ets +++ b/flashcat-rum/src/main/ets/RumMonitor.ets @@ -41,6 +41,25 @@ export interface RumMonitor { * closed with its final time_spent; the next tracked event starts a fresh * session and restarts the last view in it. */ + /** + * Force the session to be collected regardless of the configured sample + * rates. Call it when your own code decides a visitor needs debugging (an + * allow-list, a support flow). A session that was not being collected ends + * and a collected one starts in its place; a session already collected keeps + * running, and calling again while the forced session runs does nothing. The + * forced state lasts for the process lifetime — decide again on each launch. + */ + setForcedSession(): void; + + /** + * Read the custom values published for this application in the console. The + * SDK delivers them verbatim and never interprets them — what a value means + * is entirely up to your own code (a debug allow-list to pair with + * {@link setForcedSession}, a feature toggle). Values are cached across + * launches; null when remote configuration is off or nothing is published. + */ + getRemoteConfig(): Record | null; + stopSession(): void; } @@ -67,6 +86,10 @@ class NoOpRumMonitor implements RumMonitor { callback(undefined); } stopSession(): void {} + setForcedSession(): void {} + getRemoteConfig(): Record | null { + return null; + } } /** diff --git a/flashcat-rum/src/main/ets/RumTypes.ets b/flashcat-rum/src/main/ets/RumTypes.ets index a4376f7..2303f1c 100644 --- a/flashcat-rum/src/main/ets/RumTypes.ets +++ b/flashcat-rum/src/main/ets/RumTypes.ets @@ -11,6 +11,29 @@ */ export type RumEventMapper = (event: Record) => Record | null; +/** + * What the SDK is about to draw a new session with, handed to + * {@link BeforeSamplingCallback}: the rate that would apply (the console's + * where it published one, the init value where it did not) and the console's + * custom values, decoded. + */ +export interface BeforeSamplingContext { + /** 0..100. */ + readonly sessionSampleRate: number; + /** The console's custom bag, or null when nothing is published. */ + readonly custom: Record | null; +} + +/** + * The application's last word on session sampling, called synchronously each + * time a new session is about to be drawn. Return a rate to override — 100 + * always collects, 0 never does — or `undefined` to leave the incoming rate + * alone. Runs inside session creation, so it must be fast and synchronous; a + * throw or an out-of-range value is ignored. A session already under way is + * never re-decided. + */ +export type BeforeSamplingCallback = (context: BeforeSamplingContext) => number | undefined; + export enum RumActionType { TAP = 'tap', SCROLL = 'scroll', diff --git a/flashcat-rum/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index 4547fd6..60f90ec 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -227,6 +227,7 @@ export class RumFeature implements Feature, FeatureEventReceiver { this.activeJsCrashPolicy = null; 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 } RumNavigationTracker.detach(); // remove the router observer RumAutoInstrumentation.reset(); // make tap/nav trackers inert after stop @@ -263,6 +264,14 @@ export class RumFeature implements Feature, FeatureEventReceiver { const policy: string = RumFeature.asString(event['policy']); this.activeJsCrashPolicy = policy.length > 0 ? policy : null; FlashcatLog.d(`rum.error: crash policy owner enabled policy=${policy}`); + } else if (type === 'app_foreground') { + // Back from the background, where an in-process timer may not have run for + // hours. Whether this is worth a request is the controller's call (the + // console has to have asked for it, and the values have to be stale). + const foregrounded: DefaultRumMonitor | null = this.monitor; + if (foregrounded !== null) { + foregrounded.refreshRemoteConfig(); + } } 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). diff --git a/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets b/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets index 72850f3..e84f8a2 100644 --- a/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets +++ b/flashcat-rum/src/main/ets/internal/assembly/RumEventAssembler.ets @@ -1,5 +1,6 @@ import { FlashcatContext } from '@flashcatcloud/core'; import { util } from '@kit.ArkTS'; +import { DrawnConfiguration } from '../remoteconfig/RemoteConfigStore'; const VIEW_URL_ATTRIBUTE: string = 'view.url'; const BUILD_ID_ATTRIBUTE: string = 'build_id'; @@ -34,12 +35,13 @@ export class RumEventAssembler { crashCount: number, documentVersion: number, isActive: boolean, - attributes: Record + attributes: Record, + draw: DrawnConfiguration | null = null ): 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); + event['_dd'] = RumEventAssembler.dd(documentVersion, draw); event['application'] = RumEventAssembler.idObj(applicationId); event['session'] = RumEventAssembler.session(sessionId); @@ -238,13 +240,25 @@ export class RumEventAssembler { // ---- shared sub-objects ---- - private static dd(documentVersion: number): Record { + private static dd(documentVersion: number, draw: DrawnConfiguration | null = null): Record { const dd: Record = {}; dd['format_version'] = 2; dd['session'] = RumEventAssembler.sessionPlan(); if (documentVersion > 0) { dd['document_version'] = documentVersion; } + if (draw !== null) { + // The rate the session was ACTUALLY drawn with, not the one passed to + // 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. + const configuration: Record = {}; + configuration['session_sample_rate'] = draw.sessionSampleRate; + if (draw.version > 0) { + configuration['rc_version'] = draw.version; + } + dd['configuration'] = configuration; + } return dd; } diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index c862e68..820c341 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -5,6 +5,8 @@ import { RumConfiguration } from '../../RumConfiguration'; import { RumActionType, RumErrorSource, RumResourceKind, RumResourceMethod } from '../../RumTypes'; import { RumApplicationScope } from '../scope/RumApplicationScope'; import { RumRawEvent } from '../scope/RumScope'; +import { RemoteConfigStore, RemoteConfigValues } from '../remoteconfig/RemoteConfigStore'; +import { RemoteConfigController } from '../remoteconfig/RemoteConfigController'; const MS_TO_NS: number = 1e6; // Periodically re-emit the active view so its time_spent (and the session @@ -42,13 +44,59 @@ export class DefaultRumMonitor implements RumMonitor { private readonly globalAttributes: Map = new Map(); private readonly activeActions: Map = new Map(); private keepAliveTimerId: number = -1; + // Null unless the app opted into remote configuration. + private readonly remoteConfig: RemoteConfigStore | null; + private readonly controller: RemoteConfigController | null; - constructor(core: SdkCore, featureScope: FeatureScope, configuration: RumConfiguration) { + constructor( + core: SdkCore, + featureScope: FeatureScope, + configuration: RumConfiguration, + remoteConfig: RemoteConfigStore | null = null, + controller: RemoteConfigController | null = null + ) { + this.remoteConfig = remoteConfig; + this.controller = controller; this.applicationScope = new RumApplicationScope( - featureScope, core, configuration.applicationId, configuration.sessionSampleRate); + featureScope, core, configuration.applicationId, configuration.sessionSampleRate, + remoteConfig, configuration.beforeSampling, + controller === null ? null : (): void => controller.onSessionStarted()); this.startKeepAlive(); } + /** Refresh the console's configuration if it is stale and the console allows + * it — the core tells us the app came back to the foreground. */ + refreshRemoteConfig(): void { + if (this.controller !== null) { + this.controller.refreshIfStale(); + } + } + + /** Stop keeping the console's configuration fresh (SDK stop). */ + stopRemoteConfig(): void { + if (this.controller !== null) { + this.controller.stop(); + } + } + + setForcedSession(): void { + this.applicationScope.forceSession(Date.now()); + } + + getRemoteConfig(): Record | null { + if (this.remoteConfig === null) { + return null; + } + const stored: RemoteConfigValues | null = this.remoteConfig.read(); + if (stored === null || stored.custom === null) { + return null; + } + // Handed to the host application decoded: every other platform hands back a + // dictionary, and leaving one of them to parse a string would make the same + // console value cost more on HarmonyOS than anywhere else. + return RemoteConfigStore.decodeCustom(stored.custom); + } + startView(key: string, name: string, attributes?: Record): void { const e: RumRawEvent = this.raw('startView', attributes); e.key = key; diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets new file mode 100644 index 0000000..f3926ab --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -0,0 +1,317 @@ +import { FlashcatLog } from '@flashcatcloud/core'; +import { RemoteConfigStore, RemoteConfigValues } from './RemoteConfigStore'; +import { RemoteConfigFetcher, RemoteConfigResponse } from './RemoteConfigFetcher'; + +const DEFAULT_TTL_SECONDS: number = 300; +const ACTIVATION_IMMEDIATE: string = 'immediate'; +const RETRY_DELAYS_SECONDS: number[] = [5, 60]; +const JITTER_FRACTION: number = 0.2; +const MAX_RATE: number = 100; +const MS_PER_SECOND: number = 1000; +const HTTP_NOT_MODIFIED: number = 304; + +const FIELD_VERSION: string = 'version'; +const FIELD_TTL: string = 'ttl'; +const FIELD_ENABLED: string = 'enabled'; +const FIELD_ACTIVATION: string = 'activation'; +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'; + +/** + * Keeps the stored remote configuration in step with what the console says. + * + * Fetching follows the rhythm of the sessions that read it: once at start-up + * and once whenever a new session begins — a change can only matter at the next + * draw, so asking more often than sessions are drawn would be requests for + * nothing. There is no timer between sessions; the server's `ttl` only bounds + * how stale the stored values may be when the console allows a foreground + * refresh. + * + * Nothing here can hold up the SDK or interrupt collection: a trigger never + * waits on the request, and a request that fails, times out or comes back + * unreadable leaves the stored values exactly as they were. Wiping them on a + * bad minute would swing a whole fleet back to the values it was built with, + * the opposite of what someone who turned a knob deliberately wants. + */ +export class RemoteConfigController { + private readonly store: RemoteConfigStore; + private readonly fetcher: RemoteConfigFetcher; + private readonly configUrl: string; + private readonly initialSessionSampleRate: number; + private readonly restartSession: () => void; + + private lastFetchAtMs: number = 0; + private ttlSeconds: number = DEFAULT_TTL_SECONDS; + private refreshOnForeground: boolean = false; + private inFlight: boolean = false; + private failedAttempts: number = 0; + private pendingRetryId: number = -1; + private stopped: boolean = false; + + constructor( + store: RemoteConfigStore, + fetcher: RemoteConfigFetcher, + configUrl: string, + initialSessionSampleRate: number, + restartSession: () => void + ) { + this.store = store; + this.fetcher = fetcher; + this.configUrl = configUrl; + this.initialSessionSampleRate = initialSessionSampleRate; + this.restartSession = restartSession; + } + + start(): void { + this.triggerFetch(); + } + + /** + * A new session is the one moment a changed configuration can matter: its + * draw has just happened with whatever was stored, and this response lands in + * storage for the next draw. It never waits for the request — a session is + * never delayed by the network. + */ + onSessionStarted(): void { + this.triggerFetch(); + } + + /** + * Asks again when the app returns to the foreground, where timers cannot be + * trusted: the system may not have run them for hours. + * + * Off unless an operator turned it on for this application. Session starts + * spread requests across the day; returning to the foreground does the + * opposite, bunching them at the moment everyone opens the app — the same + * shape as a release herd, arriving when the endpoint can least absorb it. + * The staleness check is the second guard: it keeps switching between apps + * from turning into a request each time. + */ + refreshIfStale(): void { + const ageMs: number = Date.now() - this.lastFetchAtMs; + if (RemoteConfigController.shouldRefreshOnForeground(this.refreshOnForeground, ageMs, this.ttlSeconds)) { + this.triggerFetch(); + } + } + + stop(): void { + this.stopped = true; + this.cancelRetry(); + } + + /** + * Runs a fetch now, dropping any retry still waiting: a natural trigger + * re-arms the whole backoff, so a session starting in the middle of an outage + * does not wait out the patient retry before asking again. + */ + private triggerFetch(): void { + if (this.stopped) { + return; + } + this.cancelRetry(); + this.failedAttempts = 0; + if (this.inFlight) { + return; + } + this.inFlight = true; + this.fetchOnce(); + } + + private fetchOnce(): void { + // Stamped before the request goes out, so a request that never comes back + // still counts as an attempt for the staleness gate instead of leaving the + // app asking again on every foreground. + this.lastFetchAtMs = Date.now(); + const applied: number | null = this.store.appliedVersion(); + const url: string = applied === null ? this.configUrl : `${this.configUrl}&applied_version=${applied}`; + const stored: RemoteConfigValues | null = this.store.read(); + // The answer varies per caller, so the validator only means something + // paired with the configuration it validated: stored beside it, echoed back + // exactly as sent. + const ifNoneMatch: string | null = stored === null ? null : stored.etag; + + this.fetcher.fetch(url, ifNoneMatch) + .then((response: RemoteConfigResponse) => { + this.inFlight = false; + // A request already on the wire cannot be recalled, so the answer can + // arrive after the SDK was stopped. Applying it then would write to + // storage and restart a session on behalf of a torn-down feature. + if (this.stopped) { + return; + } + // Unchanged: what is stored is still the answer, so there is nothing to + // apply — but the ask itself succeeded, and no retry is owed. + if (response.code === HTTP_NOT_MODIFIED) { + return; + } + if (response.code < 200 || response.code >= 300) { + this.scheduleRetry(); + return; + } + if (!this.apply(response.body, response.etag)) { + this.scheduleRetry(); + } + }) + .catch((e: Object) => { + this.inFlight = false; + if (this.stopped) { + return; + } + FlashcatLog.d(`rum.remoteconfig: fetch failed (${RemoteConfigController.errorText(e)}); keeping the values already in use`); + this.scheduleRetry(); + }); + } + + /** + * A failed fetch is retried quickly, then patiently, then not at all until the + * next natural trigger (a new session, or the next app start). The budget is + * deliberately tiny — two extra requests per outage per client — so a fleet + * can never turn an endpoint incident into a storm. + */ + private scheduleRetry(): void { + if (this.stopped || this.failedAttempts >= RETRY_DELAYS_SECONDS.length) { + return; + } + const delaySeconds: number = + RemoteConfigController.jittered(RETRY_DELAYS_SECONDS[this.failedAttempts], Math.random()); + this.failedAttempts++; + this.pendingRetryId = setTimeout(() => { + this.pendingRetryId = -1; + if (this.stopped || this.inFlight) { + return; + } + this.inFlight = true; + this.fetchOnce(); + }, delaySeconds * MS_PER_SECOND); + } + + private cancelRetry(): void { + if (this.pendingRetryId !== -1) { + clearTimeout(this.pendingRetryId); + this.pendingRetryId = -1; + } + } + + /** + * Stores what the response carried and, when the console asked for it, + * restarts the session so the new values take hold now instead of at the + * visitor's next one. Returns false when the body could not be read at all — + * that is a failed ask, not an empty configuration. + * + * The session is only restarted when the values THIS client will draw with + * really changed. Without that check, a console resending an unchanged + * configuration would cut every session in two on every fetch. + */ + apply(payload: string, etag: string | null): boolean { + const json: Record | null = RemoteConfigController.parseObject(payload); + if (json === null) { + return false; + } + const enabled: boolean = json[FIELD_ENABLED] === true; + const activation: string = RemoteConfigController.stringOf(json[FIELD_ACTIVATION]) ?? ''; + this.refreshOnForeground = json[FIELD_REFRESH_ON_FOREGROUND] === true; + + const before: number | null = this.storedRate(); + const version: number | null = RemoteConfigController.positiveIntOf(json[FIELD_VERSION]); + const rate: number | null = enabled + ? RemoteConfigController.readRate(RemoteConfigController.objectOf(json[FIELD_RUM])) + : null; + // 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; + this.store.write(new RemoteConfigValues(rate, version, custom, etag)); + + if (activation === ACTIVATION_IMMEDIATE && this.changesThisClient(before, rate)) { + this.restartSession(); + } + + // Remembered here rather than around the request, so a fetch that fails + // keeps the ttl the server last asked for instead of falling back to ours. + const ttl: number | null = RemoteConfigController.positiveIntOf(json[FIELD_TTL]); + this.ttlSeconds = ttl ?? DEFAULT_TTL_SECONDS; + return true; + } + + private storedRate(): number | null { + const stored: RemoteConfigValues | null = this.store.read(); + return stored === null ? null : stored.sessionSampleRate; + } + + private changesThisClient(before: number | null, after: number | null): boolean { + return (before ?? this.initialSessionSampleRate) !== (after ?? this.initialSessionSampleRate); + } + + /** + * Whether returning to the foreground is a reason to ask again. Both halves + * guard different things: the permission keeps the request pattern off unless + * someone chose it, and the age keeps app switching from becoming a request + * each time. + */ + static shouldRefreshOnForeground(allowed: boolean, ageMs: number, ttlSeconds: number): boolean { + return allowed && ageMs >= ttlSeconds * MS_PER_SECOND; + } + + /** + * Spreads a delay by ±20%. An endpoint incident aligns every failed client's + * retry clock to the same moment; without this, recovery would be greeted by + * the whole fleet at once. + */ + static jittered(seconds: number, jitter: number): number { + return seconds * (1 - JITTER_FRACTION + 2 * JITTER_FRACTION * jitter); + } + + /** A value the response did not send stays absent, so the value passed to init + * keeps applying. An out-of-range number is treated the same way rather than + * clamped: a rate we cannot trust is not a rate to sample traffic with. */ + private static readRate(rum: Record | null): number | null { + if (rum === null) { + return null; + } + const value: Object | undefined = rum[FIELD_SESSION_SAMPLE_RATE]; + if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > MAX_RATE) { + return null; + } + return value as number; + } + + private static parseObject(payload: string): Record | null { + try { + const value: Object = JSON.parse(payload) as Object; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + return value as Record; + } catch (_e) { + return null; + } + } + + private static objectOf(value: Object | undefined): Record | null { + if (value === undefined || typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + return value as Record; + } + + private static rawObjectOf(value: Object | undefined): string | null { + const record: Record | null = RemoteConfigController.objectOf(value); + return record === null ? null : JSON.stringify(record); + } + + private static positiveIntOf(value: Object | undefined): number | null { + if (typeof value !== 'number' || Number.isNaN(value) || (value as number) <= 0) { + return null; + } + return Math.trunc(value as number); + } + + private static stringOf(value: Object | undefined): string | null { + return typeof value === 'string' ? value as string : null; + } + + private static errorText(e: Object): string { + return e instanceof Error ? e.message : 'network error'; + } +} diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets new file mode 100644 index 0000000..78c2ecc --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets @@ -0,0 +1,87 @@ +import { http } from '@kit.NetworkKit'; +import { FlashcatContext, FlashcatLog } from '@flashcatcloud/core'; + +const CONFIG_PATH: string = '/api/v2/rum/config'; +const TIMEOUT_MS: number = 10000; +const HEADER_IF_NONE_MATCH: string = 'If-None-Match'; + +/** One answer from the configuration endpoint. */ +export interface RemoteConfigResponse { + code: number; + body: string; + /** The validator to store beside the values and echo back next time. */ + etag: string | null; +} + +/** + * How the controller asks. A seam, not an abstraction for its own sake: it is + * what lets the retry / staleness / parsing rules be tested without a network, + * which is the half of this feature that must not be got wrong. + */ +export interface RemoteConfigFetcher { + fetch(url: string, ifNoneMatch: string | null): Promise; +} + +/** Real fetcher, on the same NetworkKit stack the intake uploads run on. */ +export class HttpRemoteConfigFetcher implements RemoteConfigFetcher { + private readonly userAgent: string; + + constructor(userAgent: string) { + this.userAgent = userAgent; + } + + async fetch(url: string, ifNoneMatch: string | null): Promise { + const request: http.HttpRequest = http.createHttp(); + try { + const header: Record = { 'User-Agent': this.userAgent }; + if (ifNoneMatch !== null) { + header[HEADER_IF_NONE_MATCH] = ifNoneMatch; + } + const response: http.HttpResponse = await request.request(url, { + method: http.RequestMethod.GET, + header: header, + expectDataType: http.HttpDataType.STRING, + connectTimeout: TIMEOUT_MS, + readTimeout: TIMEOUT_MS + }); + const body: string = typeof response.result === 'string' ? response.result as string : ''; + FlashcatLog.d(`rum.remoteconfig: GET ${CONFIG_PATH} -> ${response.responseCode}`); + return { code: response.responseCode as number, body: body, etag: HttpRemoteConfigFetcher.etagOf(response) }; + } finally { + request.destroy(); + } + } + + /** Header names are case-insensitive on the wire and the stack does not + * normalise them, so both spellings are read before giving up. */ + private static etagOf(response: http.HttpResponse): string | null { + const headers: Record = response.header as Record; + if (headers === undefined || headers === null) { + return null; + } + const value: Object | undefined = headers['etag'] ?? headers['ETag']; + return typeof value === 'string' && (value as string).length > 0 ? value as string : null; + } +} + +/** + * Where to ask. A custom endpoint means the app was pointed at the customer's + * own host for the RUM intake, and the configuration lives beside it there — + * exactly the layout the private-deployment nginx template serves. + * + * The SDK version rides along purely as information: it keys nothing on this + * side (see the store key), and the server may one day target a configuration + * at a range of them. + */ +export function buildConfigUrl(host: string, clientToken: string, context: FlashcatContext): string { + const base: string = host.endsWith('/') ? host.substring(0, host.length - 1) : host; + let query: string = `?client_token=${encodeURIComponent(clientToken)}&sdk=harmony`; + query += appendParam('env', context.env); + query += appendParam('app_version', context.version); + query += appendParam('sdk_version', context.sdkVersion); + return `${base}${CONFIG_PATH}${query}`; +} + +function appendParam(key: string, value: string): string { + return value.length === 0 ? '' : `&${key}=${encodeURIComponent(value)}`; +} diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets new file mode 100644 index 0000000..7cd7933 --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -0,0 +1,201 @@ +import { SdkCore, FlashcatContext } from '@flashcatcloud/core'; + +/** + * The values one configuration response carried. `null` means the console did + * not set that knob, so whatever was passed to init keeps applying — an absent + * knob and a knob set to zero are different answers, and collapsing them would + * silently switch a customer's collection off. + */ +export class RemoteConfigValues { + readonly sessionSampleRate: number | null; + readonly version: number | null; + /** Raw JSON object string of the console's custom bag, delivered verbatim. */ + readonly custom: string | null; + /** Validator echoed back as If-None-Match, quoted exactly as the server sent it. */ + readonly etag: string | null; + + constructor( + sessionSampleRate: number | null, + version: number | null = null, + custom: string | null = null, + etag: string | null = null + ) { + this.sessionSampleRate = sessionSampleRate; + this.version = version; + this.custom = custom; + this.etag = etag; + } +} + +/** + * 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. + */ +export class DrawnConfiguration { + /** 0 when no configuration was ever fetched. */ + readonly version: number; + readonly sessionSampleRate: number; + + constructor(version: number, sessionSampleRate: number) { + this.version = version; + this.sessionSampleRate = sessionSampleRate; + } +} + +const FIELD_RATE: string = 'rate'; +const FIELD_VERSION: string = 'version'; +const FIELD_CUSTOM: string = 'custom'; +const FIELD_ETAG: string = 'etag'; +const MAX_RATE: number = 100; + +/** + * Keeps the console's configuration across launches. Feature modules get no + * HarmonyOS Context of their own, so the bytes go through the core's settings + * store; everything above that line is this module's business. + * + * Storage that cannot be read is not an error state: the SDK simply runs on the + * values the app was initialised with. + */ +export class RemoteConfigStore { + private readonly core: SdkCore; + private readonly storeKey: string; + + constructor(core: SdkCore, storeKey: string) { + this.core = core; + this.storeKey = storeKey; + } + + /** What the last readable response left here, or null before the first one. */ + read(): RemoteConfigValues | null { + const serialized: string | null = this.core.readSetting(this.valuesKey()); + if (serialized === null) { + return null; + } + return RemoteConfigStore.parseValues(serialized); + } + + /** + * Replaces what is stored with what the response carried. A knob the response + * omitted is dropped rather than left behind, so turning a knob off in the + * console really does hand it back to the value the app was initialised with. + */ + write(values: RemoteConfigValues): void { + const record: Record = {}; + if (values.sessionSampleRate !== null) { + record[FIELD_RATE] = values.sessionSampleRate; + } + // Kept even when no rates resolved — that is what "remote configuration is + // off, use your own settings" looks like — so the console can still see + // this client is up to date with the change that turned them off. + if (values.version !== null) { + record[FIELD_VERSION] = values.version; + } + if (values.custom !== null) { + record[FIELD_CUSTOM] = values.custom; + } + if (values.etag !== null) { + record[FIELD_ETAG] = values.etag; + } + this.core.writeSetting(this.valuesKey(), JSON.stringify(record)); + } + + /** + * The console's custom bag, decoded. A body we cannot parse reads as nothing + * published rather than as an error: the bag is application-defined, and no + * rate or decision of ours depends on it. + */ + static decodeCustom(raw: string | null): Record | null { + if (raw === null) { + return null; + } + return RemoteConfigStore.parseObject(raw); + } + + /** Which version the stored values came from, or null before the first answer. + * Reported back on the next request so the console can say how far a change + * has reached — a question events cannot answer, because a session that was + * not kept sends none, and the miss rate is set by the very rate being changed. */ + appliedVersion(): number | null { + const values: RemoteConfigValues | null = this.read(); + return values === null ? null : values.version; + } + + private valuesKey(): string { + return `${this.storeKey}.values`; + } + + + /** + * Identifies whose configuration this is. It covers everything that can + * change the answer — which host is asked, which application, in which + * environment, at which app version — so an app that ships a new version, or + * two applications on one device, never read each other's values. + * + * 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. + */ + 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('|')}`; + } + + private static hostOf(url: string): string { + const schemeEnd: number = url.indexOf('://'); + const afterScheme: string = schemeEnd >= 0 ? url.substring(schemeEnd + 3) : url; + const pathStart: number = afterScheme.indexOf('/'); + return pathStart >= 0 ? afterScheme.substring(0, pathStart) : afterScheme; + } + + private static parseValues(serialized: string): RemoteConfigValues | null { + const record: Record | null = RemoteConfigStore.parseObject(serialized); + if (record === null) { + return null; + } + return new RemoteConfigValues( + RemoteConfigStore.rateOf(record[FIELD_RATE]), + RemoteConfigStore.intOf(record[FIELD_VERSION]), + RemoteConfigStore.stringOf(record[FIELD_CUSTOM]), + RemoteConfigStore.stringOf(record[FIELD_ETAG]) + ); + } + + private static parseObject(serialized: string): Record | null { + try { + const value: Object = JSON.parse(serialized) as Object; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + return value as Record; + } catch (_e) { + return null; + } + } + + /** An out-of-range rate is treated as absent rather than clamped: a rate we + * cannot trust is not a rate to sample a customer's traffic with. */ + private static rateOf(value: Object | undefined): number | null { + if (typeof value !== 'number' || Number.isNaN(value) || value < 0 || value > MAX_RATE) { + return null; + } + return value as number; + } + + private static intOf(value: Object | undefined): number | null { + if (typeof value !== 'number' || Number.isNaN(value)) { + return null; + } + return Math.trunc(value as number); + } + + private static stringOf(value: Object | undefined): string | null { + return typeof value === 'string' && (value as string).length > 0 ? value as string : null; + } +} diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 9cd3df2..0a53f2e 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -1,6 +1,8 @@ import { FeatureScope, SdkCore, FlashcatLog } from '@flashcatcloud/core'; import { RumScope, RumRawEvent } from './RumScope'; import { RumSessionScope } from './RumSessionScope'; +import { RemoteConfigStore, RemoteConfigValues, DrawnConfiguration } from '../remoteconfig/RemoteConfigStore'; +import { BeforeSamplingCallback, BeforeSamplingContext } from '../../RumTypes'; /** * Root of the RUM scope tree. Creates and replaces sessions as they expire. @@ -24,12 +26,33 @@ export class RumApplicationScope implements RumScope { // renewal) used to discard every in-flight scope — a request 2 s old that // merely spanned the renewal boundary was lost. private drainingSession: RumSessionScope | null = null; + // Null unless the app opted into remote configuration; then it is where the + // rates the console published are read from at every draw. + private readonly remoteConfig: RemoteConfigStore | null; + private readonly beforeSampling: BeforeSamplingCallback | null; + // Told after every draw, so the configuration is refreshed at the rhythm of + // the sessions that read it. + private readonly onSessionStarted: (() => void) | null; + // Set through RumMonitor.setForcedSession, read at every draw from then on. + // Process-lifetime, like the debugging decision it represents. + private forced: boolean = false; - constructor(featureScope: FeatureScope, core: SdkCore, applicationId: string, sampleRate: number) { + constructor( + featureScope: FeatureScope, + core: SdkCore, + applicationId: string, + sampleRate: number, + remoteConfig: RemoteConfigStore | null = null, + beforeSampling: BeforeSamplingCallback | null = null, + onSessionStarted: (() => void) | null = null + ) { this.featureScope = featureScope; this.core = core; this.applicationId = applicationId; this.sampleRate = sampleRate; + this.remoteConfig = remoteConfig; + this.beforeSampling = beforeSampling; + this.onSessionStarted = onSessionStarted; } handleEvent(event: RumRawEvent): RumScope | null { @@ -183,7 +206,74 @@ export class RumApplicationScope implements RumScope { return this.session?.getSessionId(); } + /** + * Draws a new session under the settings that apply RIGHT NOW: what the + * console published if anything, the init value otherwise, and finally + * whatever the app's own hook says. Order matters — the hook is the last + * word precisely so an allow-list can keep collecting a visitor the console's + * rate would drop. + */ private startNewSession(nowMs: number): RumSessionScope { - return new RumSessionScope(this.featureScope, this.core, this.applicationId, this.sampleRate, nowMs); + 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); + const version: number = stored !== null && stored.version !== null ? stored.version : 0; + // Only meaningful when remote configuration is on: without it the rate on + // the event would just repeat what the app was built with, and every event + // would grow a field carrying no information. + const draw: DrawnConfiguration | null = + this.remoteConfig !== null ? new DrawnConfiguration(version, rate) : null; + const session: RumSessionScope = new RumSessionScope( + this.featureScope, this.core, this.applicationId, rate, nowMs, this.forced, draw); + if (this.onSessionStarted !== null) { + this.onSessionStarted(); + } + return session; + } + + /** + * 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. + */ + private askBeforeSampling(rate: number, stored: RemoteConfigValues | null): number { + const hook: BeforeSamplingCallback | null = this.beforeSampling; + if (hook === null) { + return rate; + } + try { + const custom: Record | null = + stored !== null ? RemoteConfigStore.decodeCustom(stored.custom) : null; + const context: BeforeSamplingContext = { sessionSampleRate: rate, custom: custom }; + const override: number | undefined = hook(context); + if (override === undefined || typeof override !== 'number' + || Number.isNaN(override) || override < 0 || override > 100) { + return rate; + } + return override as number; + } catch (e) { + FlashcatLog.e(`rum.app: beforeSampling threw, keeping ${rate}: ${e instanceof Error ? e.message : 'error'}`); + return rate; + } } + + /** + * RumMonitor.setForcedSession: from here on every draw keeps the session. A + * session already being collected keeps running — RUM cannot retro-collect + * what a running session already dropped — while one that was not collected + * ends now so a collected one starts in its place. + */ + forceSession(nowMs: number): void { + if (this.forced && this.session !== null && this.session.isSampled()) { + return; // already forced and collecting — nothing to do + } + this.forced = true; + if (this.session !== null && this.session.isSampled()) { + 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 68c948b..f892841 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -4,6 +4,7 @@ import { RumScope, RumRawEvent } from './RumScope'; import { RumViewScope } from './RumViewScope'; import { RumEventAssembler } from '../assembly/RumEventAssembler'; import { writeMapped } from '../RumEventMapperHolder'; +import { DrawnConfiguration } from '../remoteconfig/RemoteConfigStore'; const SESSION_INACTIVITY_MS: number = 15 * 60 * 1000; // 15 min const SESSION_MAX_DURATION_MS: number = 4 * 60 * 60 * 1000; // 4 h @@ -21,6 +22,8 @@ export class RumSessionScope implements RumScope { private readonly applicationId: string; private readonly sessionId: string; private readonly sampled: boolean; + // The settings this session was drawn under, carried onto its view documents. + private readonly draw: DrawnConfiguration | null; private readonly startedAtMs: number; private lastActivityMs: number; private lastKeepAliveMs: number = 0; @@ -48,7 +51,9 @@ export class RumSessionScope implements RumScope { core: SdkCore, applicationId: string, sampleRate: number, - startedAtMs: number + startedAtMs: number, + forced: boolean = false, + draw: DrawnConfiguration | null = null ) { this.featureScope = featureScope; this.core = core; @@ -56,7 +61,10 @@ export class RumSessionScope implements RumScope { this.sessionId = util.generateRandomUUID(true); this.startedAtMs = startedAtMs; this.lastActivityMs = startedAtMs; - this.sampled = RumSessionScope.decideSampling(sampleRate); + // 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); + this.draw = draw; const update: Record = {}; update['session.id'] = this.sessionId; update['session.sampled'] = this.sampled; @@ -130,7 +138,7 @@ 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); + event.key ?? '', event.name ?? '', viewId, event.timestampMs, event.attributes, this.draw); return this; } diff --git a/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets index e7cdf4a..3c4fe9e 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumViewScope.ets @@ -3,6 +3,7 @@ import { RumScope, RumRawEvent } from './RumScope'; import { RumResourceScope } from './RumResourceScope'; import { RumEventAssembler } from '../assembly/RumEventAssembler'; import { writeMapped } from '../RumEventMapperHolder'; +import { DrawnConfiguration } from '../remoteconfig/RemoteConfigStore'; const MS_TO_NS: number = 1e6; const MAX_PENDING_RESOURCES: number = 100; @@ -24,6 +25,10 @@ export class RumViewScope implements RumScope { private readonly viewId: string; private readonly startedAtMs: number; private readonly attributes: Record; + // What this session was drawn under. Reported on the view document (the only + // 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; private actionCount: number = 0; private errorCount: number = 0; private resourceCount: number = 0; @@ -47,7 +52,8 @@ export class RumViewScope implements RumScope { viewName: string, viewId: string, startedAtMs: number, - attributes: Record + attributes: Record, + draw: DrawnConfiguration | null = null ) { this.featureScope = featureScope; this.core = core; @@ -58,6 +64,7 @@ export class RumViewScope implements RumScope { this.viewId = viewId; this.startedAtMs = startedAtMs; this.attributes = attributes; + this.draw = draw; // 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 @@ -265,7 +272,8 @@ export class RumViewScope implements RumScope { this.featureScope.withWriteContext((context: FlashcatContext, writer: EventWriter) => { 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.startedAtMs, timeSpentNs, actions, errors, resources, crashes, version, isActive, + this.viewAttributes(), this.draw); writeMapped(writer, event, false); }); } diff --git a/flashcat-rum/src/test/List.test.ets b/flashcat-rum/src/test/List.test.ets index 8de512d..9292db0 100644 --- a/flashcat-rum/src/test/List.test.ets +++ b/flashcat-rum/src/test/List.test.ets @@ -6,6 +6,7 @@ import phase2Tests from './Phase2AutoInstrumentation.test'; import schemaAlignmentTests from './SchemaAlignment.test'; import resourceKindTests from './ResourceKind.test'; import crashAttributionTests from './CrashAttribution.test'; +import remoteConfigTests from './RemoteConfig.test'; export default function testsuite(): void { crashReportTests(); @@ -13,6 +14,7 @@ export default function testsuite(): void { schemaAlignmentTests(); resourceKindTests(); crashAttributionTests(); + remoteConfigTests(); describe('flashcat-rum', (): void => { it('defaultMonitorIsNoOp', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const monitor = GlobalRumMonitor.get(); diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets new file mode 100644 index 0000000..766af94 --- /dev/null +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -0,0 +1,490 @@ +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 { RemoteConfigStore, RemoteConfigValues } from '../main/ets/internal/remoteconfig/RemoteConfigStore'; +import { RemoteConfigController } from '../main/ets/internal/remoteconfig/RemoteConfigController'; +import { RemoteConfigFetcher, RemoteConfigResponse, buildConfigUrl } from '../main/ets/internal/remoteconfig/RemoteConfigFetcher'; +import { BeforeSamplingContext } from '../main/ets/RumTypes'; + +function testContext(appVersion: string = '1.0.0'): FlashcatContext { + return { + env: 'prod', + variant: '', + service: 'svc', + version: appVersion, + bundleId: 'com.example.demo', + source: 'harmony', + sdkVersion: '0.5.1', + 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 settings: Map = new Map(); + private readonly appVersion: string; + /** Simulates a device where the settings store cannot be opened at all. */ + storageUnavailable: boolean = false; + + constructor(appVersion: string = '1.0.0') { + this.appVersion = appVersion; + } + + 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 {} + getContext(): FlashcatContext { + return testContext(this.appVersion); + } + getTrackingConsent(): TrackingConsent { + return TrackingConsent.GRANTED; + } + isActive(): boolean { + return true; + } + setUserInfo(_user: UserInfo): void {} + clearUserInfo(): void {} + getIntakeTarget(): IntakeTarget { + return { host: 'https://intake.example.com', clientToken: 'ct-123' }; + } + readSetting(key: string): string | null { + if (this.storageUnavailable) { + return null; + } + const value: string | undefined = this.settings.get(key); + return value === undefined ? null : value; + } + writeSetting(key: string, value: string | null): void { + if (this.storageUnavailable) { + return; + } + if (value === null) { + this.settings.delete(key); + } else { + this.settings.set(key, value); + } + } +} + +class CapturingScope implements FeatureScope { + readonly written: Array> = []; + + withWriteContext(callback: (context: FlashcatContext, writer: EventWriter) => void): void { + const sink: Array> = this.written; + const writer: EventWriter = { + write: (event: Record, _forceFlush?: boolean): boolean => { + sink.push(event); + return true; + } + }; + callback(testContext(), writer); + } + + sendEvent(_event: Record): void {} +} + +/** Answers whatever the test queued, and records what it was asked. */ +class FakeFetcher implements RemoteConfigFetcher { + readonly urls: string[] = []; + readonly validators: Array = []; + private response: RemoteConfigResponse | null = null; + private failure: string | null = null; + + answer(code: number, body: string, etag: string | null = null): void { + this.response = { code: code, body: body, etag: etag }; + this.failure = null; + } + + fail(message: string): void { + this.failure = message; + this.response = null; + } + + fetch(url: string, ifNoneMatch: string | null): Promise { + this.urls.push(url); + this.validators.push(ifNoneMatch); + if (this.failure !== null) { + return Promise.reject(new Error(this.failure)); + } + return Promise.resolve(this.response as RemoteConfigResponse); + } +} + +function newStore(core: FakeCore): RemoteConfigStore { + return new RemoteConfigStore(core, RemoteConfigStore.buildStoreKey(core.getContext(), 'https://intake.example.com', 'app-1')); +} + +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): void { + const e: RumRawEvent = raw('startView', atMs, 'home'); + e.name = 'Home'; + app.handleEvent(e); +} + +function sessionIdOf(event: Record): string { + const session: Record = event['session'] as Record; + return session !== undefined ? session['id'] as string : ''; +} + +function configurationOf(event: Record): Record | undefined { + const dd: Record = event['_dd'] as Record; + return dd === undefined ? undefined : dd['configuration'] as Record; +} + +/** Body the engine sends: rates live under `rum`, the app's bag under `custom`. */ +function body(version: number, enabled: boolean, rate: number | null, + activation: string = 'next_session', custom: string = ''): string { + const parts: string[] = [`"version":${version}`, `"ttl":600`, `"enabled":${enabled}`, `"activation":"${activation}"`]; + parts.push(rate === null ? '"rum":{}' : `"rum":{"sessionSampleRate":${rate}}`); + if (custom.length > 0) { + parts.push(`"custom":${custom}`); + } + return `{${parts.join(',')}}`; +} + +export default function remoteConfigTests(): void { + describe('rum-remote-config-store', (): void => { + it('keepsAbsentKnobsAbsent', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(null, 7, null, '"tag"')); + const read: RemoteConfigValues | null = store.read(); + expect(read !== null).assertTrue(); + // A version with no rates is what "the console turned the feature off" + // looks like: the client is up to date, and its init rate applies. + expect((read as RemoteConfigValues).sessionSampleRate).assertNull(); + expect((read as RemoteConfigValues).version).assertEqual(7); + expect((read as RemoteConfigValues).etag).assertEqual('"tag"'); + }); + + it('roundTripsPublishedValues', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(42.5, 3, '{"tier":"vip"}', '"e1"')); + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertEqual(42.5); + expect(read.custom).assertEqual('{"tier":"vip"}'); + expect(store.appliedVersion()).assertEqual(3); + }); + + it('readsCorruptEntryAsNothingStored', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(50, 1, null, null)); + core.settings.forEach((_v: string, k: string) => core.settings.set(k, 'not json')); + expect(store.read()).assertNull(); + expect(store.appliedVersion()).assertNull(); + }); + + it('unavailableStorageReadsAsNothingStored', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + core.storageUnavailable = true; + store.write(new RemoteConfigValues(50, 1, null, null)); + expect(store.read()).assertNull(); + }); + + it('separatesAppVersions', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const first: string = RemoteConfigStore.buildStoreKey(testContext('1.0.0'), 'https://intake.example.com/', 'app-1'); + const same: string = RemoteConfigStore.buildStoreKey(testContext('1.0.0'), 'https://intake.example.com', 'app-1'); + const shipped: string = RemoteConfigStore.buildStoreKey(testContext('2.0.0'), 'https://intake.example.com', 'app-1'); + const otherApp: string = RemoteConfigStore.buildStoreKey(testContext('1.0.0'), 'https://intake.example.com', 'app-2'); + expect(first).assertEqual(same); // a trailing slash is not a different host + expect(first === shipped).assertFalse(); + expect(first === otherApp).assertFalse(); + }); + }); + + describe('rum-remote-config-controller', (): void => { + it('storesWhatThePublishedConfigurationCarried', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + let restarts: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config?client_token=t', 100, (): void => { restarts++; }); + + expect(controller.apply(body(9, true, 25, 'next_session', '{"vip":["u1"]}'), '"e9"')).assertTrue(); + + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertEqual(25); + expect(read.version).assertEqual(9); + expect(read.custom).assertEqual('{"vip":["u1"]}'); + expect(read.etag).assertEqual('"e9"'); + expect(restarts).assertEqual(0); // next_session: nobody's session is cut short + }); + + it('treatsAnOutOfRangeRateAsAbsent', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + controller.apply(body(2, true, 140), null); + // Not clamped to 100: a rate we cannot trust is not a rate to sample with, + // so the value the app was initialised with keeps applying. + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertNull(); + expect((store.read() as RemoteConfigValues).version).assertEqual(2); + }); + + it('killSwitchHandsTheKnobsBack', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + controller.apply(body(4, true, 10, 'next_session', '{"tier":"vip"}'), null); + controller.apply(body(5, false, 10, 'next_session', '{"tier":"vip"}'), null); + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertNull(); + expect(read.custom).assertNull(); + expect(read.version).assertEqual(5); // still traceable to the change that switched it off + }); + + it('unreadableBodyIsAFailedAskNotAnEmptyConfiguration', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + controller.apply(body(3, true, 30), null); + expect(controller.apply('gateway error', null)).assertFalse(); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(30); + }); + + it('immediateActivationOnlyCutsSessionsWhenThisClientsRateChanged', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + let restarts: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', 100, (): void => { restarts++; }); + + controller.apply(body(1, true, 20, 'immediate'), null); + expect(restarts).assertEqual(1); + // The console republishing the same rate must not cut every session in two. + controller.apply(body(2, true, 20, 'immediate'), null); + expect(restarts).assertEqual(1); + controller.apply(body(3, true, 60, 'immediate'), null); + expect(restarts).assertEqual(2); + }); + + it('foregroundRefreshNeedsPermissionAndAge', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(RemoteConfigController.shouldRefreshOnForeground(false, 999999, 60)).assertFalse(); + expect(RemoteConfigController.shouldRefreshOnForeground(true, 1000, 60)).assertFalse(); + expect(RemoteConfigController.shouldRefreshOnForeground(true, 60000, 60)).assertTrue(); + }); + + it('spreadsRetriesAroundTheAskedDelay', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(Math.round(RemoteConfigController.jittered(10, 0))).assertEqual(8); + expect(Math.round(RemoteConfigController.jittered(10, 1))).assertEqual(12); + expect(Math.round(RemoteConfigController.jittered(10, 0.5))).assertEqual(10); + }); + + it('keepsStoredValuesWhenTheRequestFails', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config?client_token=t', 100, (): void => {}); + controller.apply(body(6, true, 15), '"e6"'); + + fetcher.fail('connection reset'); + controller.start(); + await Promise.resolve(); + await Promise.resolve(); + controller.stop(); // drop the scheduled retry so the test leaves no timer + + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertEqual(15); + expect(read.version).assertEqual(6); + }); + + it('carriesTheAppliedVersionAndValidatorOnTheRequest', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config?client_token=t', 100, (): void => {}); + controller.apply(body(6, true, 15), '"e6"'); + + fetcher.answer(304, ''); + controller.start(); + await Promise.resolve(); + await Promise.resolve(); + controller.stop(); + + expect(fetcher.urls[0]).assertEqual('https://x/config?client_token=t&applied_version=6'); + expect(fetcher.validators[0]).assertEqual('"e6"'); + // 304: what is stored is still the answer. + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(15); + }); + + it('buildsTheConfigUrlTheEngineAnswers', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const url: string = buildConfigUrl('https://intake.example.com/', 'ct 123', testContext('2.3.4')); + expect(url).assertEqual( + 'https://intake.example.com/api/v2/rum/config?client_token=ct%20123&sdk=harmony&env=prod&app_version=2.3.4&sdk_version=0.5.1'); + }); + }); + + describe('rum-remote-config-sampling', (): void => { + it('publishedRateAppliesToTheNextSessionNotTheRunningOne', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 4, null, null)); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); + + const now: number = Date.now(); + startView(app, now); + expect(scope.written.length > 0).assertTrue(); // the console's 100 beat the init 0 + const runningSession: string = sessionIdOf(scope.written[0]); + + // The console drops to 0 while the session runs: it must keep collecting. + store.write(new RemoteConfigValues(0, 5, null, null)); + app.handleEvent(raw('addAction', now + 1000)); + const last: Record = scope.written[scope.written.length - 1]; + expect(sessionIdOf(last)).assertEqual(runningSession); + + // Only the NEXT session is drawn under it. + app.stopCurrentSession(now + 2000); + const before: number = scope.written.length; + startView(app, now + 3000); + app.handleEvent(raw('addAction', now + 4000)); + expect(scope.written.length).assertEqual(before); + }); + + it('initValueAppliesWhenNothingWasEverPublished', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, newStore(core)); + startView(app, Date.now()); + expect(scope.written.length).assertEqual(0); // init 0 still means "collect nothing" + }); + + it('beforeSamplingHasTheLastWordAndSeesTheCustomValues', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 8, '{"vip":["u-42"]}', null)); + const scope: CapturingScope = new CapturingScope(); + let sawRate: number = -1; + let sawVip: string = ''; + const app: RumApplicationScope = new RumApplicationScope( + scope, core, 'app-1', 90, store, + (context: BeforeSamplingContext): number | undefined => { + sawRate = context.sessionSampleRate; + const custom: Record | null = context.custom; + if (custom !== null) { + const vip: Array = custom['vip'] as Array; + sawVip = vip[0] as string; + } + return 100; + }); + + startView(app, Date.now()); + expect(sawRate).assertEqual(0); // the rate that WOULD apply: the console's, not init + expect(sawVip).assertEqual('u-42'); + expect(scope.written.length > 0).assertTrue(); // the allow-list kept a session 0% would drop + }); + + it('ignoresAnUnusableAnswerFromTheHook', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const thrower: RumApplicationScope = new RumApplicationScope( + scope, core, 'app-1', 100, newStore(core), + (_c: BeforeSamplingContext): number | undefined => { + throw new Error('bad hook'); + }); + startView(thrower, Date.now()); + expect(scope.written.length > 0).assertTrue(); // a throwing hook must not take collection down + + const outOfRange: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope( + outOfRange, core, 'app-1', 100, newStore(core), + (_c: BeforeSamplingContext): number | undefined => -5); + startView(app, Date.now()); + expect(outOfRange.written.length > 0).assertTrue(); + + const passthrough: CapturingScope = new CapturingScope(); + const untouched: RumApplicationScope = new RumApplicationScope( + passthrough, core, 'app-1', 0, newStore(core), + (_c: BeforeSamplingContext): number | undefined => undefined); + startView(untouched, Date.now()); + expect(passthrough.written.length).assertEqual(0); // nothing returned: the incoming 0 stands + }); + + it('forcedSessionCollectsWhatTheRateDropped', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, newStore(core)); + const now: number = Date.now(); + startView(app, now); + expect(scope.written.length).assertEqual(0); + + app.forceSession(now + 100); + startView(app, now + 200); + expect(scope.written.length > 0).assertTrue(); + }); + + it('forcingAgainWhileItRunsChangesNothing', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, newStore(core)); + const now: number = Date.now(); + app.forceSession(now); + startView(app, now + 100); + const sessionId: string = sessionIdOf(scope.written[0]); + + app.forceSession(now + 200); + app.handleEvent(raw('addAction', now + 300)); + const last: Record = scope.written[scope.written.length - 1]; + expect(sessionIdOf(last)).assertEqual(sessionId); + }); + + it('viewEventsCarryTheDrawnRateAndVersion', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 12, null, null)); + const scope: CapturingScope = new CapturingScope(); + // init says 17; the console says 100 — the event must report what was drawn. + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 17, store); + startView(app, Date.now()); + + const view: Record = scope.written[0]; + const configuration: Record = configurationOf(view) as Record; + expect(configuration['session_sample_rate']).assertEqual(100); + expect(configuration['rc_version']).assertEqual(12); + }); + + it('carriesNoConfigurationBlockWhenRemoteConfigurationIsOff', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100); + startView(app, Date.now()); + expect(configurationOf(scope.written[0])).assertUndefined(); + }); + }); +} diff --git a/flashcat-rum/src/test/SessionLifecycle.test.ets b/flashcat-rum/src/test/SessionLifecycle.test.ets index 487902c..7af7029 100644 --- a/flashcat-rum/src/test/SessionLifecycle.test.ets +++ b/flashcat-rum/src/test/SessionLifecycle.test.ets @@ -1,7 +1,7 @@ import { describe, expect, it, Level, Size, TestType } from '@ohos/hypium'; import { FlashcatContext, FeatureScope, EventWriter, SdkCore, TrackingConsent, - Feature, FeatureEventReceiver, UserInfo + Feature, FeatureEventReceiver, UserInfo, IntakeTarget } from '@flashcatcloud/core'; import { RumApplicationScope } from '../main/ets/internal/scope/RumApplicationScope'; import { RumRawEvent } from '../main/ets/internal/scope/RumScope'; @@ -54,6 +54,7 @@ class CapturingScope implements FeatureScope { class FakeCore implements SdkCore { readonly name: string = 'test'; readonly featureContext: Record = {}; + private readonly settings: Map = new Map(); registerFeature(_feature: Feature): void {} getFeature(_featureName: string): FeatureScope | null { @@ -77,6 +78,20 @@ class FakeCore implements SdkCore { } 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); + } + } } function raw(kind: string, timestampMs: number, key?: string): RumRawEvent { From 53112775a9a94a754e8669b630efa21fd0b36ba3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 08:05:18 -0700 Subject: [PATCH 02/10] Fold away what the remote-configuration change added twice MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three things the first pass left behind: `stopSession`'s documentation ended up attached to a method inserted above it, two error-to-string helpers with one caller each where every other call site in this codebase writes the expression inline, and the same JSON-object parse in both the store and the controller — the store's is now shared, since the endpoint's body and the store's own entries need exactly the same tolerance. --- entry/src/main/ets/common/DemoSdk.ets | 7 +------ flashcat-rum/src/main/ets/FlashcatRum.ets | 6 +----- flashcat-rum/src/main/ets/RumMonitor.ets | 10 +++++----- .../src/main/ets/internal/RumFeature.ets | 6 +++--- .../remoteconfig/RemoteConfigController.ets | 18 ++---------------- .../remoteconfig/RemoteConfigStore.ets | 5 ++++- 6 files changed, 16 insertions(+), 36 deletions(-) diff --git a/entry/src/main/ets/common/DemoSdk.ets b/entry/src/main/ets/common/DemoSdk.ets index eb567e5..f14af5c 100644 --- a/entry/src/main/ets/common/DemoSdk.ets +++ b/entry/src/main/ets/common/DemoSdk.ets @@ -139,12 +139,7 @@ export class DemoSdk { return false; } const entries: Array = vip as Array; - for (let i: number = 0; i < entries.length; i++) { - if (entries[i] === id) { - return true; - } - } - return false; + return entries.includes(id); } static monitor(): RumMonitor { diff --git a/flashcat-rum/src/main/ets/FlashcatRum.ets b/flashcat-rum/src/main/ets/FlashcatRum.ets index 03fd3c6..5079127 100644 --- a/flashcat-rum/src/main/ets/FlashcatRum.ets +++ b/flashcat-rum/src/main/ets/FlashcatRum.ets @@ -74,7 +74,7 @@ export class FlashcatRum { // was initialised with — never a reason to fail enable(). store = null; controller = null; - FlashcatLog.e(`rum.remoteconfig: not started (${FlashcatRum.errorText(e)}); init values apply`); + FlashcatLog.e(`rum.remoteconfig: not started (${e instanceof Error ? e.message : 'error'}); init values apply`); } } @@ -94,10 +94,6 @@ export class FlashcatRum { } - private static errorText(e: Object): string { - return e instanceof Error ? e.message : 'error'; - } - /** * Auto-record a TAP action on `target`. No-op unless RUM is enabled with * `setTrackUserInteractions(true)`. Wrap a component's `onClick` (or one shared diff --git a/flashcat-rum/src/main/ets/RumMonitor.ets b/flashcat-rum/src/main/ets/RumMonitor.ets index c1b7a3d..5a12dfa 100644 --- a/flashcat-rum/src/main/ets/RumMonitor.ets +++ b/flashcat-rum/src/main/ets/RumMonitor.ets @@ -36,11 +36,6 @@ export interface RumMonitor { getCurrentSessionId(callback: (sessionId: string | undefined) => void): void; - /** - * End the current session immediately (e.g. on logout). The active view is - * closed with its final time_spent; the next tracked event starts a fresh - * session and restarts the last view in it. - */ /** * Force the session to be collected regardless of the configured sample * rates. Call it when your own code decides a visitor needs debugging (an @@ -60,6 +55,11 @@ export interface RumMonitor { */ getRemoteConfig(): Record | null; + /** + * End the current session immediately (e.g. on logout). The active view is + * closed with its final time_spent; the next tracked event starts a fresh + * session and restarts the last view in it. + */ stopSession(): void; } diff --git a/flashcat-rum/src/main/ets/internal/RumFeature.ets b/flashcat-rum/src/main/ets/internal/RumFeature.ets index 60f90ec..d2f8ac4 100644 --- a/flashcat-rum/src/main/ets/internal/RumFeature.ets +++ b/flashcat-rum/src/main/ets/internal/RumFeature.ets @@ -268,9 +268,9 @@ export class RumFeature implements Feature, FeatureEventReceiver { // Back from the background, where an in-process timer may not have run for // hours. Whether this is worth a request is the controller's call (the // console has to have asked for it, and the values have to be stale). - const foregrounded: DefaultRumMonitor | null = this.monitor; - if (foregrounded !== null) { - foregrounded.refreshRemoteConfig(); + const m: DefaultRumMonitor | null = this.monitor; + if (m !== null) { + m.refreshRemoteConfig(); } } else if (type === 'keep_alive') { // App backgrounded — refresh the active view so its final time_spent is sent diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index f3926ab..2702fb1 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -159,7 +159,7 @@ export class RemoteConfigController { if (this.stopped) { return; } - FlashcatLog.d(`rum.remoteconfig: fetch failed (${RemoteConfigController.errorText(e)}); keeping the values already in use`); + FlashcatLog.d(`rum.remoteconfig: fetch failed (${e instanceof Error ? e.message : 'network error'}); keeping the values already in use`); this.scheduleRetry(); }); } @@ -205,7 +205,7 @@ export class RemoteConfigController { * configuration would cut every session in two on every fetch. */ apply(payload: string, etag: string | null): boolean { - const json: Record | null = RemoteConfigController.parseObject(payload); + const json: Record | null = RemoteConfigStore.parseObject(payload); if (json === null) { return false; } @@ -276,17 +276,6 @@ export class RemoteConfigController { return value as number; } - private static parseObject(payload: string): Record | null { - try { - const value: Object = JSON.parse(payload) as Object; - if (typeof value !== 'object' || value === null || Array.isArray(value)) { - return null; - } - return value as Record; - } catch (_e) { - return null; - } - } private static objectOf(value: Object | undefined): Record | null { if (value === undefined || typeof value !== 'object' || value === null || Array.isArray(value)) { @@ -311,7 +300,4 @@ export class RemoteConfigController { return typeof value === 'string' ? value as string : null; } - private static errorText(e: Object): string { - return e instanceof Error ? e.message : 'network error'; - } } diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index 7cd7933..490f444 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -167,7 +167,10 @@ export class RemoteConfigStore { ); } - private static parseObject(serialized: string): Record | null { + /** Reads a JSON object, or null for anything that is not one — including a + * body that is not JSON at all. Shared with the controller: the endpoint's + * body and this store's own entries need exactly the same tolerance. */ + static parseObject(serialized: string): Record | null { try { const value: Object = JSON.parse(serialized) as Object; if (typeof value !== 'object' || value === null || Array.isArray(value)) { From b2518746097450f80bfc246d12fca8f803915443 Mon Sep 17 00:00:00 2001 From: Fiona Date: Wed, 26 Aug 2026 20:36:45 -0700 Subject: [PATCH 03/10] feat(rum): refuse a configuration written to a contract this SDK cannot read The server states the shape of the body it wrote in `schema_version`. A reader that guesses instead of checking is exactly what that field exists to prevent: a payload shaped for a newer reader can be misread field by field while every individual field still parses, and half-understood sampling settings are worse than none. The check has to be honoured by the first SDK that ships. Refusing can only be done by code already on the device, so a version added later would be ignored by exactly the clients it needs to protect. `apply()` now reports one of three outcomes instead of a boolean: APPLIED the body was read and its values are stored UNREADABLE not a configuration at all - ask again UNSUPPORTED_SCHEMA a contract this SDK does not read - refused whole Only UNREADABLE is retried. A schema we do not know is an answer, not a failure: asking again would fetch the same refusal, so a server-side schema bump cannot turn a fleet into a retry storm. Nothing from a refused body reaches storage, not even the fields that happened to parse. --- .../remoteconfig/RemoteConfigController.ets | 53 ++++++++++++++++--- flashcat-rum/src/test/RemoteConfig.test.ets | 38 +++++++++++-- 2 files changed, 80 insertions(+), 11 deletions(-) diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index 2702fb1..ec3930c 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -9,7 +9,35 @@ const JITTER_FRACTION: number = 0.2; const MAX_RATE: number = 100; const MS_PER_SECOND: number = 1000; const HTTP_NOT_MODIFIED: number = 304; +/** + * The contract this SDK reads. Not the SDK version and not the settings version: it names the + * SHAPE of the body, and the server bumps it only when a body would be misread by a reader + * written against the previous shape. + */ +const SUPPORTED_SCHEMA_VERSION: number = 1; +/** + * What reading one response body came to. Only UNREADABLE is worth asking again for: the other + * two are answers, whether or not this SDK can act on them. + */ +export enum ApplyOutcome { + /** The body was read and its values are now stored. */ + APPLIED = 'applied', + /** + * The body was not a configuration at all — not JSON, or truncated. A captive portal answering + * 200 with a login page looks exactly like this, so it is treated as a request that did not + * arrive rather than as a configuration saying nothing. + */ + UNREADABLE = 'unreadable', + /** + * The body is a configuration written to a contract this SDK does not know. Refused whole: a + * payload shaped for a newer reader can be misread field by field while every individual field + * still parses, and half-understood sampling settings are worse than none. + */ + UNSUPPORTED_SCHEMA = 'unsupported_schema' +} + +const FIELD_SCHEMA_VERSION: string = 'schema_version'; const FIELD_VERSION: string = 'version'; const FIELD_TTL: string = 'ttl'; const FIELD_ENABLED: string = 'enabled'; @@ -150,7 +178,10 @@ export class RemoteConfigController { this.scheduleRetry(); return; } - if (!this.apply(response.body, response.etag)) { + // An unreadable body is the only outcome worth asking again for. A body we understood — + // even one we must refuse because its schema is newer than this SDK — is an answered + // question, and repeating it would just be the same refusal twice. + if (this.apply(response.body, response.etag) === ApplyOutcome.UNREADABLE) { this.scheduleRetry(); } }) @@ -197,17 +228,27 @@ export class RemoteConfigController { /** * Stores what the response carried and, when the console asked for it, * restarts the session so the new values take hold now instead of at the - * visitor's next one. Returns false when the body could not be read at all — - * that is a failed ask, not an empty configuration. + * visitor's next one. * * The session is only restarted when the values THIS client will draw with * really changed. Without that check, a console resending an unchanged * configuration would cut every session in two on every fetch. */ - apply(payload: string, etag: string | null): boolean { + apply(payload: string, etag: string | null): ApplyOutcome { const json: Record | null = RemoteConfigStore.parseObject(payload); if (json === null) { - return false; + FlashcatLog.d('rum.remoteconfig: response was not readable; keeping the values already in use'); + return ApplyOutcome.UNREADABLE; + } + // Checked before anything is read out of the body. The server states the shape it wrote, and + // a reader that guesses instead of checking is exactly what this field exists to prevent — + // which is why it has to be honoured by the first SDK that ships, not by a later one: only + // code already on the device can refuse. + const schema: number | null = RemoteConfigController.positiveIntOf(json[FIELD_SCHEMA_VERSION]); + if (schema !== SUPPORTED_SCHEMA_VERSION) { + FlashcatLog.e(`rum.remoteconfig: ignoring a configuration written to schema version ${schema ?? 'none'}; ` + + `this SDK reads version ${SUPPORTED_SCHEMA_VERSION}. Update the SDK to take the console's settings again`); + return ApplyOutcome.UNSUPPORTED_SCHEMA; } const enabled: boolean = json[FIELD_ENABLED] === true; const activation: string = RemoteConfigController.stringOf(json[FIELD_ACTIVATION]) ?? ''; @@ -231,7 +272,7 @@ export class RemoteConfigController { // keeps the ttl the server last asked for instead of falling back to ours. const ttl: number | null = RemoteConfigController.positiveIntOf(json[FIELD_TTL]); this.ttlSeconds = ttl ?? DEFAULT_TTL_SECONDS; - return true; + return ApplyOutcome.APPLIED; } private storedRate(): number | null { diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 766af94..359985a 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -6,7 +6,7 @@ import { import { RumApplicationScope } from '../main/ets/internal/scope/RumApplicationScope'; import { RumRawEvent } from '../main/ets/internal/scope/RumScope'; import { RemoteConfigStore, RemoteConfigValues } from '../main/ets/internal/remoteconfig/RemoteConfigStore'; -import { RemoteConfigController } from '../main/ets/internal/remoteconfig/RemoteConfigController'; +import { RemoteConfigController, ApplyOutcome } from '../main/ets/internal/remoteconfig/RemoteConfigController'; import { RemoteConfigFetcher, RemoteConfigResponse, buildConfigUrl } from '../main/ets/internal/remoteconfig/RemoteConfigFetcher'; import { BeforeSamplingContext } from '../main/ets/RumTypes'; @@ -155,8 +155,12 @@ function configurationOf(event: Record): Record /** Body the engine sends: rates live under `rum`, the app's bag under `custom`. */ function body(version: number, enabled: boolean, rate: number | null, - activation: string = 'next_session', custom: string = ''): string { - const parts: string[] = [`"version":${version}`, `"ttl":600`, `"enabled":${enabled}`, `"activation":"${activation}"`]; + activation: string = 'next_session', custom: string = '', schemaVersion: number | null = 1): string { + const parts: string[] = []; + if (schemaVersion !== null) { + parts.push(`"schema_version":${schemaVersion}`); + } + parts.push(`"version":${version}`, `"ttl":600`, `"enabled":${enabled}`, `"activation":"${activation}"`); parts.push(rate === null ? '"rum":{}' : `"rum":{"sessionSampleRate":${rate}}`); if (custom.length > 0) { parts.push(`"custom":${custom}`); @@ -225,7 +229,8 @@ export default function remoteConfigTests(): void { const controller: RemoteConfigController = new RemoteConfigController( store, new FakeFetcher(), 'https://x/config?client_token=t', 100, (): void => { restarts++; }); - expect(controller.apply(body(9, true, 25, 'next_session', '{"vip":["u1"]}'), '"e9"')).assertTrue(); + expect(controller.apply(body(9, true, 25, 'next_session', '{"vip":["u1"]}'), '"e9"')) + .assertEqual(ApplyOutcome.APPLIED); const read: RemoteConfigValues = store.read() as RemoteConfigValues; expect(read.sessionSampleRate).assertEqual(25); @@ -266,10 +271,33 @@ export default function remoteConfigTests(): void { const controller: RemoteConfigController = new RemoteConfigController( store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); controller.apply(body(3, true, 30), null); - expect(controller.apply('gateway error', null)).assertFalse(); + expect(controller.apply('gateway error', null)).assertEqual(ApplyOutcome.UNREADABLE); expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(30); }); + it('refusesASchemaItDoesNotRead', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + + // Nothing of a body we cannot vouch for reaches storage, not even the fields that parsed. + expect(controller.apply(body(3, true, 30, 'next_session', '', 99), null)) + .assertEqual(ApplyOutcome.UNSUPPORTED_SCHEMA); + expect(store.read() === null).assertTrue(); + }); + + it('refusesABodyWithNoSchemaAtAll', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + + expect(controller.apply(body(3, true, 30, 'next_session', '', null), null)) + .assertEqual(ApplyOutcome.UNSUPPORTED_SCHEMA); + expect(store.read() === null).assertTrue(); + }); + it('immediateActivationOnlyCutsSessionsWhenThisClientsRateChanged', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); From 67090257846fc3f7bff162617c27022562ececde Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:35:43 -0700 Subject: [PATCH 04/10] fix(rum): read a configuration that carries no schema stamp MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A response with no schema stamp at all was refused as a shape this SDK cannot read. A body without one is, by construction, the shape that existed before the stamp did — which is the shape this reader was written against. Refusing it switches remote configuration silently off against a server that merely predates the field, and nothing says so: the refusal path is the same one a body we genuinely cannot read takes, so there is no error to notice. Only a stamp that is present and unrecognised is a refusal now, which is what the web SDK already did. The two no longer disagree about the same response. --- .../ets/internal/remoteconfig/RemoteConfigController.ets | 7 ++++++- flashcat-rum/src/test/RemoteConfig.test.ets | 9 ++++++--- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index ec3930c..f41ab5a 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -244,8 +244,13 @@ export class RemoteConfigController { // a reader that guesses instead of checking is exactly what this field exists to prevent — // which is why it has to be honoured by the first SDK that ships, not by a later one: only // code already on the device can refuse. + // No stamp at all is not a refusal: a body without one is, by construction, the shape that + // existed before the stamp did, which is the shape this reader was written against. Refusing it + // would switch remote configuration silently off against a server that merely predates the + // field. Only a stamp we can see and do not recognise is a reason to refuse. + const stamped: boolean = json[FIELD_SCHEMA_VERSION] !== undefined && json[FIELD_SCHEMA_VERSION] !== null; const schema: number | null = RemoteConfigController.positiveIntOf(json[FIELD_SCHEMA_VERSION]); - if (schema !== SUPPORTED_SCHEMA_VERSION) { + if (stamped && schema !== SUPPORTED_SCHEMA_VERSION) { FlashcatLog.e(`rum.remoteconfig: ignoring a configuration written to schema version ${schema ?? 'none'}; ` + `this SDK reads version ${SUPPORTED_SCHEMA_VERSION}. Update the SDK to take the console's settings again`); return ApplyOutcome.UNSUPPORTED_SCHEMA; diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 359985a..4d484cb 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -287,15 +287,18 @@ export default function remoteConfigTests(): void { expect(store.read() === null).assertTrue(); }); - it('refusesABodyWithNoSchemaAtAll', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + it('readsABodyWithNoSchemaStampAtAll', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A body with no stamp is, by construction, the shape that existed before the stamp did — + // the shape this reader was written against. Refusing it would switch remote configuration + // silently off against a server that merely predates the field, with nothing to say so. const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); const controller: RemoteConfigController = new RemoteConfigController( store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); expect(controller.apply(body(3, true, 30, 'next_session', '', null), null)) - .assertEqual(ApplyOutcome.UNSUPPORTED_SCHEMA); - expect(store.read() === null).assertTrue(); + .assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.sessionSampleRate).assertEqual(30); }); it('immediateActivationOnlyCutsSessionsWhenThisClientsRateChanged', From 47e41a47df3fa5b589301a741ac5b0636e96eac6 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 27 Aug 2026 07:59:07 -0700 Subject: [PATCH 05/10] refactor(rum): drop a guard the one below it already covers forceSession() returned early when the session was already forced and being collected, then set the flag and returned again on the same "already being collected" condition. Reaching the first guard meant the flag was already set, so the only thing it did was skip setting it to the value it already had. --- .../src/main/ets/internal/scope/RumApplicationScope.ets | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 0a53f2e..220727c 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -266,12 +266,9 @@ export class RumApplicationScope implements RumScope { * ends now so a collected one starts in its place. */ forceSession(nowMs: number): void { - if (this.forced && this.session !== null && this.session.isSampled()) { - return; // already forced and collecting — nothing to do - } this.forced = true; if (this.session !== null && this.session.isSampled()) { - return; + return; // already collecting — nothing to end } this.stopCurrentSession(nowMs); } From d834ac3a1da3b6cd0e15507f353be7e944801bed Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 10 Sep 2026 03:31:30 -0700 Subject: [PATCH 06/10] Report a session under the rate that drew it, and let the scope decide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Four things about the remote-configuration path were wrong, and they share a theme: what a session reports, and who gets to end one. A forced session reported the rate the console published and the version that went with it, though neither decided it — the draw was skipped precisely because the application asked for this visitor whatever the fleet is on. The backend weights a session by 100/rate, so one visitor kept by `setForcedSession()` under a published 3% arrived as thirty-three real ones, credited to a version it ignored. A forced session now reports 100 and no version. View documents carried no configuration block at all unless the application had opted into remote configuration. But the rate is how the backend weights every session, and one that reports nothing is counted once — the same as one from an SDK too old to say so. Every session now reports the rate it was drawn with; the version is still reported only when a published configuration decided it. A response older than the one already in force replaced the stored values and their validator, so the client would draw under superseded settings and then revalidate against them. Rollbacks are published as a new version, so an older body can only come from a replica that has not caught up or an intermediary answering with something it held. It is now refused — and refused before any other field is read, so its ttl and its foreground-refresh permission cannot outlive the values it was refused for. A rate crossing zero never reached the session already running. Switching collection on left everyone already in the app dark until their sessions expired — up to four hours, and nothing at all is indistinguishable from broken — while switching it off was not the emergency stop it looks like. That decision now lives in `RumApplicationScope`, the only part that knows whether the running session is being collected, whether the application forced it, and what `beforeSampling` says about the rate that would apply next: - a session that could never have been collected is replaced only when it was drawn at zero and the next rate can collect something. One that lost a fair draw is left alone; re-drawing it on every publish would bias collection upward. - a session being collected ends only when the next rate is zero, or when the console asked for `immediate`. - a session the application forced, or that its own hook keeps, is never ended by a rate. Every replacement would be kept too, so ending one costs the view the visitor is on and buys nothing. The controller now announces what changed and leaves the judgement there. Its retry schedule becomes a constructor parameter, defaulted to the real one, for the same reason the fetcher is one: the backoff, the unchanged-answer path and the foreground rhythm are the half of this feature that must not be got wrong, and a test that had to wait out a real minute-long delay would never be written. Twenty-four tests cover the paths that had none — the retry budget and its re-arming, an unchanged answer not leaving the controller wedged, a response landing after the SDK stopped, the server's ttl, a refused body leaving the rhythm alone, the seam that asks the console again on every new session, and a published change travelling from the response all the way to the session it decides. --- flashcat-rum/src/main/ets/FlashcatRum.ets | 15 +- .../internal/monitor/DefaultRumMonitor.ets | 9 + .../remoteconfig/RemoteConfigController.ets | 83 ++- .../remoteconfig/RemoteConfigStore.ets | 7 +- .../internal/scope/RumApplicationScope.ets | 86 ++- .../ets/internal/scope/RumSessionScope.ets | 10 +- flashcat-rum/src/test/RemoteConfig.test.ets | 601 +++++++++++++++++- 7 files changed, 745 insertions(+), 66 deletions(-) diff --git a/flashcat-rum/src/main/ets/FlashcatRum.ets b/flashcat-rum/src/main/ets/FlashcatRum.ets index 5079127..09acb7d 100644 --- a/flashcat-rum/src/main/ets/FlashcatRum.ets +++ b/flashcat-rum/src/main/ets/FlashcatRum.ets @@ -53,6 +53,10 @@ export class FlashcatRum { // values this configuration was built with. let store: RemoteConfigStore | null = null; let controller: RemoteConfigController | null = null; + // Assigned a few lines below, and only ever read when a response comes + // back — by which time it is there. The controller has to exist first + // because the monitor owns it. + let monitorRef: DefaultRumMonitor | null = null; if (configuration.remoteConfigurationEnabled) { try { const intake: IntakeTarget = core.getIntakeTarget(); @@ -63,11 +67,11 @@ export class FlashcatRum { store, new HttpRemoteConfigFetcher(`flashcat-sdk-harmony/${context.sdkVersion}`), buildConfigUrl(intake.host, intake.clientToken, context), - configuration.sessionSampleRate, - // Looked up when it fires rather than captured now: the monitor is - // registered after this is built, and by the time a response comes - // back it is there. - (): void => GlobalRumMonitor.get().stopSession() + (activation: string, before: number | null, after: number | null): void => { + if (monitorRef !== null) { + monitorRef.onRemoteConfigurationChanged(activation, before, after); + } + } ); } catch (e) { // Whatever went wrong here, the app keeps collecting with the values it @@ -79,6 +83,7 @@ export class FlashcatRum { } const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration, store, controller); + monitorRef = monitor; feature.attachMonitor(monitor); GlobalRumMonitor.register(monitor); diff --git a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets index 820c341..ff6adc6 100644 --- a/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets +++ b/flashcat-rum/src/main/ets/internal/monitor/DefaultRumMonitor.ets @@ -79,6 +79,15 @@ export class DefaultRumMonitor implements RumMonitor { } } + /** + * A configuration reached storage. Handed to the scope, which is the only + * part that knows whether the session running right now is being collected, + * whether the application forced it, and what its hook says. + */ + onRemoteConfigurationChanged(activation: string, before: number | null, after: number | null): void { + this.applicationScope.onConfigurationChanged(activation, before, after, Date.now()); + } + setForcedSession(): void { this.applicationScope.forceSession(Date.now()); } diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index f41ab5a..fdf7e7f 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -3,7 +3,6 @@ import { RemoteConfigStore, RemoteConfigValues } from './RemoteConfigStore'; import { RemoteConfigFetcher, RemoteConfigResponse } from './RemoteConfigFetcher'; const DEFAULT_TTL_SECONDS: number = 300; -const ACTIVATION_IMMEDIATE: string = 'immediate'; const RETRY_DELAYS_SECONDS: number[] = [5, 60]; const JITTER_FRACTION: number = 0.2; const MAX_RATE: number = 100; @@ -17,12 +16,32 @@ const HTTP_NOT_MODIFIED: number = 304; const SUPPORTED_SCHEMA_VERSION: number = 1; /** - * What reading one response body came to. Only UNREADABLE is worth asking again for: the other - * two are answers, whether or not this SDK can act on them. + * Told after every configuration that reached storage: which activation the console asked for, + * the published rate this client held before it, and the one it holds now (`null` on either side + * means "nothing published, the init value applies"). + * + * It is an announcement, not an instruction. Whether a running session is worth ending is a + * question only the scope can answer — it is the one that knows whether that session is being + * collected, whether the host application forced it, and what the application's own hook says + * about the rate that would apply next. + */ +export type RemoteConfigChangeListener = + (activation: string, before: number | null, after: number | null) => void; + +/** + * What reading one response body came to. Only UNREADABLE is worth asking again for: the rest are + * answers, whether or not this SDK can act on them. */ export enum ApplyOutcome { /** The body was read and its values are now stored. */ APPLIED = 'applied', + /** + * The body is older than the configuration already in force. Rollbacks are published as a new + * version, so an older one can only come from a replica that has not caught up or an + * intermediary replaying something it held: it must not replace the stored values, their + * validator, or the rhythm the current body asked for. + */ + STALE_VERSION = 'stale_version', /** * The body was not a configuration at all — not JSON, or truncated. A captive portal answering * 200 with a login page looks exactly like this, so it is treated as a request that did not @@ -67,8 +86,8 @@ export class RemoteConfigController { private readonly store: RemoteConfigStore; private readonly fetcher: RemoteConfigFetcher; private readonly configUrl: string; - private readonly initialSessionSampleRate: number; - private readonly restartSession: () => void; + private readonly onConfigurationChanged: RemoteConfigChangeListener; + private readonly retryDelaysSeconds: number[]; private lastFetchAtMs: number = 0; private ttlSeconds: number = DEFAULT_TTL_SECONDS; @@ -78,18 +97,25 @@ export class RemoteConfigController { private pendingRetryId: number = -1; private stopped: boolean = false; + /** + * `retryDelaysSeconds` is the schedule an outage is retried on. It is a + * parameter for the same reason the fetcher is one: the retry rules are the + * half of this feature that must not be got wrong, and a test that had to + * wait out the real minute-long delay would never be written. Production + * always takes the default. + */ constructor( store: RemoteConfigStore, fetcher: RemoteConfigFetcher, configUrl: string, - initialSessionSampleRate: number, - restartSession: () => void + onConfigurationChanged: RemoteConfigChangeListener, + retryDelaysSeconds: number[] = RETRY_DELAYS_SECONDS ) { this.store = store; this.fetcher = fetcher; this.configUrl = configUrl; - this.initialSessionSampleRate = initialSessionSampleRate; - this.restartSession = restartSession; + this.onConfigurationChanged = onConfigurationChanged; + this.retryDelaysSeconds = retryDelaysSeconds; } start(): void { @@ -202,11 +228,11 @@ export class RemoteConfigController { * can never turn an endpoint incident into a storm. */ private scheduleRetry(): void { - if (this.stopped || this.failedAttempts >= RETRY_DELAYS_SECONDS.length) { + if (this.stopped || this.failedAttempts >= this.retryDelaysSeconds.length) { return; } const delaySeconds: number = - RemoteConfigController.jittered(RETRY_DELAYS_SECONDS[this.failedAttempts], Math.random()); + RemoteConfigController.jittered(this.retryDelaysSeconds[this.failedAttempts], Math.random()); this.failedAttempts++; this.pendingRetryId = setTimeout(() => { this.pendingRetryId = -1; @@ -226,13 +252,13 @@ export class RemoteConfigController { } /** - * Stores what the response carried and, when the console asked for it, - * restarts the session so the new values take hold now instead of at the - * visitor's next one. + * Stores what the response carried and announces what changed for this + * client, so the scope can decide whether the session already running is + * worth ending. * - * The session is only restarted when the values THIS client will draw with - * really changed. Without that check, a console resending an unchanged - * configuration would cut every session in two on every fetch. + * Everything a body says is refused together or kept together: a response + * this SDK will not read must not leave its ttl or its foreground permission + * behind. */ apply(payload: string, etag: string | null): ApplyOutcome { const json: Record | null = RemoteConfigStore.parseObject(payload); @@ -255,12 +281,23 @@ export class RemoteConfigController { + `this SDK reads version ${SUPPORTED_SCHEMA_VERSION}. Update the SDK to take the console's settings again`); return ApplyOutcome.UNSUPPORTED_SCHEMA; } + // Read next, and acted on before anything else in the body is: a body older than the one in + // force must leave no trace at all, and a permission or a ttl read out of it ahead of this + // check would outlive the values we refuse. The version is the only thing worth reading from + // a body we may not keep. + const version: number | null = RemoteConfigController.positiveIntOf(json[FIELD_VERSION]); + const applied: number | null = this.store.appliedVersion(); + if ((version ?? 0) < (applied ?? 0)) { + FlashcatLog.d(`rum.remoteconfig: ignoring version ${version ?? 'none'}; ` + + `version ${applied} is already in force`); + return ApplyOutcome.STALE_VERSION; + } + const enabled: boolean = json[FIELD_ENABLED] === true; const activation: string = RemoteConfigController.stringOf(json[FIELD_ACTIVATION]) ?? ''; this.refreshOnForeground = json[FIELD_REFRESH_ON_FOREGROUND] === true; const before: number | null = this.storedRate(); - const version: number | null = RemoteConfigController.positiveIntOf(json[FIELD_VERSION]); const rate: number | null = enabled ? RemoteConfigController.readRate(RemoteConfigController.objectOf(json[FIELD_RUM])) : null; @@ -269,9 +306,9 @@ export class RemoteConfigController { const custom: string | null = enabled ? RemoteConfigController.rawObjectOf(json[FIELD_CUSTOM]) : null; this.store.write(new RemoteConfigValues(rate, version, custom, etag)); - if (activation === ACTIVATION_IMMEDIATE && this.changesThisClient(before, rate)) { - this.restartSession(); - } + // Announced after the write, so the scope's hook sees the values that are + // now in force rather than the ones they replaced. + this.onConfigurationChanged(activation, before, rate); // Remembered here rather than around the request, so a fetch that fails // keeps the ttl the server last asked for instead of falling back to ours. @@ -285,10 +322,6 @@ export class RemoteConfigController { return stored === null ? null : stored.sessionSampleRate; } - private changesThisClient(before: number | null, after: number | null): boolean { - return (before ?? this.initialSessionSampleRate) !== (after ?? this.initialSessionSampleRate); - } - /** * Whether returning to the foreground is a reason to ask again. Both halves * guard different things: the permission keeps the request pattern off unless diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index 490f444..6c17c98 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -116,9 +116,10 @@ export class RemoteConfigStore { } /** Which version the stored values came from, or null before the first answer. - * Reported back on the next request so the console can say how far a change - * has reached — a question events cannot answer, because a session that was - * not kept sends none, and the miss rate is set by the very rate being changed. */ + * Sent on the next request as `applied_version`. Nothing reads it today — the + * console works out how far a change has reached from the `rc_version` the + * sessions themselves carry — but it costs one query parameter and it is the + * only signal a client that never draws a kept session could ever send. */ appliedVersion(): number | null { const values: RemoteConfigValues | null = this.read(); return values === null ? null : values.version; diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 220727c..3b32187 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -4,6 +4,11 @@ import { RumSessionScope } from './RumSessionScope'; import { RemoteConfigStore, RemoteConfigValues, DrawnConfiguration } from '../remoteconfig/RemoteConfigStore'; import { BeforeSamplingCallback, BeforeSamplingContext } from '../../RumTypes'; +/** What a forced session reports: it is kept whatever the rates say, so it stands for itself. */ +const FORCED_SAMPLE_RATE: number = 100; +/** The console asking for a change to reach the session that is already running. */ +const ACTIVATION_IMMEDIATE: string = 'immediate'; + /** * Root of the RUM scope tree. Creates and replaces sessions as they expire. * Equivalent to Android's RumApplicationScope. @@ -219,11 +224,18 @@ export class RumApplicationScope implements RumScope { const baseRate: number = publishedRate !== null ? publishedRate : this.sampleRate; const rate: number = this.askBeforeSampling(baseRate, stored); const version: number = stored !== null && stored.version !== null ? stored.version : 0; - // Only meaningful when remote configuration is on: without it the rate on - // the event would just repeat what the app was built with, and every event - // would grow a field carrying no information. - const draw: DrawnConfiguration | null = - this.remoteConfig !== null ? new DrawnConfiguration(version, rate) : null; + // 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 + // to say. The version is the part that is only meaningful when a + // configuration decided the rate, and it is omitted when none did. + // + // 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 session: RumSessionScope = new RumSessionScope( this.featureScope, this.core, this.applicationId, rate, nowMs, this.forced, draw); if (this.onSessionStarted !== null) { @@ -259,6 +271,70 @@ export class RumApplicationScope implements RumScope { } } + /** + * A configuration reached storage. The rates it carries apply to the NEXT + * session drawn — a session already under way is never re-judged on a rate + * that merely moved. It was drawn by a fair coin, and re-drawing it on every + * publish would bias collection upward. + * + * Three changes do reach the running session. Two are about zero, and hold + * whatever activation the console asked for: + * + * - Collection was off and is now on. Without this, an operator who has just + * switched a fleet on sees nothing at all from anyone already using the + * app, for as long as their sessions last: up to four hours, and nothing + * at all is indistinguishable from broken. + * - Collection was on and is now off. That is an emergency stop, and an + * emergency stop that waits four hours is not one. + * + * The third is the console asking for `immediate`, which is a request to + * re-decide the running session on any rate that really changed — the one + * 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. + */ + onConfigurationChanged(activation: string, before: number | null, after: number | null, nowMs: number): void { + if (this.session === null) { + return; // the next event mints one, and it will draw under what just arrived + } + if (this.forced) { + // Every replacement would be forced too, so ending this one buys the same + // session back and costs the view the visitor is on. + return; + } + const previousRate: number = before ?? this.sampleRate; + const nextRate: number = after ?? this.sampleRate; + if (previousRate === nextRate) { + return; // the console re-publishing what this client already holds + } + // The hook has the last word on the rate that would apply, exactly as it + // 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); + if (RumApplicationScope.shouldEndSession( + this.session.isSampled(), this.session.getDrawnRate(), resolvedRate, activation)) { + this.stopCurrentSession(nowMs); + } + } + + /** + * Whether a change decides anything for the session running right now. + * + * `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. + */ + static shouldEndSession(sampled: boolean, drawnRate: number, nextRate: number, activation: string): boolean { + if (sampled) { + return nextRate === 0 || 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); + } + /** * RumMonitor.setForcedSession: from here on every draw keeps the session. A * session already being collected keeps running — RUM cannot retro-collect diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index f892841..0024235 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -23,7 +23,7 @@ export class RumSessionScope implements RumScope { private readonly sessionId: string; private readonly sampled: boolean; // The settings this session was drawn under, carried onto its view documents. - private readonly draw: DrawnConfiguration | null; + private readonly draw: DrawnConfiguration; private readonly startedAtMs: number; private lastActivityMs: number; private lastKeepAliveMs: number = 0; @@ -53,7 +53,7 @@ export class RumSessionScope implements RumScope { sampleRate: number, startedAtMs: number, forced: boolean = false, - draw: DrawnConfiguration | null = null + draw: DrawnConfiguration = new DrawnConfiguration(0, sampleRate) ) { this.featureScope = featureScope; this.core = core; @@ -274,6 +274,12 @@ export class RumSessionScope implements RumScope { return this.lastViewAttributes; } + /** The rate this session was actually drawn with — the console's, the init + * value, or whatever the application's hook returned. */ + getDrawnRate(): number { + return this.draw.sessionSampleRate; + } + isSampled(): boolean { return this.sampled; } diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 4d484cb..71bf017 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -6,9 +6,23 @@ import { import { RumApplicationScope } from '../main/ets/internal/scope/RumApplicationScope'; import { RumRawEvent } from '../main/ets/internal/scope/RumScope'; import { RemoteConfigStore, RemoteConfigValues } from '../main/ets/internal/remoteconfig/RemoteConfigStore'; -import { RemoteConfigController, ApplyOutcome } from '../main/ets/internal/remoteconfig/RemoteConfigController'; +import { RemoteConfigController, ApplyOutcome, RemoteConfigChangeListener } from '../main/ets/internal/remoteconfig/RemoteConfigController'; import { RemoteConfigFetcher, RemoteConfigResponse, buildConfigUrl } from '../main/ets/internal/remoteconfig/RemoteConfigFetcher'; -import { BeforeSamplingContext } from '../main/ets/RumTypes'; +import { BeforeSamplingContext, BeforeSamplingCallback } from '../main/ets/RumTypes'; +import { DefaultRumMonitor } from '../main/ets/internal/monitor/DefaultRumMonitor'; +import { RumConfigurationBuilder } from '../main/ets/RumConfiguration'; + +const ignoreChanges: RemoteConfigChangeListener = (_activation: string, _before: number | null, _after: number | null): void => {}; + +/** Retry delays short enough for a test to wait out, in seconds. The window a + * test then waits is several times the whole schedule, so a loaded machine + * cannot turn a timing assertion into a flake. */ +const QUICK_RETRIES: number[] = [0.02, 0.04]; +const AFTER_EVERY_RETRY_MS: number = 250; + +function sleep(ms: number): Promise { + return new Promise((resolve: () => void) => setTimeout(resolve, ms)); +} function testContext(appVersion: string = '1.0.0'): FlashcatContext { return { @@ -225,9 +239,12 @@ export default function remoteConfigTests(): void { it('storesWhatThePublishedConfigurationCarried', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); - let restarts: number = 0; + const announced: string[] = []; const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config?client_token=t', 100, (): void => { restarts++; }); + store, new FakeFetcher(), 'https://x/config?client_token=t', + (activation: string, before: number | null, after: number | null): void => { + announced.push(`${activation}:${before}:${after}`); + }); expect(controller.apply(body(9, true, 25, 'next_session', '{"vip":["u1"]}'), '"e9"')) .assertEqual(ApplyOutcome.APPLIED); @@ -237,14 +254,17 @@ export default function remoteConfigTests(): void { expect(read.version).assertEqual(9); expect(read.custom).assertEqual('{"vip":["u1"]}'); expect(read.etag).assertEqual('"e9"'); - expect(restarts).assertEqual(0); // next_session: nobody's session is cut short + // The controller reports what changed; whether a running session is worth + // ending is the scope's call — only it knows the session and the hook. + expect(announced.length).assertEqual(1); + expect(announced[0]).assertEqual('next_session:null:25'); }); it('treatsAnOutOfRangeRateAsAbsent', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + store, new FakeFetcher(), 'https://x/config', ignoreChanges); controller.apply(body(2, true, 140), null); // Not clamped to 100: a rate we cannot trust is not a rate to sample with, // so the value the app was initialised with keeps applying. @@ -256,7 +276,7 @@ export default function remoteConfigTests(): void { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + store, new FakeFetcher(), 'https://x/config', ignoreChanges); controller.apply(body(4, true, 10, 'next_session', '{"tier":"vip"}'), null); controller.apply(body(5, false, 10, 'next_session', '{"tier":"vip"}'), null); const read: RemoteConfigValues = store.read() as RemoteConfigValues; @@ -269,7 +289,7 @@ export default function remoteConfigTests(): void { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + store, new FakeFetcher(), 'https://x/config', ignoreChanges); controller.apply(body(3, true, 30), null); expect(controller.apply('gateway error', null)).assertEqual(ApplyOutcome.UNREADABLE); expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(30); @@ -279,7 +299,7 @@ export default function remoteConfigTests(): void { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + store, new FakeFetcher(), 'https://x/config', ignoreChanges); // Nothing of a body we cannot vouch for reaches storage, not even the fields that parsed. expect(controller.apply(body(3, true, 30, 'next_session', '', 99), null)) @@ -294,30 +314,111 @@ export default function remoteConfigTests(): void { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config', 100, (): void => {}); + store, new FakeFetcher(), 'https://x/config', ignoreChanges); expect(controller.apply(body(3, true, 30, 'next_session', '', null), null)) .assertEqual(ApplyOutcome.APPLIED); expect(store.read()?.sessionSampleRate).assertEqual(30); }); - it('immediateActivationOnlyCutsSessionsWhenThisClientsRateChanged', + it('announcesTheRateThisClientHeldBeforeAndAfter', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); - let restarts: number = 0; + const announced: string[] = []; const controller: RemoteConfigController = new RemoteConfigController( - store, new FakeFetcher(), 'https://x/config', 100, (): void => { restarts++; }); + store, new FakeFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + announced.push(`${activation}:${before}:${after}`); + }); controller.apply(body(1, true, 20, 'immediate'), null); - expect(restarts).assertEqual(1); - // The console republishing the same rate must not cut every session in two. controller.apply(body(2, true, 20, 'immediate'), null); - expect(restarts).assertEqual(1); - controller.apply(body(3, true, 60, 'immediate'), null); - expect(restarts).assertEqual(2); + controller.apply(body(3, true, 60, 'next_session'), null); + // `before` is read before the write, so the scope can tell a real move + // from the console re-publishing what this client already holds. + expect(announced[0]).assertEqual('immediate:null:20'); + expect(announced[1]).assertEqual('immediate:20:20'); + expect(announced[2]).assertEqual('next_session:20:60'); + }); + + it('refusesAConfigurationOlderThanTheOneInForce', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + let announcements: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (_a: string, _b: number | null, _c: number | null): void => { announcements++; }); + // In force: foreground refresh off, which is what the operator chose. + controller.apply(body(7, true, 5, 'next_session', '', 1), '"e7"'); + announcements = 0; + + // A lagging replica or a proxy replaying a held body. Rollbacks are + // published as a NEW version, so an older one can only be stale. + expect(controller.apply(`{"schema_version":1,"version":6,"ttl":30,"enabled":true,` + + `"activation":"immediate","refresh_on_foreground":true,"rum":{"sessionSampleRate":50}}`, '"e6"')) + .assertEqual(ApplyOutcome.STALE_VERSION); + + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertEqual(5); + expect(read.version).assertEqual(7); + expect(read.etag).assertEqual('"e7"'); // the validator still validates what we hold + expect(announcements).assertEqual(0); + // Nothing the refused body carried may take effect, not even the fields + // that are not values: a permission read out of a body we would not + // read would outlive it. Its `refresh_on_foreground: true` must not + // start the foreground fetching the operator turned off. + controller.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(0); }); + it('refusesABodyThatLostItsVersionAfterOneWasApplied', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(3, true, 10), null); + + // Version 0 and a missing version both read as "no version": after a + // real one has been applied, only a lagging node answers that way. + expect(controller.apply(body(0, true, 90), null)).assertEqual(ApplyOutcome.STALE_VERSION); + expect(controller.apply('{"schema_version":1,"enabled":true,"rum":{"sessionSampleRate":90}}', null)) + .assertEqual(ApplyOutcome.STALE_VERSION); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(10); + }); + + it('appliesAConfigurationRepublishedUnderTheSameVersion', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(4, true, 10), null); + // Equal is not stale: the same version can resolve to different values + // for this client when its env or app version changed under a rule. + expect(controller.apply(body(4, true, 80), null)).assertEqual(ApplyOutcome.APPLIED); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(80); + }); + + it('ignoresANegativeRateFromTheServer', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(2, true, -1), null); + // A negative rate would sample nothing: collection must not be switched + // off by a malformed body. Read off the stored entry rather than through + // `read()`, which guards the value a second time and would hide a hole + // here. + let serialized: string = ''; + core.settings.forEach((value: string, _key: string): void => { serialized = value; }); + expect(serialized.indexOf('"rate"') >= 0).assertFalse(); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertNull(); + }); + it('foregroundRefreshNeedsPermissionAndAge', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { expect(RemoteConfigController.shouldRefreshOnForeground(false, 999999, 60)).assertFalse(); expect(RemoteConfigController.shouldRefreshOnForeground(true, 1000, 60)).assertFalse(); @@ -335,7 +436,7 @@ export default function remoteConfigTests(): void { const store: RemoteConfigStore = newStore(core); const fetcher: FakeFetcher = new FakeFetcher(); const controller: RemoteConfigController = new RemoteConfigController( - store, fetcher, 'https://x/config?client_token=t', 100, (): void => {}); + store, fetcher, 'https://x/config?client_token=t', ignoreChanges); controller.apply(body(6, true, 15), '"e6"'); fetcher.fail('connection reset'); @@ -355,7 +456,7 @@ export default function remoteConfigTests(): void { const store: RemoteConfigStore = newStore(core); const fetcher: FakeFetcher = new FakeFetcher(); const controller: RemoteConfigController = new RemoteConfigController( - store, fetcher, 'https://x/config?client_token=t', 100, (): void => {}); + store, fetcher, 'https://x/config?client_token=t', ignoreChanges); controller.apply(body(6, true, 15), '"e6"'); fetcher.answer(304, ''); @@ -377,6 +478,265 @@ export default function remoteConfigTests(): void { }); }); + describe('rum-remote-config-fetching', (): void => { + it('retriesTwiceThenWaitsForTheNextTrigger', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.fail('connection reset'); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(AFTER_EVERY_RETRY_MS); + // One ask plus the two the budget allows. A fleet must never turn an + // endpoint incident into a storm. + expect(fetcher.urls.length).assertEqual(3); + await sleep(AFTER_EVERY_RETRY_MS); + expect(fetcher.urls.length).assertEqual(3); + controller.stop(); + }); + + it('aNewSessionAsksAtOnceInsteadOfWaitingOutTheBackoff', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.fail('connection reset'); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(AFTER_EVERY_RETRY_MS); + expect(fetcher.urls.length).assertEqual(3); // budget spent + + // A session starting in the middle of an outage does not inherit an + // exhausted budget: it asks, and is owed the two retries again. + controller.onSessionStarted(); + await sleep(AFTER_EVERY_RETRY_MS); + expect(fetcher.urls.length).assertEqual(6); + controller.stop(); + }); + + it('asksAgainAfterAnUnchangedAnswer', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); + // "Nothing changed" is an answer, not a request still in flight: a 304 + // that left the controller wedged would freeze a whole fleet on the + // first unchanged reply. + controller.onSessionStarted(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(2); + controller.stop(); + }); + + it('dropsAResponseThatLandsAfterTheSdkStopped', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(3, true, 40, 'immediate')); + let announcements: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (_a: string, _b: number | null, _c: number | null): void => { announcements++; }, + QUICK_RETRIES); + + controller.start(); + controller.stop(); // the request is already on the wire and cannot be recalled + await sleep(20); + + expect(store.read()).assertNull(); + expect(announcements).assertEqual(0); + }); + + it('followsTheServerTtlWhenDecidingWhatIsStale', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + // ttl 1s, and the console allows the foreground to ask again. + fetcher.answer(200, `{"schema_version":1,"version":1,"ttl":1,"enabled":true,` + + `"activation":"next_session","refresh_on_foreground":true,"rum":{"sessionSampleRate":30}}`); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); + + controller.refreshIfStale(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); // still fresh: switching apps is not a reason + + await sleep(1100); + controller.refreshIfStale(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(2); // older than the ttl the server asked for + controller.stop(); + }); + + it('anUnsupportedSchemaLeavesTheRhythmAlone', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + controller.apply(body(2, true, 30), null); // refresh_on_foreground absent: off + + // A body written to a contract we cannot read is refused whole — its + // permissions included. + expect(controller.apply(`{"schema_version":99,"version":3,"ttl":1,"enabled":true,` + + `"activation":"next_session","refresh_on_foreground":true,"rum":{"sessionSampleRate":70}}`, null)) + .assertEqual(ApplyOutcome.UNSUPPORTED_SCHEMA); + + controller.refreshIfStale(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(0); + }); + }); + + describe('rum-remote-config-monitor', (): void => { + function monitorWith(core: FakeCore, scope: CapturingScope, store: RemoteConfigStore, + fetcher: FakeFetcher, rate: number): DefaultRumMonitor { + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + return new DefaultRumMonitor( + core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(rate) + .setRemoteConfigurationEnabled(true).build(), + store, controller); + } + + it('everyNewSessionAsksTheConsoleAgain', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, newStore(core), fetcher, 100); + + monitor.startView('home', 'Home'); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); + + // Without this the console's change reaches nobody who keeps the app + // open: the next ask would wait for a cold start. + monitor.stopSession(); + monitor.startView('next', 'Next'); + await sleep(20); + expect(fetcher.urls.length).assertEqual(2); + + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + + it('stopRemoteConfigEndsTheAsking', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, newStore(core), fetcher, 100); + + monitor.stopRemoteConfig(); + monitor.startView('home', 'Home'); + await sleep(20); + expect(fetcher.urls.length).assertEqual(0); + monitor.stopKeepAlive(); + }); + + it('setForcedSessionCollectsAVisitorTheRateDropped', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 3, null, null)); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, store, fetcher, 100); + + monitor.startView('home', 'Home'); + expect(scope.written.length).assertEqual(0); + + monitor.setForcedSession(); + monitor.startView('home', 'Home'); + expect(scope.written.length > 0).assertTrue(); + + await sleep(20); + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + + it('aPublishedChangeTravelsFromTheResponseToTheRunningSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); // collection is off + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(2, true, 100)); // and the console turns it on + + // Wired the way `FlashcatRum.enable` wires it: the controller exists + // before the monitor that owns it, so the listener finds the monitor + // when it fires rather than when it is built. + let monitorRef: DefaultRumMonitor | null = null; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + if (monitorRef !== null) { + monitorRef.onRemoteConfigurationChanged(activation, before, after); + } + }, + QUICK_RETRIES); + const monitor: DefaultRumMonitor = new DefaultRumMonitor( + core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(100) + .setRemoteConfigurationEnabled(true).build(), + store, controller); + monitorRef = monitor; + + // Drawn under the stored 0, so nothing is collected — and starting it + // is what asks the console. + monitor.startView('home', 'Home'); + expect(scope.written.length).assertEqual(0); + await sleep(20); + + // The response travelled response → store → announcement → monitor → + // scope, and ended the session that could not collect anything. + monitor.startView('next', 'Next'); + expect(scope.written.length > 0).assertTrue(); + + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + + it('getRemoteConfigHandsBackTheDecodedBag', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 3, '{"flags":{"beta":true}}', null)); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, store, fetcher, 100); + + const bag: Record = monitor.getRemoteConfig() as Record; + const flags: Record = bag['flags'] as Record; + expect(flags['beta']).assertTrue(); // decoded, not handed back as a string + + await sleep(20); + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + }); + describe('rum-remote-config-sampling', (): void => { it('publishedRateAppliesToTheNextSessionNotTheRunningOne', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { @@ -391,7 +751,9 @@ export default function remoteConfigTests(): void { expect(scope.written.length > 0).assertTrue(); // the console's 100 beat the init 0 const runningSession: string = sessionIdOf(scope.written[0]); - // The console drops to 0 while the session runs: it must keep collecting. + // Storage changing under a running session decides nothing on its own: + // only an announcement through `onConfigurationChanged` can end one, and + // that path is pinned by `theConsoleTurningCollectionOffStopsTheRunningSession`. store.write(new RemoteConfigValues(0, 5, null, null)); app.handleEvent(raw('addAction', now + 1000)); const last: Record = scope.written[scope.written.length - 1]; @@ -469,14 +831,46 @@ export default function remoteConfigTests(): void { it('forcedSessionCollectsWhatTheRateDropped', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const scope: CapturingScope = new CapturingScope(); - const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, newStore(core)); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(3, 7, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); const now: number = Date.now(); - startView(app, now); - expect(scope.written.length).assertEqual(0); app.forceSession(now + 100); startView(app, now + 200); expect(scope.written.length > 0).assertTrue(); + + // A forced session was not drawn by the published rate, so it must not be + // reported under it: the console weights a session by 100/rate, and one + // forced visitor at 3% would land in the estimate as thirty-three. + const configuration: Record = + configurationOf(scope.written[0]) as Record; + expect(configuration['session_sample_rate']).assertEqual(100); + expect(configuration['rc_version']).assertUndefined(); + }); + + it('hookCannotRaiseTheRateAboveTheScale', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 2, null, null)); + const app: RumApplicationScope = new RumApplicationScope( + scope, core, 'app-1', 0, store, + (_c: BeforeSamplingContext): number | undefined => 101); + startView(app, Date.now()); + // 101 is not a rate; the incoming 0 stands rather than being clamped to + // something nobody asked for. + expect(scope.written.length).assertEqual(0); + }); + + it('ignoresANegativeRateLeftInStorage', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(-1, 4, null, null)); + const read: RemoteConfigValues | null = store.read(); + // A corrupted entry must not switch collection off: it reads as "nothing + // published", so the init value keeps applying. + expect(read === null || read.sessionSampleRate === null).assertTrue(); }); it('forcingAgainWhileItRunsChangesNothing', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { @@ -509,13 +903,168 @@ export default function remoteConfigTests(): void { expect(configuration['rc_version']).assertEqual(12); }); - it('carriesNoConfigurationBlockWhenRemoteConfigurationIsOff', + it('carriesTheDrawnRateEvenWhenRemoteConfigurationIsOff', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const scope: CapturingScope = new CapturingScope(); const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100); startView(app, Date.now()); - expect(configurationOf(scope.written[0])).assertUndefined(); + + // The backend weights every session by this rate whether or not the app + // opted into remote configuration; sending nothing is read as a zero, + // which the console files under "SDK too old to report". + const configuration: Record = + configurationOf(scope.written[0]) as Record; + expect(configuration['session_sample_rate']).assertEqual(100); + // No version, because no configuration decided it. + expect(configuration['rc_version']).assertUndefined(); + }); + }); + + describe('rum-remote-config-activation', (): void => { + it('endsARunningSessionOnlyWhenTheChangeDecidesSomething', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A session already under way is never re-drawn on a rate that merely + // moved: re-rolling a session that lost a fair draw would bias + // collection upward with every publish. + expect(RumApplicationScope.shouldEndSession(false, 0, 100, 'next_session')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(false, 30, 100, 'next_session')).assertFalse(); + expect(RumApplicationScope.shouldEndSession(false, 0, 0, 'next_session')).assertFalse(); + // Collecting, and the change means "collect nothing": an emergency stop + // has to reach the session that is running. + expect(RumApplicationScope.shouldEndSession(true, 100, 0, 'next_session')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(true, 100, 50, 'next_session')).assertFalse(); + // `immediate` is the console asking for this session to be re-decided, + // but there is still nothing to gain from replacing a dark session with + // another one the new rate cannot collect either. + expect(RumApplicationScope.shouldEndSession(true, 100, 50, 'immediate')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(false, 30, 50, 'immediate')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(false, 30, 0, 'immediate')).assertFalse(); + }); + + it('turningCollectionOnReachesTheSessionThatIsAlreadyDark', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + expect(scope.written.length).assertEqual(0); + const dark: string = app.getCurrentSessionId() as string; + + store.write(new RemoteConfigValues(100, 2, null, null)); + app.onConfigurationChanged('next_session', 0, 100, now + 1000); + // Ended, so the next event draws under the new rate. Without this an + // operator who just switched collection on sees nothing from anyone + // already using the app — for up to four hours. + expect(app.getCurrentSessionId()).assertUndefined(); + + startView(app, now + 2000); + expect(scope.written.length > 0).assertTrue(); + expect(app.getCurrentSessionId() === dark).assertFalse(); + }); + + it('republishingTheSameRateCutsNothing', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + const running: string = app.getCurrentSessionId() as string; + + app.onConfigurationChanged('immediate', 100, 100, now + 1000); + expect(app.getCurrentSessionId()).assertEqual(running); + // Falling back to the init value on both sides is the same non-change. + app.onConfigurationChanged('immediate', null, null, now + 2000); + expect(app.getCurrentSessionId()).assertEqual(running); + }); + + it('theKillSwitchIsMeasuredAgainstTheInitValue', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + expect(scope.written.length).assertEqual(0); + + // The console hands the knob back: `after` is null, and what applies + // from now on is the 100 this app was built with. + store.write(new RemoteConfigValues(null, 2, null, null)); + app.onConfigurationChanged('next_session', 0, null, now + 1000); + expect(app.getCurrentSessionId()).assertUndefined(); + }); + + it('aForcedSessionIsNeverEndedByARate', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); + const now: number = Date.now(); + app.forceSession(now); + startView(app, now + 100); + const forced: string = app.getCurrentSessionId() as string; + + // Every replacement would be forced too, so ending this one only costs + // the view the visitor is on. + app.onConfigurationChanged('next_session', 0, 50, now + 200); + expect(app.getCurrentSessionId()).assertEqual(forced); + app.onConfigurationChanged('immediate', 50, 0, now + 300); + expect(app.getCurrentSessionId()).assertEqual(forced); + }); + + it('aSessionTheHookKeepsIsNeverEndedByARate', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const allowList: BeforeSamplingCallback = + (_c: BeforeSamplingContext): number | undefined => 100; + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store, allowList); + const now: number = Date.now(); + startView(app, now); + const kept: string = app.getCurrentSessionId() as string; + expect(scope.written.length > 0).assertTrue(); + + // The allow-list already answers the question the rate is asking, in + // both directions: cutting these sessions would split the timeline of + // exactly the visitors someone chose to keep. + store.write(new RemoteConfigValues(5, 2, null, null)); + app.onConfigurationChanged('next_session', 0, 5, now + 1000); + expect(app.getCurrentSessionId()).assertEqual(kept); + + store.write(new RemoteConfigValues(0, 3, null, null)); + app.onConfigurationChanged('next_session', 5, 0, now + 2000); + expect(app.getCurrentSessionId()).assertEqual(kept); + }); + + it('theConsoleTurningCollectionOffStopsTheRunningSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + expect(app.getCurrentSessionId() !== undefined).assertTrue(); + + store.write(new RemoteConfigValues(0, 2, null, null)); + app.onConfigurationChanged('next_session', 100, 0, now + 1000); + expect(app.getCurrentSessionId()).assertUndefined(); + + const written: number = scope.written.length; + startView(app, now + 2000); + app.handleEvent(raw('addAction', now + 3000)); + expect(scope.written.length).assertEqual(written); // and the replacement collects nothing }); }); } From c1807180fbdf09713c8f0aacf563edc22eb693c0 Mon Sep 17 00:00:00 2001 From: Fiona Date: Thu, 10 Sep 2026 05:24:17 -0700 Subject: [PATCH 07/10] fix(rum): preserve remote configuration state across updates --- .../src/main/ets/RumConfiguration.ets | 12 +- flashcat-rum/src/main/ets/RumTypes.ets | 7 +- .../remoteconfig/RemoteConfigController.ets | 40 ++-- .../remoteconfig/RemoteConfigStore.ets | 27 ++- .../internal/scope/RumApplicationScope.ets | 15 +- flashcat-rum/src/test/RemoteConfig.test.ets | 193 +++++++++++++++++- 6 files changed, 258 insertions(+), 36 deletions(-) diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index e9e4065..eb9c8fd 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -121,8 +121,9 @@ export class RumConfigurationBuilder { * Default false — nothing is fetched, and every rate stays exactly what this * builder was given. * - * A published rate applies to the NEXT session drawn, never to the one - * running: a session is decided once, at its creation. Values already + * A published rate applies to the next session drawn. A decisive change + * through zero or an immediate activation may end the current session; + * sampling never changes within a session. Values already * fetched survive a restart, so the first session of a launch is drawn under * them; when nothing has ever been fetched, or the console has the feature * switched off, the init values apply. @@ -134,9 +135,10 @@ export class RumConfigurationBuilder { /** * Have the last word on session sampling. Called synchronously each time a - * new session is about to be drawn, with the rate that would apply and the - * console's custom values; return a rate to override, or nothing to leave it - * alone. Typical use is an allow-list: keep every session of the handful of + * new session is about to be drawn and when configuration arrives, with the + * rate that would apply and the console's custom values. Keep the callback + * free of side effects and deterministic. Return a rate to override, or + * nothing to leave it alone. Typical use is an allow-list: keep every session of the handful of * users you are debugging while the fleet stays at a low rate. */ setBeforeSampling(callback: BeforeSamplingCallback): RumConfigurationBuilder { diff --git a/flashcat-rum/src/main/ets/RumTypes.ets b/flashcat-rum/src/main/ets/RumTypes.ets index 2303f1c..edddf55 100644 --- a/flashcat-rum/src/main/ets/RumTypes.ets +++ b/flashcat-rum/src/main/ets/RumTypes.ets @@ -28,9 +28,10 @@ export interface BeforeSamplingContext { * The application's last word on session sampling, called synchronously each * time a new session is about to be drawn. Return a rate to override — 100 * always collects, 0 never does — or `undefined` to leave the incoming rate - * alone. Runs inside session creation, so it must be fast and synchronous; a - * throw or an out-of-range value is ignored. A session already under way is - * never re-decided. + * alone. Also called when configuration arrives to decide whether to end the + * current session. It must be fast, synchronous, free of side effects, and + * return the same result for the same input. A throw or an out-of-range value + * is ignored. Sampling never changes within an existing session. */ export type BeforeSamplingCallback = (context: BeforeSamplingContext) => number | undefined; diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index fdf7e7f..1164855 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -1,8 +1,7 @@ import { FlashcatLog } from '@flashcatcloud/core'; -import { RemoteConfigStore, RemoteConfigValues } from './RemoteConfigStore'; +import { RemoteConfigStore, RemoteConfigValues, DEFAULT_TTL_SECONDS } from './RemoteConfigStore'; import { RemoteConfigFetcher, RemoteConfigResponse } from './RemoteConfigFetcher'; -const DEFAULT_TTL_SECONDS: number = 300; const RETRY_DELAYS_SECONDS: number[] = [5, 60]; const JITTER_FRACTION: number = 0.2; const MAX_RATE: number = 100; @@ -43,7 +42,7 @@ export enum ApplyOutcome { */ STALE_VERSION = 'stale_version', /** - * The body was not a configuration at all — not JSON, or truncated. A captive portal answering + * The body was not a configuration: invalid JSON or an invalid envelope. A captive portal answering * 200 with a login page looks exactly like this, so it is treated as a request that did not * arrive rather than as a configuration saying nothing. */ @@ -116,6 +115,9 @@ export class RemoteConfigController { this.configUrl = configUrl; this.onConfigurationChanged = onConfigurationChanged; this.retryDelaysSeconds = retryDelaysSeconds; + const stored: RemoteConfigValues | null = store.read(); + this.ttlSeconds = stored?.ttlSeconds ?? DEFAULT_TTL_SECONDS; + this.refreshOnForeground = stored?.refreshOnForeground ?? false; } start(): void { @@ -281,11 +283,18 @@ export class RemoteConfigController { + `this SDK reads version ${SUPPORTED_SCHEMA_VERSION}. Update the SDK to take the console's settings again`); return ApplyOutcome.UNSUPPORTED_SCHEMA; } - // Read next, and acted on before anything else in the body is: a body older than the one in - // force must leave no trace at all, and a permission or a ttl read out of it ahead of this - // check would outlive the values we refuse. The version is the only thing worth reading from - // a body we may not keep. - const version: number | null = RemoteConfigController.positiveIntOf(json[FIELD_VERSION]); + // Validate the envelope before changing any state: valid JSON alone is + // not proof that the response is a configuration. Then reject older + // versions before applying their values, permissions, or refresh cadence. + const rawVersion: Object | undefined = json[FIELD_VERSION]; + const rum: Object | undefined = json[FIELD_RUM]; + if (typeof rawVersion !== 'number' || !Number.isSafeInteger(rawVersion) || rawVersion < 0 + || typeof json[FIELD_ENABLED] !== 'boolean' + || (rum !== undefined && RemoteConfigController.objectOf(rum) === null)) { + FlashcatLog.d('rum.remoteconfig: invalid response envelope; keeping the values already in use'); + return ApplyOutcome.UNREADABLE; + } + const version: number = rawVersion as number; const applied: number | null = this.store.appliedVersion(); if ((version ?? 0) < (applied ?? 0)) { FlashcatLog.d(`rum.remoteconfig: ignoring version ${version ?? 'none'}; ` @@ -295,7 +304,7 @@ export class RemoteConfigController { const enabled: boolean = json[FIELD_ENABLED] === true; const activation: string = RemoteConfigController.stringOf(json[FIELD_ACTIVATION]) ?? ''; - this.refreshOnForeground = json[FIELD_REFRESH_ON_FOREGROUND] === true; + const refreshOnForeground: boolean = json[FIELD_REFRESH_ON_FOREGROUND] === true; const before: number | null = this.storedRate(); const rate: number | null = enabled @@ -304,16 +313,15 @@ export class RemoteConfigController { // 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; - this.store.write(new RemoteConfigValues(rate, version, custom, etag)); + const ttl: number = RemoteConfigController.positiveIntOf(json[FIELD_TTL]) ?? DEFAULT_TTL_SECONDS; + this.store.write(new RemoteConfigValues(rate, version, custom, etag, ttl, refreshOnForeground)); + this.ttlSeconds = ttl; + this.refreshOnForeground = refreshOnForeground; // Announced after the write, so the scope's hook sees the values that are // now in force rather than the ones they replaced. this.onConfigurationChanged(activation, before, rate); - // Remembered here rather than around the request, so a fetch that fails - // keeps the ttl the server last asked for instead of falling back to ours. - const ttl: number | null = RemoteConfigController.positiveIntOf(json[FIELD_TTL]); - this.ttlSeconds = ttl ?? DEFAULT_TTL_SECONDS; return ApplyOutcome.APPLIED; } @@ -369,10 +377,10 @@ export class RemoteConfigController { } private static positiveIntOf(value: Object | undefined): number | null { - if (typeof value !== 'number' || Number.isNaN(value) || (value as number) <= 0) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value <= 0) { return null; } - return Math.trunc(value as number); + return value as number; } private static stringOf(value: Object | undefined): string | null { diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index 6c17c98..0a7294e 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -1,5 +1,7 @@ import { SdkCore, FlashcatContext } from '@flashcatcloud/core'; +export const DEFAULT_TTL_SECONDS: number = 300; + /** * The values one configuration response carried. `null` means the console did * not set that knob, so whatever was passed to init keeps applying — an absent @@ -13,17 +15,23 @@ export class RemoteConfigValues { readonly custom: string | null; /** Validator echoed back as If-None-Match, quoted exactly as the server sent it. */ readonly etag: string | null; + readonly ttlSeconds: number; + readonly refreshOnForeground: boolean; constructor( sessionSampleRate: number | null, version: number | null = null, custom: string | null = null, - etag: string | null = null + etag: string | null = null, + ttlSeconds: number = DEFAULT_TTL_SECONDS, + refreshOnForeground: boolean = false ) { this.sessionSampleRate = sessionSampleRate; this.version = version; this.custom = custom; this.etag = etag; + this.ttlSeconds = ttlSeconds; + this.refreshOnForeground = refreshOnForeground; } } @@ -50,6 +58,8 @@ const FIELD_RATE: string = 'rate'; const FIELD_VERSION: string = 'version'; 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 MAX_RATE: number = 100; /** @@ -100,6 +110,8 @@ export class RemoteConfigStore { if (values.etag !== null) { record[FIELD_ETAG] = values.etag; } + record[FIELD_TTL] = values.ttlSeconds; + record[FIELD_REFRESH_ON_FOREGROUND] = values.refreshOnForeground; this.core.writeSetting(this.valuesKey(), JSON.stringify(record)); } @@ -160,11 +172,18 @@ export class RemoteConfigStore { if (record === null) { return null; } + const ttl: number | null = RemoteConfigStore.intOf(record[FIELD_TTL]); + const hasRefreshSettings: boolean = ttl !== null && ttl > 0 + && typeof record[FIELD_REFRESH_ON_FOREGROUND] === 'boolean'; + // 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. return new RemoteConfigValues( RemoteConfigStore.rateOf(record[FIELD_RATE]), RemoteConfigStore.intOf(record[FIELD_VERSION]), RemoteConfigStore.stringOf(record[FIELD_CUSTOM]), - RemoteConfigStore.stringOf(record[FIELD_ETAG]) + hasRefreshSettings ? RemoteConfigStore.stringOf(record[FIELD_ETAG]) : null, + ttl !== null && ttl > 0 ? ttl : DEFAULT_TTL_SECONDS, + record[FIELD_REFRESH_ON_FOREGROUND] === true ); } @@ -193,10 +212,10 @@ export class RemoteConfigStore { } private static intOf(value: Object | undefined): number | null { - if (typeof value !== 'number' || Number.isNaN(value)) { + if (typeof value !== 'number' || !Number.isSafeInteger(value) || value < 0) { return null; } - return Math.trunc(value as number); + return value as number; } private static stringOf(value: Object | undefined): string | null { diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index 3b32187..d457bd9 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -41,6 +41,9 @@ export class RumApplicationScope implements RumScope { // Set through RumMonitor.setForcedSession, read at every draw from then on. // 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; constructor( featureScope: FeatureScope, @@ -223,6 +226,7 @@ export class RumApplicationScope implements RumScope { 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; // Carried on every session, whether or not the app opted into remote // configuration: the backend weights a session by the rate it reports, and @@ -293,7 +297,7 @@ export class RumApplicationScope implements RumScope { * 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. */ - onConfigurationChanged(activation: string, before: number | null, after: number | null, nowMs: number): void { + onConfigurationChanged(activation: string, _before: number | null, after: number | null, nowMs: number): void { if (this.session === null) { return; // the next event mints one, and it will draw under what just arrived } @@ -302,16 +306,17 @@ export class RumApplicationScope implements RumScope { // session back and costs the view the visitor is on. return; } - const previousRate: number = before ?? this.sampleRate; const nextRate: number = after ?? this.sampleRate; - if (previousRate === nextRate) { - return; // the console re-publishing what this client already holds - } // The hook has the last word on the rate that would apply, exactly as it // 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()) { + return; // unchanged effective settings must not cause another draw + } if (RumApplicationScope.shouldEndSession( this.session.isSampled(), this.session.getDrawnRate(), resolvedRate, activation)) { this.stopCurrentSession(nowMs); diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 71bf017..0fc1cca 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -285,6 +285,59 @@ export default function remoteConfigTests(): void { expect(read.version).assertEqual(5); // still traceable to the change that switched it off }); + it('rejectsMalformedEnvelopesWithoutChangingStoredValuesOrRefreshPermission', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const invalid: string[] = [ + '{"schema_version":1,"version":999999}', + '{"version":999999,"enabled":"false"}', + '{"version":999999,"enabled":null}', + '{"enabled":true}', + '{"version":-1,"enabled":true}', + '{"version":6.5,"enabled":true}', + '{"version":1e309,"enabled":true}', + '{"version":9007199254740992,"enabled":true}', + '{"version":999999,"enabled":true,"rum":[]}', + '{"version":999999,"enabled":true,"rum":"invalid"}', + '{"version":999999,"enabled":true,"rum":null}' + ]; + for (const payload of invalid) { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + let announcements: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (_a: string, _b: number | null, _c: number | null): void => { announcements++; }); + controller.apply(body(5, true, 0, 'next_session', '{"vip":["u1"]}'), '"e5"'); + const malformed: string = payload.replace('}', ',"refresh_on_foreground":true,"ttl":1}'); + expect(controller.apply(malformed, '"invalid"')).assertEqual(ApplyOutcome.UNREADABLE); + const stored: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(stored.version).assertEqual(5); + expect(stored.sessionSampleRate).assertEqual(0); + expect(stored.custom).assertEqual('{"vip":["u1"]}'); + expect(stored.etag).assertEqual('"e5"'); + expect(announcements).assertEqual(1); + controller.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(0); + expect(controller.apply(body(6, false, null), '"e6"')).assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.version).assertEqual(6); + expect(store.read()?.sessionSampleRate).assertNull(); + controller.stop(); + } + }); + + it('acceptsVersionZeroBeforeAnyConfigurationWasPublished', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + expect(controller.apply('{"schema_version":1,"version":0,"enabled":false}', '"empty"')) + .assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.sessionSampleRate).assertNull(); + expect(controller.apply(body(1, true, 25), '"e1"')).assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.sessionSampleRate).assertEqual(25); + }); + it('unreadableBodyIsAFailedAskNotAnEmptyConfiguration', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const store: RemoteConfigStore = newStore(core); @@ -382,11 +435,10 @@ export default function remoteConfigTests(): void { store, new FakeFetcher(), 'https://x/config', ignoreChanges); controller.apply(body(3, true, 10), null); - // Version 0 and a missing version both read as "no version": after a - // real one has been applied, only a lagging node answers that way. + // Version 0 is an older answer; a missing version is not a configuration. expect(controller.apply(body(0, true, 90), null)).assertEqual(ApplyOutcome.STALE_VERSION); expect(controller.apply('{"schema_version":1,"enabled":true,"rum":{"sessionSampleRate":90}}', null)) - .assertEqual(ApplyOutcome.STALE_VERSION); + .assertEqual(ApplyOutcome.UNREADABLE); expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(10); }); @@ -557,6 +609,74 @@ export default function remoteConfigTests(): void { expect(announcements).assertEqual(0); }); + it('restoresForegroundRefreshAndTtlBeforeAnUnchangedStartupResponse', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const first: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + first.apply('{"version":1,"enabled":true,"ttl":1,"refresh_on_foreground":true,"rum":{}}', '"e1"'); + first.stop(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const restarted: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges); + try { + restarted.start(); + await sleep(20); + expect(fetcher.validators[0]).assertEqual('"e1"'); + restarted.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(1); + await sleep(1100); + restarted.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(2); + } finally { + restarted.stop(); + } + }); + + it('keepsForegroundRefreshOffAcrossAnUnchangedStartupResponse', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const first: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + first.apply('{"version":1,"enabled":true,"ttl":1,"refresh_on_foreground":false}', '"e1"'); + first.stop(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const restarted: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges); + try { + restarted.start(); + await sleep(1100); + restarted.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(1); + } finally { + restarted.stop(); + } + }); + + it('fetchesAFullResponseWhenCachedRefreshSettingsAreMissing', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(25, 1, null, '"e1"')); + core.settings.forEach((_value: string, key: string): void => { + core.settings.set(key, JSON.stringify({ rate: 25, version: 1, etag: 'e1' })); + }); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(1, true, 25), '"e1"'); + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', ignoreChanges); + expect(store.read()?.sessionSampleRate).assertEqual(25); + controller.start(); + await sleep(20); + controller.stop(); + expect(fetcher.validators[0]).assertNull(); + expect(store.read()?.sessionSampleRate).assertEqual(25); + }); + it('followsTheServerTtlWhenDecidingWhatIsStale', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { const core: FakeCore = new FakeCore(); @@ -966,6 +1086,73 @@ export default function remoteConfigTests(): void { expect(app.getCurrentSessionId() === dark).assertFalse(); }); + it('activatesCustomOnlyAllowListChangesInBothDirections', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const activations: string[] = ['next_session', 'immediate']; + for (const activation of activations) { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + const hook: BeforeSamplingCallback = (context: BeforeSamplingContext): number | undefined => + context.custom !== null && context.custom['allowed'] === true ? 100 : 0; + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store, hook); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', + (mode: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(mode, before, after, Date.now()); + }); + controller.apply(body(1, true, 0, activation, '{"allowed":false}'), null); + startView(app, Date.now()); + expect(scope.written.length).assertEqual(0); + controller.apply(body(2, true, 0, activation, '{"allowed":true}'), null); + expect(app.getCurrentSessionId()).assertUndefined(); + app.handleEvent(raw('addAction', Date.now())); + expect(scope.written.length > 0).assertTrue(); + const kept: string = app.getCurrentSessionId() as string; + controller.apply(body(3, true, 0, activation, '{"allowed":true,"label":"changed"}'), null); + expect(app.getCurrentSessionId()).assertEqual(kept); + controller.apply(body(4, true, 0, activation, '{"allowed":false}'), null); + expect(app.getCurrentSessionId()).assertUndefined(); + const count: number = scope.written.length; + app.handleEvent(raw('addAction', Date.now())); + expect(scope.written.length).assertEqual(count); + controller.stop(); + } + }); + + it('doesNotRedrawWhenTheHookKeepsTheSameEffectiveRate', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const app: RumApplicationScope = new RumApplicationScope( + new CapturingScope(), new FakeCore(), 'app-1', 100, store, + (_context: BeforeSamplingContext): number | undefined => 100); + store.write(new RemoteConfigValues(0, 1)); + startView(app, Date.now()); + const kept: string = app.getCurrentSessionId() as string; + store.write(new RemoteConfigValues(20, 2)); + app.onConfigurationChanged('immediate', 0, 20, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(kept); + }); + + it('doesNotRedrawAnUnchangedRateAfterADeferredChange', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const app: RumApplicationScope = new RumApplicationScope(new CapturingScope(), core, 'app-1', 100, store); + store.write(new RemoteConfigValues(100, 1)); + startView(app, Date.now()); + const kept: string = app.getCurrentSessionId() as string; + store.write(new RemoteConfigValues(50, 2)); + app.onConfigurationChanged('next_session', 100, 50, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(kept); + store.write(new RemoteConfigValues(50, 3)); + app.onConfigurationChanged('immediate', 50, 50, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(kept); + store.write(new RemoteConfigValues(25, 4)); + app.onConfigurationChanged('immediate', 50, 25, Date.now()); + expect(app.getCurrentSessionId()).assertUndefined(); + }); + it('republishingTheSameRateCutsNothing', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); const scope: CapturingScope = new CapturingScope(); From 30011f1494c3768ba77ca1261d1f7ca457d0315f Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 13 Sep 2026 14:52:22 -0700 Subject: [PATCH 08/10] fix(rum): bound remote configuration input and prevent sampling reentry --- .../src/main/ets/RumConfiguration.ets | 6 +- flashcat-rum/src/main/ets/RumTypes.ets | 3 +- .../remoteconfig/RemoteConfigController.ets | 5 +- .../remoteconfig/RemoteConfigFetcher.ets | 2 + .../remoteconfig/RemoteConfigStore.ets | 29 +++- .../internal/scope/RumApplicationScope.ets | 17 +- flashcat-rum/src/test/RemoteConfig.test.ets | 160 ++++++++++++++++++ 7 files changed, 214 insertions(+), 8 deletions(-) diff --git a/flashcat-rum/src/main/ets/RumConfiguration.ets b/flashcat-rum/src/main/ets/RumConfiguration.ets index eb9c8fd..f9c2533 100644 --- a/flashcat-rum/src/main/ets/RumConfiguration.ets +++ b/flashcat-rum/src/main/ets/RumConfiguration.ets @@ -137,8 +137,10 @@ export class RumConfigurationBuilder { * Have the last word on session sampling. Called synchronously each time a * new session is about to be drawn and when configuration arrives, with the * rate that would apply and the console's custom values. Keep the callback - * free of side effects and deterministic. Return a rate to override, or - * nothing to leave it alone. Typical use is an allow-list: keep every session of the handful of + * lightweight, free of side effects and deterministic: it runs on the caller's + * thread. Do not perform I/O or report RUM events from it. Reentrant events + * and session operations are ignored until the callback returns. Return a rate + * to override, or nothing to leave it alone. Typical use is an allow-list: keep every session of the handful of * users you are debugging while the fleet stays at a low rate. */ setBeforeSampling(callback: BeforeSamplingCallback): RumConfigurationBuilder { diff --git a/flashcat-rum/src/main/ets/RumTypes.ets b/flashcat-rum/src/main/ets/RumTypes.ets index edddf55..afb7b29 100644 --- a/flashcat-rum/src/main/ets/RumTypes.ets +++ b/flashcat-rum/src/main/ets/RumTypes.ets @@ -31,7 +31,8 @@ export interface BeforeSamplingContext { * alone. Also called when configuration arrives to decide whether to end the * current session. It must be fast, synchronous, free of side effects, and * return the same result for the same input. A throw or an out-of-range value - * is ignored. Sampling never changes within an existing session. + * is ignored. Reentrant RUM events and session operations are ignored while + * the callback runs. Sampling never changes within an existing session. */ export type BeforeSamplingCallback = (context: BeforeSamplingContext) => number | undefined; diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets index 1164855..0170b99 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -314,7 +314,10 @@ export class RemoteConfigController { // 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; - this.store.write(new RemoteConfigValues(rate, version, custom, etag, ttl, refreshOnForeground)); + if (!this.store.write(new RemoteConfigValues(rate, version, custom, etag, ttl, refreshOnForeground))) { + FlashcatLog.d('rum.remoteconfig: configuration exceeds the storage budget; keeping the values already in use'); + return ApplyOutcome.UNREADABLE; + } this.ttlSeconds = ttl; this.refreshOnForeground = refreshOnForeground; diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets index 78c2ecc..9709aeb 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets @@ -1,5 +1,6 @@ import { http } from '@kit.NetworkKit'; import { FlashcatContext, FlashcatLog } from '@flashcatcloud/core'; +import { MAX_CONFIG_BYTES } from './RemoteConfigStore'; const CONFIG_PATH: string = '/api/v2/rum/config'; const TIMEOUT_MS: number = 10000; @@ -41,6 +42,7 @@ export class HttpRemoteConfigFetcher implements RemoteConfigFetcher { method: http.RequestMethod.GET, header: header, expectDataType: http.HttpDataType.STRING, + maxLimit: MAX_CONFIG_BYTES, connectTimeout: TIMEOUT_MS, readTimeout: TIMEOUT_MS }); diff --git a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets index 0a7294e..76fd21e 100644 --- a/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -1,6 +1,10 @@ import { SdkCore, FlashcatContext } from '@flashcatcloud/core'; +import { util } from '@kit.ArkTS'; export const DEFAULT_TTL_SECONDS: number = 300; +// Covers the 16 KiB custom budget, the response envelope, and JSON escaping +// when custom is stored as a string. Bound both network input and local reads. +export const MAX_CONFIG_BYTES: number = 64 * 1024; /** * The values one configuration response carried. `null` means the console did @@ -92,8 +96,14 @@ export class RemoteConfigStore { * Replaces what is stored with what the response carried. A knob the response * omitted is dropped rather than left behind, so turning a knob off in the * console really does hand it back to the value the app was initialised with. + * Returns false when the entry exceeds the budget, leaving the old entry + * intact. Persistence itself remains best-effort through the core. */ - write(values: RemoteConfigValues): void { + write(values: RemoteConfigValues): boolean { + if ((values.custom !== null && !RemoteConfigStore.isWithinSizeLimit(values.custom)) + || (values.etag !== null && !RemoteConfigStore.isWithinSizeLimit(values.etag))) { + return false; + } const record: Record = {}; if (values.sessionSampleRate !== null) { record[FIELD_RATE] = values.sessionSampleRate; @@ -112,7 +122,12 @@ export class RemoteConfigStore { } record[FIELD_TTL] = values.ttlSeconds; record[FIELD_REFRESH_ON_FOREGROUND] = values.refreshOnForeground; - this.core.writeSetting(this.valuesKey(), JSON.stringify(record)); + const serialized: string = JSON.stringify(record); + if (!RemoteConfigStore.isWithinSizeLimit(serialized)) { + return false; + } + this.core.writeSetting(this.valuesKey(), serialized); + return true; } /** @@ -192,6 +207,9 @@ export class RemoteConfigStore { * body and this store's own entries need exactly the same tolerance. */ static parseObject(serialized: string): Record | null { try { + if (!RemoteConfigStore.isWithinSizeLimit(serialized)) { + return null; + } const value: Object = JSON.parse(serialized) as Object; if (typeof value !== 'object' || value === null || Array.isArray(value)) { return null; @@ -202,6 +220,13 @@ export class RemoteConfigStore { } } + private static isWithinSizeLimit(value: string): boolean { + // Reject large strings before allocating an encoding buffer. The second + // check counts UTF-8 bytes, so multibyte values cannot bypass the budget. + return value.length <= MAX_CONFIG_BYTES + && new util.TextEncoder().encodeInto(value).byteLength <= MAX_CONFIG_BYTES; + } + /** An out-of-range rate is treated as absent rather than clamped: a rate we * cannot trust is not a rate to sample a customer's traffic with. */ private static rateOf(value: Object | undefined): number | null { diff --git a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets index d457bd9..c728d33 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -44,6 +44,7 @@ export class RumApplicationScope implements RumScope { // 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; + private invokingBeforeSampling: boolean = false; constructor( featureScope: FeatureScope, @@ -64,6 +65,9 @@ export class RumApplicationScope implements RumScope { } handleEvent(event: RumRawEvent): RumScope | null { + if (this.invokingBeforeSampling) { + return this; + } // Late completions owned by an already-ended session settle into it. if ((event.kind === 'stopResource' || event.kind === 'stopResourceWithError') && this.drainingSession !== null @@ -195,7 +199,7 @@ export class RumApplicationScope implements RumScope { * restarts it there (same recovery path as inactivity expiry). */ stopCurrentSession(nowMs: number): void { - if (this.session === null) { + if (this.invokingBeforeSampling || this.session === null) { return; } this.rememberExpiredView(this.session); @@ -259,6 +263,10 @@ export class RumApplicationScope implements RumScope { if (hook === null) { return rate; } + // A hook may accidentally report a RUM event before the new session has + // been assigned. Ignore reentrant events and session mutations until it + // returns, rather than recursively drawing more sessions. + this.invokingBeforeSampling = true; try { const custom: Record | null = stored !== null ? RemoteConfigStore.decodeCustom(stored.custom) : null; @@ -272,6 +280,8 @@ export class RumApplicationScope implements RumScope { } catch (e) { FlashcatLog.e(`rum.app: beforeSampling threw, keeping ${rate}: ${e instanceof Error ? e.message : 'error'}`); return rate; + } finally { + this.invokingBeforeSampling = false; } } @@ -298,7 +308,7 @@ export class RumApplicationScope implements RumScope { * view it costs, so a dark session is left alone when the new rate is zero. */ onConfigurationChanged(activation: string, _before: number | null, after: number | null, nowMs: number): void { - if (this.session === null) { + if (this.invokingBeforeSampling || this.session === null) { return; // the next event mints one, and it will draw under what just arrived } if (this.forced) { @@ -347,6 +357,9 @@ export class RumApplicationScope implements RumScope { * ends now so a collected one starts in its place. */ forceSession(nowMs: number): void { + if (this.invokingBeforeSampling) { + return; + } this.forced = true; if (this.session !== null && this.session.isSampled()) { return; // already collecting — nothing to end diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 0fc1cca..957b3c2 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -183,6 +183,166 @@ function body(version: number, enabled: boolean, rate: number | null, } export default function remoteConfigTests(): void { + describe('rum-remote-config-host-isolation', (): void => { + it('rejectsOversizedResponsesWithoutChangingStoredStateAndCanRecover', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + let changes: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', (): void => { changes++; }); + controller.apply(body(1, true, 25, 'next_session', '{"allowed":true}'), '"old"'); + const key: string = Array.from(core.settings.keys())[0]; + const previous: string = core.settings.get(key) as string; + + expect(controller.apply(body(99, true, 0, 'immediate', + `{"blob":"${'x'.repeat(64 * 1024)}"}`), '"oversized"')).assertEqual(ApplyOutcome.UNREADABLE); + expect(core.settings.get(key)).assertEqual(previous); + expect(changes).assertEqual(1); + expect(controller.apply(body(2, true, 50), '"new"')).assertEqual(ApplyOutcome.APPLIED); + expect(store.appliedVersion()).assertEqual(2); + expect(changes).assertEqual(2); + }); + + it('countsUtf8BytesAndAcceptsTheExactResponseBoundary', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + const payload: string = body(1, true, 100); + const atLimit: string = payload + ' '.repeat(64 * 1024 - payload.length); + expect(controller.apply(atLimit, '"one"')).assertEqual(ApplyOutcome.APPLIED); + expect(controller.apply(atLimit + ' ', '"too-large"')).assertEqual(ApplyOutcome.UNREADABLE); + const multibyte: string = body(2, true, 0, 'immediate', `{"blob":"${'\u4e00'.repeat(24000)}"}`); + expect(multibyte.length < 64 * 1024).assertTrue(); + expect(controller.apply(multibyte, '"multibyte"')).assertEqual(ApplyOutcome.UNREADABLE); + expect(store.appliedVersion()).assertEqual(1); + }); + + it('keepsTheOldConfigurationWhenTheSerializedCacheWouldExceedTheBudget', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + let changes: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', (): void => { changes++; }); + controller.apply(body(1, true, 25), '"old"'); + expect(controller.apply(body(2, true, 0), 'x'.repeat(64 * 1024))).assertEqual(ApplyOutcome.UNREADABLE); + expect(store.appliedVersion()).assertEqual(1); + expect((store.read() as RemoteConfigValues).etag).assertEqual('"old"'); + expect(changes).assertEqual(1); + }); + + it('ignoresOversizedCachedValuesAndRequestsAFullReplacement', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1)); + const key: string = Array.from(core.settings.keys())[0]; + const custom: Record = { blob: '\u4e00'.repeat(24000) }; + const cached: string = JSON.stringify({ rate: 0, version: 99, custom: JSON.stringify(custom), + etag: '"cached"', ttl: 1, refresh_on_foreground: true }); + expect(cached.length < 64 * 1024).assertTrue(); + core.settings.set(key, cached); + expect(store.read()).assertNull(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(2, true, 50), '"replacement"'); + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config?client_token=t', ignoreChanges); + controller.start(); + await sleep(20); + controller.stop(); + expect(fetcher.validators[0]).assertNull(); + expect(fetcher.urls[0]).assertEqual('https://x/config?client_token=t'); + expect(store.appliedVersion()).assertEqual(2); + }); + + it('preservesAValidCacheWhenAnOversizedWriteIsAttempted', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + store.write(new RemoteConfigValues(25, 1, '{"ok":true}', '"one"')); + store.write(new RemoteConfigValues(0, 99, `{"blob":"${'x'.repeat(64 * 1024)}"}`, '"large"')); + expect(store.appliedVersion()).assertEqual(1); + expect((store.read() as RemoteConfigValues).custom).assertEqual('{"ok":true}'); + }); + + it('roundTripsTheSupportedCustomBudgetIncludingEscapedValues', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const custom: Record = { a: '"'.repeat(2000), b: '"'.repeat(2000), + c: '"'.repeat(2000), d: '"'.repeat(2000) }; + const serialized: string = JSON.stringify(custom); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + expect(controller.apply(body(1, true, 100, 'next_session', serialized), '"tag"')).assertEqual(ApplyOutcome.APPLIED); + expect((store.read() as RemoteConfigValues).custom).assertEqual(serialized); + const multibyte: string = `{"text":"${'\u4e00'.repeat(1000)}"}`; + expect(controller.apply(body(2, true, 100, 'next_session', multibyte), '"unicode"')).assertEqual(ApplyOutcome.APPLIED); + expect((store.read() as RemoteConfigValues).custom).assertEqual(multibyte); + }); + + it('doesNotReenterSamplingWhenTheHookReportsAnEvent', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + let calls: number = 0; + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 100, null, + (_context: BeforeSamplingContext): number | undefined => { + calls++; + if (calls < 4) { + app.handleEvent(raw('addAction', Date.now())); + } + return 100; + }); + startView(app, Date.now()); + expect(calls).assertEqual(1); + expect(scope.written.length).assertEqual(1); + app.stopCurrentSession(Date.now()); + startView(app, Date.now()); + expect(calls).assertEqual(2); + }); + + it('ignoresSessionMutationsInsideTheHookAndReleasesTheGuardAfterAThrow', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 0, null, + (_context: BeforeSamplingContext): number | undefined => { + app.forceSession(Date.now()); + app.stopCurrentSession(Date.now()); + throw new Error('callback failed'); + }); + startView(app, Date.now()); + expect(scope.written.length).assertEqual(0); + app.forceSession(Date.now()); + startView(app, Date.now()); + expect(scope.written.length > 0).assertTrue(); + }); + + it('doesNotEndTheSessionReentrantlyDuringConfigurationActivation', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1)); + let calls: number = 0; + const app: RumApplicationScope = new RumApplicationScope( + new CapturingScope(), core, 'app-1', 100, store, + (_context: BeforeSamplingContext): number | undefined => { + calls++; + if (calls === 2) { + app.stopCurrentSession(Date.now()); + app.onConfigurationChanged('immediate', 100, 0, Date.now()); + } + return undefined; + }); + startView(app, Date.now()); + const session: string | undefined = app.getCurrentSessionId(); + app.onConfigurationChanged('next_session', 100, 100, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(session); + expect(calls).assertEqual(2); + }); + }); + describe('rum-remote-config-store', (): void => { it('keepsAbsentKnobsAbsent', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const core: FakeCore = new FakeCore(); From 69b40ba7a389db9d8a3c1877264be9f61828341e Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 13 Sep 2026 16:16:46 -0700 Subject: [PATCH 09/10] fix(rum): close an expired session's view at its last activity A session only learns it has expired from the next event. Ending it before that event arrives - a remote configuration applied on return to foreground, or stopSession after a long idle - closed the view at the current time and credited the whole idle gap to time_spent. Close it where natural expiry does: at the last observed-alive time. --- .../ets/internal/scope/RumSessionScope.ets | 18 ++++++++---- flashcat-rum/src/test/RemoteConfig.test.ets | 29 +++++++++++++++++++ .../src/test/SessionLifecycle.test.ets | 17 +++++++++++ 3 files changed, 59 insertions(+), 5 deletions(-) diff --git a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets index 0024235..d0a307c 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumSessionScope.ets @@ -79,7 +79,7 @@ export class RumSessionScope implements RumScope { // later, and stamping it would credit the whole idle gap to the view // (a 5-min visit reads as 4 h of time_spent). if (this.activeView !== null) { - const closeAtMs: number = Math.max(this.lastActivityMs, this.lastKeepAliveMs); + const closeAtMs: number = this.lastAliveMs(); // A crash that itself detects the expiry belongs to THIS session (the // incident snapshot already carries this session/view id): count it in // the final document, not in a phantom replacement session. @@ -212,13 +212,17 @@ export class RumSessionScope implements RumScope { } /** - * Explicit end (RumMonitor.stopSession, e.g. logout): close the active view - * at the caller's timestamp (a real user action, unlike lazy expiry) and - * un-publish the session identity. + * Explicit end (RumMonitor.stopSession, e.g. logout, or a remote configuration + * that decides the running session): close the active view and un-publish the + * session identity. A live session closes at the caller's timestamp. One that + * has already expired closes at its last observed-alive time, as the next event + * would have closed it: expiry is only noticed by that event, nothing arrives + * while the app is in the background, and a configuration response landing on + * the return to foreground would otherwise credit the whole idle gap to the view. */ end(nowMs: number): void { if (this.activeView !== null) { - this.activeView.forceStop(nowMs); + this.activeView.forceStop(this.isExpired(nowMs) ? this.lastAliveMs() : nowMs); this.retireView(this.activeView); // keep in-flight resources drainable this.activeView = null; } @@ -294,6 +298,10 @@ export class RumSessionScope implements RumScope { }); } + private lastAliveMs(): number { + return Math.max(this.lastActivityMs, this.lastKeepAliveMs); + } + private isExpired(nowMs: number): boolean { return (nowMs - this.lastActivityMs) >= SESSION_INACTIVITY_MS || (nowMs - this.startedAtMs) >= SESSION_MAX_DURATION_MS; diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 957b3c2..40bac93 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -1246,6 +1246,35 @@ export default function remoteConfigTests(): void { expect(app.getCurrentSessionId() === dark).assertFalse(); }); + it('endingAnIdleSessionDoesNotCreditTheIdleGapToItsView', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const idleGapsMs: number[] = [60 * 1000, 3 * 60 * 60 * 1000]; + const expectedTimeSpentNs: number[] = [60 * 1000 * 1000000, 0]; + for (let i: number = 0; i < idleGapsMs.length; i++) { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + + // Collection switched off while the app sat in the background; the + // response lands on the return to foreground, before any event. + store.write(new RemoteConfigValues(0, 2, null, null)); + app.onConfigurationChanged('next_session', 100, 0, now + idleGapsMs[i]); + expect(app.getCurrentSessionId()).assertUndefined(); + + const views: Array> = + scope.written.filter((e: Record) => e['type'] === 'view'); + const closing: Record = views[views.length - 1]['view'] as Record; + expect(closing['is_active'] as boolean).assertFalse(); + // A live session closes when it is told to; one that already expired + // closes when it was last seen alive, not hours later. + expect(closing['time_spent'] as number).assertEqual(expectedTimeSpentNs[i]); + } + }); + it('activatesCustomOnlyAllowListChangesInBothDirections', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const activations: string[] = ['next_session', 'immediate']; diff --git a/flashcat-rum/src/test/SessionLifecycle.test.ets b/flashcat-rum/src/test/SessionLifecycle.test.ets index 7af7029..10590fb 100644 --- a/flashcat-rum/src/test/SessionLifecycle.test.ets +++ b/flashcat-rum/src/test/SessionLifecycle.test.ets @@ -211,6 +211,23 @@ export default function sessionLifecycleTests(): void { scope.ofType('view')[scope.ofType('view').length - 1]; expect(sessionIdOf(latest) === sessionA).assertFalse(); }); + + it('stopSessionAfterExpiryClosesViewAtLastActivity', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, new FakeCore(), 'app-1', 100); + const t0: number = Date.now(); + const start: RumRawEvent = raw('startView', t0, 'home'); + start.name = 'Home'; + app.handleEvent(start); + app.handleEvent(raw('keepAlive', t0 + 5 * 60 * 1000)); + + app.stopCurrentSession(t0 + 3 * MIN_15); + const views: Array> = scope.ofType('view'); + const closing: Record = views[views.length - 1]['view'] as Record; + // Closed at the last keep-alive, the latest moment the session was seen alive. + expect(closing['time_spent'] as number).assertEqual(5 * 60 * 1000 * 1000000); + }); }); describe('RumConfigTrackErrors', (): void => { From 2e2c714027bbb769d638cdff5867fb3e51392fe3 Mon Sep 17 00:00:00 2001 From: Fiona Date: Sun, 13 Sep 2026 16:21:50 -0700 Subject: [PATCH 10/10] =?UTF-8?q?chore(release):=200.6.0=20=E2=80=94=20rem?= =?UTF-8?q?ote=20configuration=20(#39)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Bump the five SDK packages to 0.6.0 in lockstep and record the release in each package's changelog. The RUM package can now read its session sample rate and a custom map from the console. --- flashcat-axios/CHANGELOG.md | 5 ++++ flashcat-axios/oh-package.json5 | 4 +-- flashcat-core/CHANGELOG.md | 11 ++++++++ flashcat-core/oh-package.json5 | 2 +- .../src/main/ets/internal/Version.ets | 2 +- flashcat-crash/CHANGELOG.md | 5 ++++ flashcat-crash/oh-package.json5 | 4 +-- .../src/test/CrashEventMapper.test.ets | 4 +-- .../src/test/CrashIncident.test.ets | 6 ++--- flashcat-rum/CHANGELOG.md | 27 +++++++++++++++++++ flashcat-rum/oh-package.json5 | 4 +-- flashcat-rum/src/test/RemoteConfig.test.ets | 4 +-- .../src/test/SessionLifecycle.test.ets | 2 +- flashcat-trace/CHANGELOG.md | 5 ++++ flashcat-trace/oh-package.json5 | 4 +-- 15 files changed, 71 insertions(+), 18 deletions(-) diff --git a/flashcat-axios/CHANGELOG.md b/flashcat-axios/CHANGELOG.md index 1a0609f..68036f7 100644 --- a/flashcat-axios/CHANGELOG.md +++ b/flashcat-axios/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` + 0.6.0: remote configuration). + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-axios/oh-package.json5 b/flashcat-axios/oh-package.json5 index ba62a25..86bcee6 100644 --- a/flashcat-axios/oh-package.json5 +++ b/flashcat-axios/oh-package.json5 @@ -1,13 +1,13 @@ { name: "@flashcatcloud/axios", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS axios integration: reports @ohos/axios requests as RUM resources with distributed-trace headers.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/trace": "0.5.1", + "@flashcatcloud/trace": "0.6.0", "@ohos/axios": "^2.2.4" } } diff --git a/flashcat-core/CHANGELOG.md b/flashcat-core/CHANGELOG.md index 76622f2..798de5c 100644 --- a/flashcat-core/CHANGELOG.md +++ b/flashcat-core/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.6.0 + +- `SdkCore` gains `getIntakeTarget()`, `readSetting()` and `writeSetting()`, + which feature packages use to call an endpoint of their own and keep small + settings across launches (see `@flashcatcloud/rum` 0.6.0: remote + configuration). + +### Breaking + +- Code that implements `SdkCore` itself needs to add the three methods above. + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-core/oh-package.json5 b/flashcat-core/oh-package.json5 index 466a2e8..cd493a9 100644 --- a/flashcat-core/oh-package.json5 +++ b/flashcat-core/oh-package.json5 @@ -1,6 +1,6 @@ { name: "@flashcatcloud/core", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS SDK core: init, configuration, context, message bus, storage and batched upload.", main: "Index.ets", license: "Apache-2.0", diff --git a/flashcat-core/src/main/ets/internal/Version.ets b/flashcat-core/src/main/ets/internal/Version.ets index 9124f9d..1180f24 100644 --- a/flashcat-core/src/main/ets/internal/Version.ets +++ b/flashcat-core/src/main/ets/internal/Version.ets @@ -5,4 +5,4 @@ * Keep this in sync with the `version` field of each module's * `oh-package.json5` on every release bump. */ -export const SDK_VERSION: string = '0.5.1'; +export const SDK_VERSION: string = '0.6.0'; diff --git a/flashcat-crash/CHANGELOG.md b/flashcat-crash/CHANGELOG.md index cd06184..0926bd9 100644 --- a/flashcat-crash/CHANGELOG.md +++ b/flashcat-crash/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` + 0.6.0: remote configuration). + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-crash/oh-package.json5 b/flashcat-crash/oh-package.json5 index a6f4d0b..e3a488b 100644 --- a/flashcat-crash/oh-package.json5 +++ b/flashcat-crash/oh-package.json5 @@ -1,12 +1,12 @@ { name: "@flashcatcloud/crash", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS crash reporting: hiAppEvent APP_CRASH (native + ArkTS) + APP_FREEZE, reported as RUM is_crash errors.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/core": "0.5.1" + "@flashcatcloud/core": "0.6.0" } } diff --git a/flashcat-crash/src/test/CrashEventMapper.test.ets b/flashcat-crash/src/test/CrashEventMapper.test.ets index ba8f144..7518d94 100644 --- a/flashcat-crash/src/test/CrashEventMapper.test.ets +++ b/flashcat-crash/src/test/CrashEventMapper.test.ets @@ -137,7 +137,7 @@ export default function crashEventMapperTests(): void { 'TypeError: boom\n at run (Index.ets:1:2)', 'fp-1', JsCrashPolicy.REPORT_THEN_EXIT, - '0.5.1', + '0.6.0', 'session-1', 'view-1', 'Home'); @@ -163,7 +163,7 @@ export default function crashEventMapperTests(): void { 'TypeError: boom\n at run (Index.ets:1:2)', 'fp-recovered', JsCrashPolicy.REPORT_AND_RECOVER, - '0.5.1', + '0.6.0', 'session-1', 'view-1', 'Home'); diff --git a/flashcat-crash/src/test/CrashIncident.test.ets b/flashcat-crash/src/test/CrashIncident.test.ets index 9e4e719..de4e9ea 100644 --- a/flashcat-crash/src/test/CrashIncident.test.ets +++ b/flashcat-crash/src/test/CrashIncident.test.ets @@ -45,7 +45,7 @@ export default function crashIncidentTests(): void { it('roundTripsViewUrlThroughSerialization', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const record: CrashIncidentRecord = new CrashIncidentRecord( 'inc-1', 1000, 'Error', 'boom', 'Error: boom\n at f (a.ets:1:1)', 'fp-1', - JsCrashPolicy.REPORT_THEN_EXIT, '0.5.1', 's-1', 'v-1', 'Home', 'pages/Home'); + JsCrashPolicy.REPORT_THEN_EXIT, '0.6.0', 's-1', 'v-1', 'Home', 'pages/Home'); const parsed: CrashIncidentRecord | null = CrashIncidentRecord.parse(record.serialize()); @@ -162,7 +162,7 @@ export default function crashIncidentTests(): void { 'TypeError: boom\n at run (Index.ets:1:2)', 'fp-1', JsCrashPolicy.REPORT_THEN_EXIT, - '0.5.1', + '0.6.0', 'session-1', 'view-1', 'Home'); @@ -178,7 +178,7 @@ export default function crashIncidentTests(): void { expect(parsed.stack.indexOf('Index.ets')).assertLarger(-1); expect(parsed.fingerprint).assertEqual('fp-1'); expect(parsed.policy).assertEqual(JsCrashPolicy.REPORT_THEN_EXIT); - expect(parsed.sdkVersion).assertEqual('0.5.1'); + expect(parsed.sdkVersion).assertEqual('0.6.0'); expect(parsed.sessionId).assertEqual('session-1'); expect(parsed.viewId).assertEqual('view-1'); expect(parsed.viewName).assertEqual('Home'); diff --git a/flashcat-rum/CHANGELOG.md b/flashcat-rum/CHANGELOG.md index 0d4309f..bd471e0 100644 --- a/flashcat-rum/CHANGELOG.md +++ b/flashcat-rum/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.6.0 + +- **Remote configuration.** `RumConfigurationBuilder.setRemoteConfigurationEnabled(true)` + lets the session sample rate and a `custom` map be changed from the console + without releasing the app again. Off by default. The SDK fetches the + configuration at startup, at every new session and, when the console allows + it, on return to foreground; a failed or malformed response never changes the + values in use. A new rate applies from the next session, unless the console + asks for it immediately or it switches collection on or off. +- `RumConfigurationBuilder.setBeforeSampling(callback)` gives the app the last + word on the rate a session is drawn with, for example an allow-list driven by + `custom`. It runs synchronously on the calling thread: keep it light, and do + not report RUM events from inside it. +- `RumMonitor.setForcedSession()` keeps every session from then on, and + `RumMonitor.getRemoteConfig()` returns the delivered `custom` map. +- View events now report the sample rate their session was drawn with + (`_dd.configuration.session_sample_rate`), also when remote configuration is + off, and the configuration version (`rc_version`) when one decided it. +- Fixed: ending a session that had already expired, for example `stopSession()` + after a long time in the background, credited the idle gap to the last + view's `time_spent`. + +### Breaking + +- `RumMonitor` gains `setForcedSession()` and `getRemoteConfig()`. Only code + that implements `RumMonitor` itself needs to add them. + ## 0.5.1 - App freezes (`APP_FREEZE`) are now reported with `error.category: "ANR"` diff --git a/flashcat-rum/oh-package.json5 b/flashcat-rum/oh-package.json5 index 04db192..59514ea 100644 --- a/flashcat-rum/oh-package.json5 +++ b/flashcat-rum/oh-package.json5 @@ -1,12 +1,12 @@ { name: "@flashcatcloud/rum", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS RUM: views, actions, resources, errors, sessions.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/core": "0.5.1" + "@flashcatcloud/core": "0.6.0" } } diff --git a/flashcat-rum/src/test/RemoteConfig.test.ets b/flashcat-rum/src/test/RemoteConfig.test.ets index 40bac93..70a241b 100644 --- a/flashcat-rum/src/test/RemoteConfig.test.ets +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -32,7 +32,7 @@ function testContext(appVersion: string = '1.0.0'): FlashcatContext { version: appVersion, bundleId: 'com.example.demo', source: 'harmony', - sdkVersion: '0.5.1', + sdkVersion: '0.6.0', device: { brand: 'HUAWEI', model: 'Pura', osName: 'HarmonyOS', osVersion: 'NEXT', apiVersion: 18, deviceType: 'phone' @@ -686,7 +686,7 @@ export default function remoteConfigTests(): void { it('buildsTheConfigUrlTheEngineAnswers', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const url: string = buildConfigUrl('https://intake.example.com/', 'ct 123', testContext('2.3.4')); expect(url).assertEqual( - 'https://intake.example.com/api/v2/rum/config?client_token=ct%20123&sdk=harmony&env=prod&app_version=2.3.4&sdk_version=0.5.1'); + 'https://intake.example.com/api/v2/rum/config?client_token=ct%20123&sdk=harmony&env=prod&app_version=2.3.4&sdk_version=0.6.0'); }); }); diff --git a/flashcat-rum/src/test/SessionLifecycle.test.ets b/flashcat-rum/src/test/SessionLifecycle.test.ets index 10590fb..25835f0 100644 --- a/flashcat-rum/src/test/SessionLifecycle.test.ets +++ b/flashcat-rum/src/test/SessionLifecycle.test.ets @@ -17,7 +17,7 @@ function testContext(): FlashcatContext { version: '1.0.0', bundleId: 'com.example.demo', source: 'harmony', - sdkVersion: '0.5.1', + sdkVersion: '0.6.0', device: { brand: 'HUAWEI', model: 'Pura', osName: 'HarmonyOS', osVersion: 'NEXT', apiVersion: 18, deviceType: 'phone' diff --git a/flashcat-trace/CHANGELOG.md b/flashcat-trace/CHANGELOG.md index 6bbf777..33fe1e5 100644 --- a/flashcat-trace/CHANGELOG.md +++ b/flashcat-trace/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` + 0.6.0: remote configuration). + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-trace/oh-package.json5 b/flashcat-trace/oh-package.json5 index 7dd05f8..bbac469 100644 --- a/flashcat-trace/oh-package.json5 +++ b/flashcat-trace/oh-package.json5 @@ -1,12 +1,12 @@ { name: "@flashcatcloud/trace", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS trace-context propagation: W3C traceparent generation + rcp interceptor + manual headers.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/core": "0.5.1" + "@flashcatcloud/core": "0.6.0" } }