diff --git a/entry/src/main/ets/common/DemoSdk.ets b/entry/src/main/ets/common/DemoSdk.ets index d9346e3..f14af5c 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,34 @@ 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; + return entries.includes(id); + } + 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..5079127 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,15 +47,53 @@ 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 (${e instanceof Error ? e.message : 'error'}); 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(); + } } + /** * 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/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..5a12dfa 100644 --- a/flashcat-rum/src/main/ets/RumMonitor.ets +++ b/flashcat-rum/src/main/ets/RumMonitor.ets @@ -36,6 +36,25 @@ export interface RumMonitor { getCurrentSessionId(callback: (sessionId: string | undefined) => void): void; + /** + * 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; + /** * 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 @@ -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..d2f8ac4 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 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 // 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..f41ab5a --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -0,0 +1,349 @@ +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; +/** + * 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'; +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; + } + // 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(); + } + }) + .catch((e: Object) => { + this.inFlight = false; + if (this.stopped) { + return; + } + FlashcatLog.d(`rum.remoteconfig: fetch failed (${e instanceof Error ? e.message : 'network error'}); 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. + * + * 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): ApplyOutcome { + const json: Record | null = RemoteConfigStore.parseObject(payload); + if (json === null) { + 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. + // 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 (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; + } + 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 ApplyOutcome.APPLIED; + } + + 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 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; + } + +} 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..490f444 --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -0,0 +1,204 @@ +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]) + ); + } + + /** 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)) { + 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..220727c 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,71 @@ 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 { + this.forced = true; + if (this.session !== null && this.session.isSampled()) { + return; // already collecting — nothing to end + } + 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..4d484cb --- /dev/null +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -0,0 +1,521 @@ +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, ApplyOutcome } 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 = '', 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}`); + } + 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"')) + .assertEqual(ApplyOutcome.APPLIED); + + 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)).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('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.APPLIED); + expect(store.read()?.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 {