From bbc40cfd3b6e1a382d1d0f9600902aa57436e69a Mon Sep 17 00:00:00 2001 From: Fiona Date: Mon, 24 Aug 2026 08:00:39 -0700 Subject: [PATCH 1/5] 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 2/5] 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 3/5] 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 4/5] 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 5/5] 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); }