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-axios/CHANGELOG.md b/flashcat-axios/CHANGELOG.md index 1a0609f..68036f7 100644 --- a/flashcat-axios/CHANGELOG.md +++ b/flashcat-axios/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` + 0.6.0: remote configuration). + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-axios/oh-package.json5 b/flashcat-axios/oh-package.json5 index ba62a25..86bcee6 100644 --- a/flashcat-axios/oh-package.json5 +++ b/flashcat-axios/oh-package.json5 @@ -1,13 +1,13 @@ { name: "@flashcatcloud/axios", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS axios integration: reports @ohos/axios requests as RUM resources with distributed-trace headers.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/trace": "0.5.1", + "@flashcatcloud/trace": "0.6.0", "@ohos/axios": "^2.2.4" } } diff --git a/flashcat-core/CHANGELOG.md b/flashcat-core/CHANGELOG.md index 76622f2..798de5c 100644 --- a/flashcat-core/CHANGELOG.md +++ b/flashcat-core/CHANGELOG.md @@ -1,5 +1,16 @@ # Changelog +## 0.6.0 + +- `SdkCore` gains `getIntakeTarget()`, `readSetting()` and `writeSetting()`, + which feature packages use to call an endpoint of their own and keep small + settings across launches (see `@flashcatcloud/rum` 0.6.0: remote + configuration). + +### Breaking + +- Code that implements `SdkCore` itself needs to add the three methods above. + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-core/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/oh-package.json5 b/flashcat-core/oh-package.json5 index 466a2e8..cd493a9 100644 --- a/flashcat-core/oh-package.json5 +++ b/flashcat-core/oh-package.json5 @@ -1,6 +1,6 @@ { name: "@flashcatcloud/core", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS SDK core: init, configuration, context, message bus, storage and batched upload.", main: "Index.ets", license: "Apache-2.0", diff --git a/flashcat-core/src/main/ets/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-core/src/main/ets/internal/Version.ets b/flashcat-core/src/main/ets/internal/Version.ets index 9124f9d..1180f24 100644 --- a/flashcat-core/src/main/ets/internal/Version.ets +++ b/flashcat-core/src/main/ets/internal/Version.ets @@ -5,4 +5,4 @@ * Keep this in sync with the `version` field of each module's * `oh-package.json5` on every release bump. */ -export const SDK_VERSION: string = '0.5.1'; +export const SDK_VERSION: string = '0.6.0'; diff --git a/flashcat-crash/CHANGELOG.md b/flashcat-crash/CHANGELOG.md index cd06184..0926bd9 100644 --- a/flashcat-crash/CHANGELOG.md +++ b/flashcat-crash/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` + 0.6.0: remote configuration). + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-crash/oh-package.json5 b/flashcat-crash/oh-package.json5 index a6f4d0b..e3a488b 100644 --- a/flashcat-crash/oh-package.json5 +++ b/flashcat-crash/oh-package.json5 @@ -1,12 +1,12 @@ { name: "@flashcatcloud/crash", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS crash reporting: hiAppEvent APP_CRASH (native + ArkTS) + APP_FREEZE, reported as RUM is_crash errors.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/core": "0.5.1" + "@flashcatcloud/core": "0.6.0" } } diff --git a/flashcat-crash/src/test/CrashEventMapper.test.ets b/flashcat-crash/src/test/CrashEventMapper.test.ets index ba8f144..7518d94 100644 --- a/flashcat-crash/src/test/CrashEventMapper.test.ets +++ b/flashcat-crash/src/test/CrashEventMapper.test.ets @@ -137,7 +137,7 @@ export default function crashEventMapperTests(): void { 'TypeError: boom\n at run (Index.ets:1:2)', 'fp-1', JsCrashPolicy.REPORT_THEN_EXIT, - '0.5.1', + '0.6.0', 'session-1', 'view-1', 'Home'); @@ -163,7 +163,7 @@ export default function crashEventMapperTests(): void { 'TypeError: boom\n at run (Index.ets:1:2)', 'fp-recovered', JsCrashPolicy.REPORT_AND_RECOVER, - '0.5.1', + '0.6.0', 'session-1', 'view-1', 'Home'); diff --git a/flashcat-crash/src/test/CrashIncident.test.ets b/flashcat-crash/src/test/CrashIncident.test.ets index 9e4e719..de4e9ea 100644 --- a/flashcat-crash/src/test/CrashIncident.test.ets +++ b/flashcat-crash/src/test/CrashIncident.test.ets @@ -45,7 +45,7 @@ export default function crashIncidentTests(): void { it('roundTripsViewUrlThroughSerialization', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { const record: CrashIncidentRecord = new CrashIncidentRecord( 'inc-1', 1000, 'Error', 'boom', 'Error: boom\n at f (a.ets:1:1)', 'fp-1', - JsCrashPolicy.REPORT_THEN_EXIT, '0.5.1', 's-1', 'v-1', 'Home', 'pages/Home'); + JsCrashPolicy.REPORT_THEN_EXIT, '0.6.0', 's-1', 'v-1', 'Home', 'pages/Home'); const parsed: CrashIncidentRecord | null = CrashIncidentRecord.parse(record.serialize()); @@ -162,7 +162,7 @@ export default function crashIncidentTests(): void { 'TypeError: boom\n at run (Index.ets:1:2)', 'fp-1', JsCrashPolicy.REPORT_THEN_EXIT, - '0.5.1', + '0.6.0', 'session-1', 'view-1', 'Home'); @@ -178,7 +178,7 @@ export default function crashIncidentTests(): void { expect(parsed.stack.indexOf('Index.ets')).assertLarger(-1); expect(parsed.fingerprint).assertEqual('fp-1'); expect(parsed.policy).assertEqual(JsCrashPolicy.REPORT_THEN_EXIT); - expect(parsed.sdkVersion).assertEqual('0.5.1'); + expect(parsed.sdkVersion).assertEqual('0.6.0'); expect(parsed.sessionId).assertEqual('session-1'); expect(parsed.viewId).assertEqual('view-1'); expect(parsed.viewName).assertEqual('Home'); diff --git a/flashcat-rum/CHANGELOG.md b/flashcat-rum/CHANGELOG.md index 0d4309f..bd471e0 100644 --- a/flashcat-rum/CHANGELOG.md +++ b/flashcat-rum/CHANGELOG.md @@ -1,5 +1,32 @@ # Changelog +## 0.6.0 + +- **Remote configuration.** `RumConfigurationBuilder.setRemoteConfigurationEnabled(true)` + lets the session sample rate and a `custom` map be changed from the console + without releasing the app again. Off by default. The SDK fetches the + configuration at startup, at every new session and, when the console allows + it, on return to foreground; a failed or malformed response never changes the + values in use. A new rate applies from the next session, unless the console + asks for it immediately or it switches collection on or off. +- `RumConfigurationBuilder.setBeforeSampling(callback)` gives the app the last + word on the rate a session is drawn with, for example an allow-list driven by + `custom`. It runs synchronously on the calling thread: keep it light, and do + not report RUM events from inside it. +- `RumMonitor.setForcedSession()` keeps every session from then on, and + `RumMonitor.getRemoteConfig()` returns the delivered `custom` map. +- View events now report the sample rate their session was drawn with + (`_dd.configuration.session_sample_rate`), also when remote configuration is + off, and the configuration version (`rc_version`) when one decided it. +- Fixed: ending a session that had already expired, for example `stopSession()` + after a long time in the background, credited the idle gap to the last + view's `time_spent`. + +### Breaking + +- `RumMonitor` gains `setForcedSession()` and `getRemoteConfig()`. Only code + that implements `RumMonitor` itself needs to add them. + ## 0.5.1 - App freezes (`APP_FREEZE`) are now reported with `error.category: "ANR"` diff --git a/flashcat-rum/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/oh-package.json5 b/flashcat-rum/oh-package.json5 index 04db192..59514ea 100644 --- a/flashcat-rum/oh-package.json5 +++ b/flashcat-rum/oh-package.json5 @@ -1,12 +1,12 @@ { name: "@flashcatcloud/rum", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS RUM: views, actions, resources, errors, sessions.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/core": "0.5.1" + "@flashcatcloud/core": "0.6.0" } } diff --git a/flashcat-rum/src/main/ets/FlashcatRum.ets b/flashcat-rum/src/main/ets/FlashcatRum.ets index fa13caf..09acb7d 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,58 @@ 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; + // Assigned a few lines below, and only ever read when a response comes + // back — by which time it is there. The controller has to exist first + // because the monitor owns it. + let monitorRef: DefaultRumMonitor | null = null; + if (configuration.remoteConfigurationEnabled) { + try { + const intake: IntakeTarget = core.getIntakeTarget(); + 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), + (activation: string, before: number | null, after: number | null): void => { + if (monitorRef !== null) { + monitorRef.onRemoteConfigurationChanged(activation, before, after); + } + } + ); + } catch (e) { + // Whatever went wrong here, the app keeps collecting with the values it + // 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); + monitorRef = monitor; 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..f9c2533 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,38 @@ 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. A decisive change + * through zero or an immediate activation may end the current session; + * sampling never changes within a session. Values already + * fetched survive a restart, so the first session of a launch is drawn under + * them; when nothing has ever been fetched, or the console has the feature + * switched off, the init values apply. + */ + 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 and when configuration arrives, with the + * rate that would apply and the console's custom values. Keep the callback + * lightweight, free of side effects and deterministic: it runs on the caller's + * thread. Do not perform I/O or report RUM events from it. Reentrant events + * and session operations are ignored until the callback returns. Return a rate + * to override, or nothing to leave it alone. Typical use is an allow-list: keep every session of the handful of + * users you are debugging while the fleet stays at a low rate. + */ + setBeforeSampling(callback: BeforeSamplingCallback): RumConfigurationBuilder { + this.beforeSampling = callback; + return this; + } + build(): RumConfiguration { return new RumConfiguration( this.applicationId, @@ -116,7 +157,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..afb7b29 100644 --- a/flashcat-rum/src/main/ets/RumTypes.ets +++ b/flashcat-rum/src/main/ets/RumTypes.ets @@ -11,6 +11,31 @@ */ 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. Also called when configuration arrives to decide whether to end the + * current session. It must be fast, synchronous, free of side effects, and + * return the same result for the same input. A throw or an out-of-range value + * is ignored. Reentrant RUM events and session operations are ignored while + * the callback runs. Sampling never changes within an existing session. + */ +export type BeforeSamplingCallback = (context: BeforeSamplingContext) => number | undefined; + 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..ff6adc6 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,68 @@ 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(); + } + } + + /** + * A configuration reached storage. Handed to the scope, which is the only + * part that knows whether the session running right now is being collected, + * whether the application forced it, and what its hook says. + */ + onRemoteConfigurationChanged(activation: string, before: number | null, after: number | null): void { + this.applicationScope.onConfigurationChanged(activation, before, after, Date.now()); + } + + setForcedSession(): void { + this.applicationScope.forceSession(Date.now()); + } + + 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..0170b99 --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigController.ets @@ -0,0 +1,393 @@ +import { FlashcatLog } from '@flashcatcloud/core'; +import { RemoteConfigStore, RemoteConfigValues, DEFAULT_TTL_SECONDS } from './RemoteConfigStore'; +import { RemoteConfigFetcher, RemoteConfigResponse } from './RemoteConfigFetcher'; + +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; + +/** + * Told after every configuration that reached storage: which activation the console asked for, + * the published rate this client held before it, and the one it holds now (`null` on either side + * means "nothing published, the init value applies"). + * + * It is an announcement, not an instruction. Whether a running session is worth ending is a + * question only the scope can answer — it is the one that knows whether that session is being + * collected, whether the host application forced it, and what the application's own hook says + * about the rate that would apply next. + */ +export type RemoteConfigChangeListener = + (activation: string, before: number | null, after: number | null) => void; + +/** + * What reading one response body came to. Only UNREADABLE is worth asking again for: the rest are + * answers, whether or not this SDK can act on them. + */ +export enum ApplyOutcome { + /** The body was read and its values are now stored. */ + APPLIED = 'applied', + /** + * The body is older than the configuration already in force. Rollbacks are published as a new + * version, so an older one can only come from a replica that has not caught up or an + * intermediary replaying something it held: it must not replace the stored values, their + * validator, or the rhythm the current body asked for. + */ + STALE_VERSION = 'stale_version', + /** + * The body was not a configuration: invalid JSON or an invalid envelope. A captive portal answering + * 200 with a login page looks exactly like this, so it is treated as a request that did not + * arrive rather than as a configuration saying nothing. + */ + 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 onConfigurationChanged: RemoteConfigChangeListener; + private readonly retryDelaysSeconds: number[]; + + 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; + + /** + * `retryDelaysSeconds` is the schedule an outage is retried on. It is a + * parameter for the same reason the fetcher is one: the retry rules are the + * half of this feature that must not be got wrong, and a test that had to + * wait out the real minute-long delay would never be written. Production + * always takes the default. + */ + constructor( + store: RemoteConfigStore, + fetcher: RemoteConfigFetcher, + configUrl: string, + onConfigurationChanged: RemoteConfigChangeListener, + retryDelaysSeconds: number[] = RETRY_DELAYS_SECONDS + ) { + this.store = store; + this.fetcher = fetcher; + this.configUrl = configUrl; + this.onConfigurationChanged = onConfigurationChanged; + this.retryDelaysSeconds = retryDelaysSeconds; + const stored: RemoteConfigValues | null = store.read(); + this.ttlSeconds = stored?.ttlSeconds ?? DEFAULT_TTL_SECONDS; + this.refreshOnForeground = stored?.refreshOnForeground ?? false; + } + + start(): void { + 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 >= this.retryDelaysSeconds.length) { + return; + } + const delaySeconds: number = + RemoteConfigController.jittered(this.retryDelaysSeconds[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 announces what changed for this + * client, so the scope can decide whether the session already running is + * worth ending. + * + * Everything a body says is refused together or kept together: a response + * this SDK will not read must not leave its ttl or its foreground permission + * behind. + */ + apply(payload: string, etag: string | null): ApplyOutcome { + const json: Record | null = RemoteConfigStore.parseObject(payload); + 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; + } + // Validate the envelope before changing any state: valid JSON alone is + // not proof that the response is a configuration. Then reject older + // versions before applying their values, permissions, or refresh cadence. + const rawVersion: Object | undefined = json[FIELD_VERSION]; + const rum: Object | undefined = json[FIELD_RUM]; + if (typeof rawVersion !== 'number' || !Number.isSafeInteger(rawVersion) || rawVersion < 0 + || typeof json[FIELD_ENABLED] !== 'boolean' + || (rum !== undefined && RemoteConfigController.objectOf(rum) === null)) { + FlashcatLog.d('rum.remoteconfig: invalid response envelope; keeping the values already in use'); + return ApplyOutcome.UNREADABLE; + } + const version: number = rawVersion as number; + const applied: number | null = this.store.appliedVersion(); + if ((version ?? 0) < (applied ?? 0)) { + FlashcatLog.d(`rum.remoteconfig: ignoring version ${version ?? 'none'}; ` + + `version ${applied} is already in force`); + return ApplyOutcome.STALE_VERSION; + } + + const enabled: boolean = json[FIELD_ENABLED] === true; + const activation: string = RemoteConfigController.stringOf(json[FIELD_ACTIVATION]) ?? ''; + const refreshOnForeground: boolean = json[FIELD_REFRESH_ON_FOREGROUND] === true; + + const before: number | null = this.storedRate(); + const rate: number | null = enabled + ? RemoteConfigController.readRate(RemoteConfigController.objectOf(json[FIELD_RUM])) + : null; + // Stored as the raw string: delivery is the platform's job, the meaning + // belongs to the host application. + const custom: string | null = enabled ? RemoteConfigController.rawObjectOf(json[FIELD_CUSTOM]) : null; + const ttl: number = RemoteConfigController.positiveIntOf(json[FIELD_TTL]) ?? DEFAULT_TTL_SECONDS; + if (!this.store.write(new RemoteConfigValues(rate, version, custom, etag, ttl, refreshOnForeground))) { + FlashcatLog.d('rum.remoteconfig: configuration exceeds the storage budget; keeping the values already in use'); + return ApplyOutcome.UNREADABLE; + } + this.ttlSeconds = ttl; + this.refreshOnForeground = refreshOnForeground; + + // Announced after the write, so the scope's hook sees the values that are + // now in force rather than the ones they replaced. + this.onConfigurationChanged(activation, before, rate); + + return ApplyOutcome.APPLIED; + } + + private storedRate(): number | null { + const stored: RemoteConfigValues | null = this.store.read(); + return stored === null ? null : stored.sessionSampleRate; + } + + /** + * 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.isSafeInteger(value) || value <= 0) { + return null; + } + return 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..9709aeb --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigFetcher.ets @@ -0,0 +1,89 @@ +import { http } from '@kit.NetworkKit'; +import { FlashcatContext, FlashcatLog } from '@flashcatcloud/core'; +import { MAX_CONFIG_BYTES } from './RemoteConfigStore'; + +const CONFIG_PATH: string = '/api/v2/rum/config'; +const TIMEOUT_MS: number = 10000; +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, + maxLimit: MAX_CONFIG_BYTES, + 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..76fd21e --- /dev/null +++ b/flashcat-rum/src/main/ets/internal/remoteconfig/RemoteConfigStore.ets @@ -0,0 +1,249 @@ +import { SdkCore, FlashcatContext } from '@flashcatcloud/core'; +import { util } from '@kit.ArkTS'; + +export const DEFAULT_TTL_SECONDS: number = 300; +// Covers the 16 KiB custom budget, the response envelope, and JSON escaping +// when custom is stored as a string. Bound both network input and local reads. +export const MAX_CONFIG_BYTES: number = 64 * 1024; + +/** + * The values one configuration response carried. `null` means the console did + * 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; + readonly ttlSeconds: number; + readonly refreshOnForeground: boolean; + + constructor( + sessionSampleRate: number | null, + version: number | null = null, + custom: string | null = null, + etag: string | null = null, + ttlSeconds: number = DEFAULT_TTL_SECONDS, + refreshOnForeground: boolean = false + ) { + this.sessionSampleRate = sessionSampleRate; + this.version = version; + this.custom = custom; + this.etag = etag; + this.ttlSeconds = ttlSeconds; + this.refreshOnForeground = refreshOnForeground; + } +} + +/** + * 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 FIELD_TTL: string = 'ttl'; +const FIELD_REFRESH_ON_FOREGROUND: string = 'refresh_on_foreground'; +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. + * Returns false when the entry exceeds the budget, leaving the old entry + * intact. Persistence itself remains best-effort through the core. + */ + write(values: RemoteConfigValues): boolean { + if ((values.custom !== null && !RemoteConfigStore.isWithinSizeLimit(values.custom)) + || (values.etag !== null && !RemoteConfigStore.isWithinSizeLimit(values.etag))) { + return false; + } + const record: Record = {}; + if (values.sessionSampleRate !== null) { + record[FIELD_RATE] = values.sessionSampleRate; + } + // 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; + } + record[FIELD_TTL] = values.ttlSeconds; + record[FIELD_REFRESH_ON_FOREGROUND] = values.refreshOnForeground; + const serialized: string = JSON.stringify(record); + if (!RemoteConfigStore.isWithinSizeLimit(serialized)) { + return false; + } + this.core.writeSetting(this.valuesKey(), serialized); + return true; + } + + /** + * 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. + * Sent on the next request as `applied_version`. Nothing reads it today — the + * console works out how far a change has reached from the `rc_version` the + * sessions themselves carry — but it costs one query parameter and it is the + * only signal a client that never draws a kept session could ever send. */ + appliedVersion(): number | null { + const values: RemoteConfigValues | null = this.read(); + return values === null ? null : values.version; + } + + 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; + } + const ttl: number | null = RemoteConfigStore.intOf(record[FIELD_TTL]); + const hasRefreshSettings: boolean = ttl !== null && ttl > 0 + && typeof record[FIELD_REFRESH_ON_FOREGROUND] === 'boolean'; + // A validator can only be reused with the whole state it validates. If + // refresh settings are missing, keep the rates but ask for a full body. + return new RemoteConfigValues( + RemoteConfigStore.rateOf(record[FIELD_RATE]), + RemoteConfigStore.intOf(record[FIELD_VERSION]), + RemoteConfigStore.stringOf(record[FIELD_CUSTOM]), + hasRefreshSettings ? RemoteConfigStore.stringOf(record[FIELD_ETAG]) : null, + ttl !== null && ttl > 0 ? ttl : DEFAULT_TTL_SECONDS, + record[FIELD_REFRESH_ON_FOREGROUND] === true + ); + } + + /** 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 { + if (!RemoteConfigStore.isWithinSizeLimit(serialized)) { + return null; + } + const value: Object = JSON.parse(serialized) as Object; + if (typeof value !== 'object' || value === null || Array.isArray(value)) { + return null; + } + return value as Record; + } catch (_e) { + return null; + } + } + + private static isWithinSizeLimit(value: string): boolean { + // Reject large strings before allocating an encoding buffer. The second + // check counts UTF-8 bytes, so multibyte values cannot bypass the budget. + return value.length <= MAX_CONFIG_BYTES + && new util.TextEncoder().encodeInto(value).byteLength <= MAX_CONFIG_BYTES; + } + + /** An out-of-range rate is treated as absent rather than clamped: a rate we + * cannot trust is not a rate to sample a customer's traffic with. */ + private static rateOf(value: Object | undefined): number | null { + 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.isSafeInteger(value) || value < 0) { + return null; + } + return 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..c728d33 100644 --- a/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets +++ b/flashcat-rum/src/main/ets/internal/scope/RumApplicationScope.ets @@ -1,6 +1,13 @@ 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'; + +/** What a forced session reports: it is kept whatever the rates say, so it stands for itself. */ +const FORCED_SAMPLE_RATE: number = 100; +/** The console asking for a change to reach the session that is already running. */ +const ACTIVATION_IMMEDIATE: string = 'immediate'; /** * Root of the RUM scope tree. Creates and replaces sessions as they expire. @@ -24,15 +31,43 @@ 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; + // A deferred change need not match the running session's draw. Remember the + // last resolved rate so republishing it cannot turn a deferral into a redraw. + private lastResolvedRate: number | null = null; + private invokingBeforeSampling: boolean = false; - constructor(featureScope: FeatureScope, 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 { + if (this.invokingBeforeSampling) { + return this; + } // Late completions owned by an already-ended session settle into it. if ((event.kind === 'stopResource' || event.kind === 'stopResourceWithError') && this.drainingSession !== null @@ -164,7 +199,7 @@ export class RumApplicationScope implements RumScope { * restarts it there (same recovery path as inactivity expiry). */ stopCurrentSession(nowMs: number): void { - if (this.session === null) { + if (this.invokingBeforeSampling || this.session === null) { return; } this.rememberExpiredView(this.session); @@ -183,7 +218,153 @@ 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); + this.lastResolvedRate = rate; + const version: number = stored !== null && stored.version !== null ? stored.version : 0; + // Carried on every session, whether or not the app opted into remote + // configuration: the backend weights a session by the rate it reports, and + // a session that reports none is counted once — the same as an SDK too old + // to say. The version is the part that is only meaningful when a + // configuration decided the rate, and it is omitted when none did. + // + // A forced session reports neither the rate that would have dropped it nor + // the version it ignored: it was not drawn by them. Reporting 3 there would + // have the console weight one debugging visitor as thirty-three sessions. + const draw: DrawnConfiguration = this.forced + ? new DrawnConfiguration(0, FORCED_SAMPLE_RATE) + : new DrawnConfiguration(version, rate); + const session: RumSessionScope = new RumSessionScope( + this.featureScope, this.core, this.applicationId, rate, nowMs, this.forced, draw); + if (this.onSessionStarted !== null) { + 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; + } + // A hook may accidentally report a RUM event before the new session has + // been assigned. Ignore reentrant events and session mutations until it + // returns, rather than recursively drawing more sessions. + this.invokingBeforeSampling = true; + try { + const custom: Record | null = + stored !== null ? RemoteConfigStore.decodeCustom(stored.custom) : null; + 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; + } finally { + this.invokingBeforeSampling = false; + } + } + + /** + * A configuration reached storage. The rates it carries apply to the NEXT + * session drawn — a session already under way is never re-judged on a rate + * that merely moved. It was drawn by a fair coin, and re-drawing it on every + * publish would bias collection upward. + * + * Three changes do reach the running session. Two are about zero, and hold + * whatever activation the console asked for: + * + * - Collection was off and is now on. Without this, an operator who has just + * switched a fleet on sees nothing at all from anyone already using the + * app, for as long as their sessions last: up to four hours, and nothing + * at all is indistinguishable from broken. + * - Collection was on and is now off. That is an emergency stop, and an + * emergency stop that waits four hours is not one. + * + * The third is the console asking for `immediate`, which is a request to + * re-decide the running session on any rate that really changed — the one + * case where a move between two non-zero rates is worth a new draw. Even + * then a replacement that still cannot collect anything is not worth the + * view it costs, so a dark session is left alone when the new rate is zero. + */ + onConfigurationChanged(activation: string, _before: number | null, after: number | null, nowMs: number): void { + if (this.invokingBeforeSampling || this.session === null) { + return; // the next event mints one, and it will draw under what just arrived + } + if (this.forced) { + // Every replacement would be forced too, so ending this one buys the same + // session back and costs the view the visitor is on. + return; + } + const nextRate: number = after ?? this.sampleRate; + // The hook has the last word on the rate that would apply, exactly as it + // does at a draw, so an allow-list answers this question too. Asked only + // here, and only once: this is an announcement, not a draw. + const stored: RemoteConfigValues | null = this.remoteConfig !== null ? this.remoteConfig.read() : null; + const resolvedRate: number = this.askBeforeSampling(nextRate, stored); + const previousResolvedRate: number | null = this.lastResolvedRate; + this.lastResolvedRate = resolvedRate; + if (resolvedRate === previousResolvedRate || resolvedRate === this.session.getDrawnRate()) { + return; // unchanged effective settings must not cause another draw + } + if (RumApplicationScope.shouldEndSession( + this.session.isSampled(), this.session.getDrawnRate(), resolvedRate, activation)) { + this.stopCurrentSession(nowMs); + } + } + + /** + * Whether a change decides anything for the session running right now. + * + * `drawnRate` is what that session was actually drawn with, which is the only + * way to tell a session that could never have been collected from one that + * lost a fair draw: only the first is owed another chance when collection is + * switched back on. + */ + static shouldEndSession(sampled: boolean, drawnRate: number, nextRate: number, activation: string): boolean { + if (sampled) { + return nextRate === 0 || activation === ACTIVATION_IMMEDIATE; + } + // A dark session is worth replacing only by one that can collect something; + // otherwise the replacement is just as dark and the visitor lost a view. + return nextRate > 0 && (drawnRate === 0 || activation === ACTIVATION_IMMEDIATE); + } + + /** + * RumMonitor.setForcedSession: from here on every draw keeps the session. A + * session already being collected keeps running — RUM cannot retro-collect + * what a running session already dropped — while one that was not collected + * ends now so a collected one starts in its place. + */ + forceSession(nowMs: number): void { + if (this.invokingBeforeSampling) { + return; + } + 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..d0a307c 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; 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 = new DrawnConfiguration(0, sampleRate) ) { 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; @@ -71,7 +79,7 @@ export class RumSessionScope implements RumScope { // later, and stamping it would credit the whole idle gap to the view // (a 5-min visit reads as 4 h of time_spent). if (this.activeView !== null) { - const closeAtMs: number = Math.max(this.lastActivityMs, this.lastKeepAliveMs); + const closeAtMs: number = this.lastAliveMs(); // A crash that itself detects the expiry belongs to THIS session (the // incident snapshot already carries this session/view id): count it in // the final document, not in a phantom replacement session. @@ -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; } @@ -204,13 +212,17 @@ export class RumSessionScope implements RumScope { } /** - * Explicit end (RumMonitor.stopSession, e.g. logout): close the active view - * at the caller's timestamp (a real user action, unlike lazy expiry) and - * un-publish the session identity. + * Explicit end (RumMonitor.stopSession, e.g. logout, or a remote configuration + * that decides the running session): close the active view and un-publish the + * session identity. A live session closes at the caller's timestamp. One that + * has already expired closes at its last observed-alive time, as the next event + * would have closed it: expiry is only noticed by that event, nothing arrives + * while the app is in the background, and a configuration response landing on + * the return to foreground would otherwise credit the whole idle gap to the view. */ end(nowMs: number): void { if (this.activeView !== null) { - this.activeView.forceStop(nowMs); + this.activeView.forceStop(this.isExpired(nowMs) ? this.lastAliveMs() : nowMs); this.retireView(this.activeView); // keep in-flight resources drainable this.activeView = null; } @@ -266,6 +278,12 @@ export class RumSessionScope implements RumScope { return this.lastViewAttributes; } + /** The rate this session was actually drawn with — the console's, the init + * value, or whatever the application's hook returned. */ + getDrawnRate(): number { + return this.draw.sessionSampleRate; + } + isSampled(): boolean { return this.sampled; } @@ -280,6 +298,10 @@ export class RumSessionScope implements RumScope { }); } + private lastAliveMs(): number { + return Math.max(this.lastActivityMs, this.lastKeepAliveMs); + } + private isExpired(nowMs: number): boolean { return (nowMs - this.lastActivityMs) >= SESSION_INACTIVITY_MS || (nowMs - this.startedAtMs) >= SESSION_MAX_DURATION_MS; diff --git a/flashcat-rum/src/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..70a241b --- /dev/null +++ b/flashcat-rum/src/test/RemoteConfig.test.ets @@ -0,0 +1,1446 @@ +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, RemoteConfigChangeListener } from '../main/ets/internal/remoteconfig/RemoteConfigController'; +import { RemoteConfigFetcher, RemoteConfigResponse, buildConfigUrl } from '../main/ets/internal/remoteconfig/RemoteConfigFetcher'; +import { BeforeSamplingContext, BeforeSamplingCallback } from '../main/ets/RumTypes'; +import { DefaultRumMonitor } from '../main/ets/internal/monitor/DefaultRumMonitor'; +import { RumConfigurationBuilder } from '../main/ets/RumConfiguration'; + +const ignoreChanges: RemoteConfigChangeListener = (_activation: string, _before: number | null, _after: number | null): void => {}; + +/** Retry delays short enough for a test to wait out, in seconds. The window a + * test then waits is several times the whole schedule, so a loaded machine + * cannot turn a timing assertion into a flake. */ +const QUICK_RETRIES: number[] = [0.02, 0.04]; +const AFTER_EVERY_RETRY_MS: number = 250; + +function sleep(ms: number): Promise { + return new Promise((resolve: () => void) => setTimeout(resolve, ms)); +} + +function testContext(appVersion: string = '1.0.0'): FlashcatContext { + return { + env: 'prod', + variant: '', + service: 'svc', + version: appVersion, + bundleId: 'com.example.demo', + source: 'harmony', + sdkVersion: '0.6.0', + device: { + brand: 'HUAWEI', model: 'Pura', osName: 'HarmonyOS', osVersion: 'NEXT', + apiVersion: 18, deviceType: 'phone' + }, + user: {}, + anonymousId: 'anon-device-1', + network: { status: 'connected', interfaces: ['wifi'] }, + featureContext: {} + }; +} + +class FakeCore implements SdkCore { + readonly name: string = 'test'; + readonly 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-host-isolation', (): void => { + it('rejectsOversizedResponsesWithoutChangingStoredStateAndCanRecover', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + let changes: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', (): void => { changes++; }); + controller.apply(body(1, true, 25, 'next_session', '{"allowed":true}'), '"old"'); + const key: string = Array.from(core.settings.keys())[0]; + const previous: string = core.settings.get(key) as string; + + expect(controller.apply(body(99, true, 0, 'immediate', + `{"blob":"${'x'.repeat(64 * 1024)}"}`), '"oversized"')).assertEqual(ApplyOutcome.UNREADABLE); + expect(core.settings.get(key)).assertEqual(previous); + expect(changes).assertEqual(1); + expect(controller.apply(body(2, true, 50), '"new"')).assertEqual(ApplyOutcome.APPLIED); + expect(store.appliedVersion()).assertEqual(2); + expect(changes).assertEqual(2); + }); + + it('countsUtf8BytesAndAcceptsTheExactResponseBoundary', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + const payload: string = body(1, true, 100); + const atLimit: string = payload + ' '.repeat(64 * 1024 - payload.length); + expect(controller.apply(atLimit, '"one"')).assertEqual(ApplyOutcome.APPLIED); + expect(controller.apply(atLimit + ' ', '"too-large"')).assertEqual(ApplyOutcome.UNREADABLE); + const multibyte: string = body(2, true, 0, 'immediate', `{"blob":"${'\u4e00'.repeat(24000)}"}`); + expect(multibyte.length < 64 * 1024).assertTrue(); + expect(controller.apply(multibyte, '"multibyte"')).assertEqual(ApplyOutcome.UNREADABLE); + expect(store.appliedVersion()).assertEqual(1); + }); + + it('keepsTheOldConfigurationWhenTheSerializedCacheWouldExceedTheBudget', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + let changes: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', (): void => { changes++; }); + controller.apply(body(1, true, 25), '"old"'); + expect(controller.apply(body(2, true, 0), 'x'.repeat(64 * 1024))).assertEqual(ApplyOutcome.UNREADABLE); + expect(store.appliedVersion()).assertEqual(1); + expect((store.read() as RemoteConfigValues).etag).assertEqual('"old"'); + expect(changes).assertEqual(1); + }); + + it('ignoresOversizedCachedValuesAndRequestsAFullReplacement', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1)); + const key: string = Array.from(core.settings.keys())[0]; + const custom: Record = { blob: '\u4e00'.repeat(24000) }; + const cached: string = JSON.stringify({ rate: 0, version: 99, custom: JSON.stringify(custom), + etag: '"cached"', ttl: 1, refresh_on_foreground: true }); + expect(cached.length < 64 * 1024).assertTrue(); + core.settings.set(key, cached); + expect(store.read()).assertNull(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(2, true, 50), '"replacement"'); + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config?client_token=t', ignoreChanges); + controller.start(); + await sleep(20); + controller.stop(); + expect(fetcher.validators[0]).assertNull(); + expect(fetcher.urls[0]).assertEqual('https://x/config?client_token=t'); + expect(store.appliedVersion()).assertEqual(2); + }); + + it('preservesAValidCacheWhenAnOversizedWriteIsAttempted', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + store.write(new RemoteConfigValues(25, 1, '{"ok":true}', '"one"')); + store.write(new RemoteConfigValues(0, 99, `{"blob":"${'x'.repeat(64 * 1024)}"}`, '"large"')); + expect(store.appliedVersion()).assertEqual(1); + expect((store.read() as RemoteConfigValues).custom).assertEqual('{"ok":true}'); + }); + + it('roundTripsTheSupportedCustomBudgetIncludingEscapedValues', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const custom: Record = { a: '"'.repeat(2000), b: '"'.repeat(2000), + c: '"'.repeat(2000), d: '"'.repeat(2000) }; + const serialized: string = JSON.stringify(custom); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + expect(controller.apply(body(1, true, 100, 'next_session', serialized), '"tag"')).assertEqual(ApplyOutcome.APPLIED); + expect((store.read() as RemoteConfigValues).custom).assertEqual(serialized); + const multibyte: string = `{"text":"${'\u4e00'.repeat(1000)}"}`; + expect(controller.apply(body(2, true, 100, 'next_session', multibyte), '"unicode"')).assertEqual(ApplyOutcome.APPLIED); + expect((store.read() as RemoteConfigValues).custom).assertEqual(multibyte); + }); + + it('doesNotReenterSamplingWhenTheHookReportsAnEvent', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + let calls: number = 0; + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 100, null, + (_context: BeforeSamplingContext): number | undefined => { + calls++; + if (calls < 4) { + app.handleEvent(raw('addAction', Date.now())); + } + return 100; + }); + startView(app, Date.now()); + expect(calls).assertEqual(1); + expect(scope.written.length).assertEqual(1); + app.stopCurrentSession(Date.now()); + startView(app, Date.now()); + expect(calls).assertEqual(2); + }); + + it('ignoresSessionMutationsInsideTheHookAndReleasesTheGuardAfterAThrow', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope( + scope, new FakeCore(), 'app-1', 0, null, + (_context: BeforeSamplingContext): number | undefined => { + app.forceSession(Date.now()); + app.stopCurrentSession(Date.now()); + throw new Error('callback failed'); + }); + startView(app, Date.now()); + expect(scope.written.length).assertEqual(0); + app.forceSession(Date.now()); + startView(app, Date.now()); + expect(scope.written.length > 0).assertTrue(); + }); + + it('doesNotEndTheSessionReentrantlyDuringConfigurationActivation', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1)); + let calls: number = 0; + const app: RumApplicationScope = new RumApplicationScope( + new CapturingScope(), core, 'app-1', 100, store, + (_context: BeforeSamplingContext): number | undefined => { + calls++; + if (calls === 2) { + app.stopCurrentSession(Date.now()); + app.onConfigurationChanged('immediate', 100, 0, Date.now()); + } + return undefined; + }); + startView(app, Date.now()); + const session: string | undefined = app.getCurrentSessionId(); + app.onConfigurationChanged('next_session', 100, 100, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(session); + expect(calls).assertEqual(2); + }); + }); + + describe('rum-remote-config-store', (): void => { + it('keepsAbsentKnobsAbsent', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + 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); + const announced: string[] = []; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config?client_token=t', + (activation: string, before: number | null, after: number | null): void => { + announced.push(`${activation}:${before}:${after}`); + }); + + expect(controller.apply(body(9, true, 25, 'next_session', '{"vip":["u1"]}'), '"e9"')) + .assertEqual(ApplyOutcome.APPLIED); + + 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"'); + // The controller reports what changed; whether a running session is worth + // ending is the scope's call — only it knows the session and the hook. + expect(announced.length).assertEqual(1); + expect(announced[0]).assertEqual('next_session:null:25'); + }); + + it('treatsAnOutOfRangeRateAsAbsent', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(2, true, 140), null); + // Not clamped to 100: a rate we cannot trust is not a rate to sample with, + // so the value the app was initialised with keeps applying. + 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', ignoreChanges); + controller.apply(body(4, true, 10, 'next_session', '{"tier":"vip"}'), null); + controller.apply(body(5, false, 10, 'next_session', '{"tier":"vip"}'), null); + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertNull(); + expect(read.custom).assertNull(); + expect(read.version).assertEqual(5); // still traceable to the change that switched it off + }); + + it('rejectsMalformedEnvelopesWithoutChangingStoredValuesOrRefreshPermission', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const invalid: string[] = [ + '{"schema_version":1,"version":999999}', + '{"version":999999,"enabled":"false"}', + '{"version":999999,"enabled":null}', + '{"enabled":true}', + '{"version":-1,"enabled":true}', + '{"version":6.5,"enabled":true}', + '{"version":1e309,"enabled":true}', + '{"version":9007199254740992,"enabled":true}', + '{"version":999999,"enabled":true,"rum":[]}', + '{"version":999999,"enabled":true,"rum":"invalid"}', + '{"version":999999,"enabled":true,"rum":null}' + ]; + for (const payload of invalid) { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + let announcements: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (_a: string, _b: number | null, _c: number | null): void => { announcements++; }); + controller.apply(body(5, true, 0, 'next_session', '{"vip":["u1"]}'), '"e5"'); + const malformed: string = payload.replace('}', ',"refresh_on_foreground":true,"ttl":1}'); + expect(controller.apply(malformed, '"invalid"')).assertEqual(ApplyOutcome.UNREADABLE); + const stored: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(stored.version).assertEqual(5); + expect(stored.sessionSampleRate).assertEqual(0); + expect(stored.custom).assertEqual('{"vip":["u1"]}'); + expect(stored.etag).assertEqual('"e5"'); + expect(announcements).assertEqual(1); + controller.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(0); + expect(controller.apply(body(6, false, null), '"e6"')).assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.version).assertEqual(6); + expect(store.read()?.sessionSampleRate).assertNull(); + controller.stop(); + } + }); + + it('acceptsVersionZeroBeforeAnyConfigurationWasPublished', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + expect(controller.apply('{"schema_version":1,"version":0,"enabled":false}', '"empty"')) + .assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.sessionSampleRate).assertNull(); + expect(controller.apply(body(1, true, 25), '"e1"')).assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.sessionSampleRate).assertEqual(25); + }); + + it('unreadableBodyIsAFailedAskNotAnEmptyConfiguration', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(3, true, 30), null); + expect(controller.apply('gateway error', null)).assertEqual(ApplyOutcome.UNREADABLE); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(30); + }); + + 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', ignoreChanges); + + // Nothing of a body we cannot vouch for reaches storage, not even the fields that parsed. + expect(controller.apply(body(3, true, 30, 'next_session', '', 99), null)) + .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', ignoreChanges); + + expect(controller.apply(body(3, true, 30, 'next_session', '', null), null)) + .assertEqual(ApplyOutcome.APPLIED); + expect(store.read()?.sessionSampleRate).assertEqual(30); + }); + + it('announcesTheRateThisClientHeldBeforeAndAfter', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const announced: string[] = []; + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + announced.push(`${activation}:${before}:${after}`); + }); + + controller.apply(body(1, true, 20, 'immediate'), null); + controller.apply(body(2, true, 20, 'immediate'), null); + controller.apply(body(3, true, 60, 'next_session'), null); + // `before` is read before the write, so the scope can tell a real move + // from the console re-publishing what this client already holds. + expect(announced[0]).assertEqual('immediate:null:20'); + expect(announced[1]).assertEqual('immediate:20:20'); + expect(announced[2]).assertEqual('next_session:20:60'); + }); + + it('refusesAConfigurationOlderThanTheOneInForce', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + let announcements: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (_a: string, _b: number | null, _c: number | null): void => { announcements++; }); + // In force: foreground refresh off, which is what the operator chose. + controller.apply(body(7, true, 5, 'next_session', '', 1), '"e7"'); + announcements = 0; + + // A lagging replica or a proxy replaying a held body. Rollbacks are + // published as a NEW version, so an older one can only be stale. + expect(controller.apply(`{"schema_version":1,"version":6,"ttl":30,"enabled":true,` + + `"activation":"immediate","refresh_on_foreground":true,"rum":{"sessionSampleRate":50}}`, '"e6"')) + .assertEqual(ApplyOutcome.STALE_VERSION); + + const read: RemoteConfigValues = store.read() as RemoteConfigValues; + expect(read.sessionSampleRate).assertEqual(5); + expect(read.version).assertEqual(7); + expect(read.etag).assertEqual('"e7"'); // the validator still validates what we hold + expect(announcements).assertEqual(0); + // Nothing the refused body carried may take effect, not even the fields + // that are not values: a permission read out of a body we would not + // read would outlive it. Its `refresh_on_foreground: true` must not + // start the foreground fetching the operator turned off. + controller.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(0); + }); + + it('refusesABodyThatLostItsVersionAfterOneWasApplied', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(3, true, 10), null); + + // Version 0 is an older answer; a missing version is not a configuration. + expect(controller.apply(body(0, true, 90), null)).assertEqual(ApplyOutcome.STALE_VERSION); + expect(controller.apply('{"schema_version":1,"enabled":true,"rum":{"sessionSampleRate":90}}', null)) + .assertEqual(ApplyOutcome.UNREADABLE); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(10); + }); + + it('appliesAConfigurationRepublishedUnderTheSameVersion', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(4, true, 10), null); + // Equal is not stale: the same version can resolve to different values + // for this client when its env or app version changed under a rule. + expect(controller.apply(body(4, true, 80), null)).assertEqual(ApplyOutcome.APPLIED); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertEqual(80); + }); + + it('ignoresANegativeRateFromTheServer', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + controller.apply(body(2, true, -1), null); + // A negative rate would sample nothing: collection must not be switched + // off by a malformed body. Read off the stored entry rather than through + // `read()`, which guards the value a second time and would hide a hole + // here. + let serialized: string = ''; + core.settings.forEach((value: string, _key: string): void => { serialized = value; }); + expect(serialized.indexOf('"rate"') >= 0).assertFalse(); + expect((store.read() as RemoteConfigValues).sessionSampleRate).assertNull(); + }); + + it('foregroundRefreshNeedsPermissionAndAge', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + expect(RemoteConfigController.shouldRefreshOnForeground(false, 999999, 60)).assertFalse(); + expect(RemoteConfigController.shouldRefreshOnForeground(true, 1000, 60)).assertFalse(); + 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', ignoreChanges); + 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', ignoreChanges); + 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.6.0'); + }); + }); + + describe('rum-remote-config-fetching', (): void => { + it('retriesTwiceThenWaitsForTheNextTrigger', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.fail('connection reset'); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(AFTER_EVERY_RETRY_MS); + // One ask plus the two the budget allows. A fleet must never turn an + // endpoint incident into a storm. + expect(fetcher.urls.length).assertEqual(3); + await sleep(AFTER_EVERY_RETRY_MS); + expect(fetcher.urls.length).assertEqual(3); + controller.stop(); + }); + + it('aNewSessionAsksAtOnceInsteadOfWaitingOutTheBackoff', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.fail('connection reset'); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(AFTER_EVERY_RETRY_MS); + expect(fetcher.urls.length).assertEqual(3); // budget spent + + // A session starting in the middle of an outage does not inherit an + // exhausted budget: it asks, and is owed the two retries again. + controller.onSessionStarted(); + await sleep(AFTER_EVERY_RETRY_MS); + expect(fetcher.urls.length).assertEqual(6); + controller.stop(); + }); + + it('asksAgainAfterAnUnchangedAnswer', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); + // "Nothing changed" is an answer, not a request still in flight: a 304 + // that left the controller wedged would freeze a whole fleet on the + // first unchanged reply. + controller.onSessionStarted(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(2); + controller.stop(); + }); + + it('dropsAResponseThatLandsAfterTheSdkStopped', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(3, true, 40, 'immediate')); + let announcements: number = 0; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (_a: string, _b: number | null, _c: number | null): void => { announcements++; }, + QUICK_RETRIES); + + controller.start(); + controller.stop(); // the request is already on the wire and cannot be recalled + await sleep(20); + + expect(store.read()).assertNull(); + expect(announcements).assertEqual(0); + }); + + it('restoresForegroundRefreshAndTtlBeforeAnUnchangedStartupResponse', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const first: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + first.apply('{"version":1,"enabled":true,"ttl":1,"refresh_on_foreground":true,"rum":{}}', '"e1"'); + first.stop(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const restarted: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges); + try { + restarted.start(); + await sleep(20); + expect(fetcher.validators[0]).assertEqual('"e1"'); + restarted.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(1); + await sleep(1100); + restarted.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(2); + } finally { + restarted.stop(); + } + }); + + it('keepsForegroundRefreshOffAcrossAnUnchangedStartupResponse', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const first: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', ignoreChanges); + first.apply('{"version":1,"enabled":true,"ttl":1,"refresh_on_foreground":false}', '"e1"'); + first.stop(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const restarted: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges); + try { + restarted.start(); + await sleep(1100); + restarted.refreshIfStale(); + expect(fetcher.urls.length).assertEqual(1); + } finally { + restarted.stop(); + } + }); + + it('fetchesAFullResponseWhenCachedRefreshSettingsAreMissing', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(25, 1, null, '"e1"')); + core.settings.forEach((_value: string, key: string): void => { + core.settings.set(key, JSON.stringify({ rate: 25, version: 1, etag: 'e1' })); + }); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(1, true, 25), '"e1"'); + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', ignoreChanges); + expect(store.read()?.sessionSampleRate).assertEqual(25); + controller.start(); + await sleep(20); + controller.stop(); + expect(fetcher.validators[0]).assertNull(); + expect(store.read()?.sessionSampleRate).assertEqual(25); + }); + + it('followsTheServerTtlWhenDecidingWhatIsStale', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + // ttl 1s, and the console allows the foreground to ask again. + fetcher.answer(200, `{"schema_version":1,"version":1,"ttl":1,"enabled":true,` + + `"activation":"next_session","refresh_on_foreground":true,"rum":{"sessionSampleRate":30}}`); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + + controller.start(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); + + controller.refreshIfStale(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); // still fresh: switching apps is not a reason + + await sleep(1100); + controller.refreshIfStale(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(2); // older than the ttl the server asked for + controller.stop(); + }); + + it('anUnsupportedSchemaLeavesTheRhythmAlone', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const fetcher: FakeFetcher = new FakeFetcher(); + const controller: RemoteConfigController = new RemoteConfigController( + newStore(core), fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + controller.apply(body(2, true, 30), null); // refresh_on_foreground absent: off + + // A body written to a contract we cannot read is refused whole — its + // permissions included. + expect(controller.apply(`{"schema_version":99,"version":3,"ttl":1,"enabled":true,` + + `"activation":"next_session","refresh_on_foreground":true,"rum":{"sessionSampleRate":70}}`, null)) + .assertEqual(ApplyOutcome.UNSUPPORTED_SCHEMA); + + controller.refreshIfStale(); + await sleep(20); + expect(fetcher.urls.length).assertEqual(0); + }); + }); + + describe('rum-remote-config-monitor', (): void => { + function monitorWith(core: FakeCore, scope: CapturingScope, store: RemoteConfigStore, + fetcher: FakeFetcher, rate: number): DefaultRumMonitor { + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', ignoreChanges, QUICK_RETRIES); + return new DefaultRumMonitor( + core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(rate) + .setRemoteConfigurationEnabled(true).build(), + store, controller); + } + + it('everyNewSessionAsksTheConsoleAgain', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, newStore(core), fetcher, 100); + + monitor.startView('home', 'Home'); + await sleep(20); + expect(fetcher.urls.length).assertEqual(1); + + // Without this the console's change reaches nobody who keeps the app + // open: the next ask would wait for a cold start. + monitor.stopSession(); + monitor.startView('next', 'Next'); + await sleep(20); + expect(fetcher.urls.length).assertEqual(2); + + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + + it('stopRemoteConfigEndsTheAsking', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, newStore(core), fetcher, 100); + + monitor.stopRemoteConfig(); + monitor.startView('home', 'Home'); + await sleep(20); + expect(fetcher.urls.length).assertEqual(0); + monitor.stopKeepAlive(); + }); + + it('setForcedSessionCollectsAVisitorTheRateDropped', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 3, null, null)); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, store, fetcher, 100); + + monitor.startView('home', 'Home'); + expect(scope.written.length).assertEqual(0); + + monitor.setForcedSession(); + monitor.startView('home', 'Home'); + expect(scope.written.length > 0).assertTrue(); + + await sleep(20); + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + + it('aPublishedChangeTravelsFromTheResponseToTheRunningSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); // collection is off + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(200, body(2, true, 100)); // and the console turns it on + + // Wired the way `FlashcatRum.enable` wires it: the controller exists + // before the monitor that owns it, so the listener finds the monitor + // when it fires rather than when it is built. + let monitorRef: DefaultRumMonitor | null = null; + const controller: RemoteConfigController = new RemoteConfigController( + store, fetcher, 'https://x/config', + (activation: string, before: number | null, after: number | null): void => { + if (monitorRef !== null) { + monitorRef.onRemoteConfigurationChanged(activation, before, after); + } + }, + QUICK_RETRIES); + const monitor: DefaultRumMonitor = new DefaultRumMonitor( + core, scope, + new RumConfigurationBuilder('app-1').setSessionSampleRate(100) + .setRemoteConfigurationEnabled(true).build(), + store, controller); + monitorRef = monitor; + + // Drawn under the stored 0, so nothing is collected — and starting it + // is what asks the console. + monitor.startView('home', 'Home'); + expect(scope.written.length).assertEqual(0); + await sleep(20); + + // The response travelled response → store → announcement → monitor → + // scope, and ended the session that could not collect anything. + monitor.startView('next', 'Next'); + expect(scope.written.length > 0).assertTrue(); + + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + + it('getRemoteConfigHandsBackTheDecodedBag', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, async (): Promise => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 3, '{"flags":{"beta":true}}', null)); + const fetcher: FakeFetcher = new FakeFetcher(); + fetcher.answer(304, ''); + const monitor: DefaultRumMonitor = monitorWith(core, scope, store, fetcher, 100); + + const bag: Record = monitor.getRemoteConfig() as Record; + const flags: Record = bag['flags'] as Record; + expect(flags['beta']).assertTrue(); // decoded, not handed back as a string + + await sleep(20); + monitor.stopKeepAlive(); + monitor.stopRemoteConfig(); + }); + }); + + describe('rum-remote-config-sampling', (): void => { + it('publishedRateAppliesToTheNextSessionNotTheRunningOne', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + 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]); + + // Storage changing under a running session decides nothing on its own: + // only an announcement through `onConfigurationChanged` can end one, and + // that path is pinned by `theConsoleTurningCollectionOffStopsTheRunningSession`. + store.write(new RemoteConfigValues(0, 5, null, null)); + app.handleEvent(raw('addAction', now + 1000)); + const last: Record = scope.written[scope.written.length - 1]; + 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 store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(3, 7, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); + const now: number = Date.now(); + + app.forceSession(now + 100); + startView(app, now + 200); + expect(scope.written.length > 0).assertTrue(); + + // A forced session was not drawn by the published rate, so it must not be + // reported under it: the console weights a session by 100/rate, and one + // forced visitor at 3% would land in the estimate as thirty-three. + const configuration: Record = + configurationOf(scope.written[0]) as Record; + expect(configuration['session_sample_rate']).assertEqual(100); + expect(configuration['rc_version']).assertUndefined(); + }); + + it('hookCannotRaiseTheRateAboveTheScale', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 2, null, null)); + const app: RumApplicationScope = new RumApplicationScope( + scope, core, 'app-1', 0, store, + (_c: BeforeSamplingContext): number | undefined => 101); + startView(app, Date.now()); + // 101 is not a rate; the incoming 0 stands rather than being clamped to + // something nobody asked for. + expect(scope.written.length).assertEqual(0); + }); + + it('ignoresANegativeRateLeftInStorage', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(-1, 4, null, null)); + const read: RemoteConfigValues | null = store.read(); + // A corrupted entry must not switch collection off: it reads as "nothing + // published", so the init value keeps applying. + expect(read === null || read.sessionSampleRate === null).assertTrue(); + }); + + it('forcingAgainWhileItRunsChangesNothing', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + 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('carriesTheDrawnRateEvenWhenRemoteConfigurationIsOff', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100); + startView(app, Date.now()); + + // The backend weights every session by this rate whether or not the app + // opted into remote configuration; sending nothing is read as a zero, + // which the console files under "SDK too old to report". + const configuration: Record = + configurationOf(scope.written[0]) as Record; + expect(configuration['session_sample_rate']).assertEqual(100); + // No version, because no configuration decided it. + expect(configuration['rc_version']).assertUndefined(); + }); + }); + + describe('rum-remote-config-activation', (): void => { + it('endsARunningSessionOnlyWhenTheChangeDecidesSomething', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + // A session already under way is never re-drawn on a rate that merely + // moved: re-rolling a session that lost a fair draw would bias + // collection upward with every publish. + expect(RumApplicationScope.shouldEndSession(false, 0, 100, 'next_session')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(false, 30, 100, 'next_session')).assertFalse(); + expect(RumApplicationScope.shouldEndSession(false, 0, 0, 'next_session')).assertFalse(); + // Collecting, and the change means "collect nothing": an emergency stop + // has to reach the session that is running. + expect(RumApplicationScope.shouldEndSession(true, 100, 0, 'next_session')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(true, 100, 50, 'next_session')).assertFalse(); + // `immediate` is the console asking for this session to be re-decided, + // but there is still nothing to gain from replacing a dark session with + // another one the new rate cannot collect either. + expect(RumApplicationScope.shouldEndSession(true, 100, 50, 'immediate')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(false, 30, 50, 'immediate')).assertTrue(); + expect(RumApplicationScope.shouldEndSession(false, 30, 0, 'immediate')).assertFalse(); + }); + + it('turningCollectionOnReachesTheSessionThatIsAlreadyDark', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + expect(scope.written.length).assertEqual(0); + const dark: string = app.getCurrentSessionId() as string; + + store.write(new RemoteConfigValues(100, 2, null, null)); + app.onConfigurationChanged('next_session', 0, 100, now + 1000); + // Ended, so the next event draws under the new rate. Without this an + // operator who just switched collection on sees nothing from anyone + // already using the app — for up to four hours. + expect(app.getCurrentSessionId()).assertUndefined(); + + startView(app, now + 2000); + expect(scope.written.length > 0).assertTrue(); + expect(app.getCurrentSessionId() === dark).assertFalse(); + }); + + it('endingAnIdleSessionDoesNotCreditTheIdleGapToItsView', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const idleGapsMs: number[] = [60 * 1000, 3 * 60 * 60 * 1000]; + const expectedTimeSpentNs: number[] = [60 * 1000 * 1000000, 0]; + for (let i: number = 0; i < idleGapsMs.length; i++) { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + + // Collection switched off while the app sat in the background; the + // response lands on the return to foreground, before any event. + store.write(new RemoteConfigValues(0, 2, null, null)); + app.onConfigurationChanged('next_session', 100, 0, now + idleGapsMs[i]); + expect(app.getCurrentSessionId()).assertUndefined(); + + const views: Array> = + scope.written.filter((e: Record) => e['type'] === 'view'); + const closing: Record = views[views.length - 1]['view'] as Record; + expect(closing['is_active'] as boolean).assertFalse(); + // A live session closes when it is told to; one that already expired + // closes when it was last seen alive, not hours later. + expect(closing['time_spent'] as number).assertEqual(expectedTimeSpentNs[i]); + } + }); + + it('activatesCustomOnlyAllowListChangesInBothDirections', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const activations: string[] = ['next_session', 'immediate']; + for (const activation of activations) { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + const hook: BeforeSamplingCallback = (context: BeforeSamplingContext): number | undefined => + context.custom !== null && context.custom['allowed'] === true ? 100 : 0; + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store, hook); + const controller: RemoteConfigController = new RemoteConfigController( + store, new FakeFetcher(), 'https://x/config', + (mode: string, before: number | null, after: number | null): void => { + app.onConfigurationChanged(mode, before, after, Date.now()); + }); + controller.apply(body(1, true, 0, activation, '{"allowed":false}'), null); + startView(app, Date.now()); + expect(scope.written.length).assertEqual(0); + controller.apply(body(2, true, 0, activation, '{"allowed":true}'), null); + expect(app.getCurrentSessionId()).assertUndefined(); + app.handleEvent(raw('addAction', Date.now())); + expect(scope.written.length > 0).assertTrue(); + const kept: string = app.getCurrentSessionId() as string; + controller.apply(body(3, true, 0, activation, '{"allowed":true,"label":"changed"}'), null); + expect(app.getCurrentSessionId()).assertEqual(kept); + controller.apply(body(4, true, 0, activation, '{"allowed":false}'), null); + expect(app.getCurrentSessionId()).assertUndefined(); + const count: number = scope.written.length; + app.handleEvent(raw('addAction', Date.now())); + expect(scope.written.length).assertEqual(count); + controller.stop(); + } + }); + + it('doesNotRedrawWhenTheHookKeepsTheSameEffectiveRate', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const store: RemoteConfigStore = newStore(new FakeCore()); + const app: RumApplicationScope = new RumApplicationScope( + new CapturingScope(), new FakeCore(), 'app-1', 100, store, + (_context: BeforeSamplingContext): number | undefined => 100); + store.write(new RemoteConfigValues(0, 1)); + startView(app, Date.now()); + const kept: string = app.getCurrentSessionId() as string; + store.write(new RemoteConfigValues(20, 2)); + app.onConfigurationChanged('immediate', 0, 20, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(kept); + }); + + it('doesNotRedrawAnUnchangedRateAfterADeferredChange', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const store: RemoteConfigStore = newStore(core); + const app: RumApplicationScope = new RumApplicationScope(new CapturingScope(), core, 'app-1', 100, store); + store.write(new RemoteConfigValues(100, 1)); + startView(app, Date.now()); + const kept: string = app.getCurrentSessionId() as string; + store.write(new RemoteConfigValues(50, 2)); + app.onConfigurationChanged('next_session', 100, 50, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(kept); + store.write(new RemoteConfigValues(50, 3)); + app.onConfigurationChanged('immediate', 50, 50, Date.now()); + expect(app.getCurrentSessionId()).assertEqual(kept); + store.write(new RemoteConfigValues(25, 4)); + app.onConfigurationChanged('immediate', 50, 25, Date.now()); + expect(app.getCurrentSessionId()).assertUndefined(); + }); + + it('republishingTheSameRateCutsNothing', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + const running: string = app.getCurrentSessionId() as string; + + app.onConfigurationChanged('immediate', 100, 100, now + 1000); + expect(app.getCurrentSessionId()).assertEqual(running); + // Falling back to the init value on both sides is the same non-change. + app.onConfigurationChanged('immediate', null, null, now + 2000); + expect(app.getCurrentSessionId()).assertEqual(running); + }); + + it('theKillSwitchIsMeasuredAgainstTheInitValue', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + expect(scope.written.length).assertEqual(0); + + // The console hands the knob back: `after` is null, and what applies + // from now on is the 100 this app was built with. + store.write(new RemoteConfigValues(null, 2, null, null)); + app.onConfigurationChanged('next_session', 0, null, now + 1000); + expect(app.getCurrentSessionId()).assertUndefined(); + }); + + it('aForcedSessionIsNeverEndedByARate', TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store); + const now: number = Date.now(); + app.forceSession(now); + startView(app, now + 100); + const forced: string = app.getCurrentSessionId() as string; + + // Every replacement would be forced too, so ending this one only costs + // the view the visitor is on. + app.onConfigurationChanged('next_session', 0, 50, now + 200); + expect(app.getCurrentSessionId()).assertEqual(forced); + app.onConfigurationChanged('immediate', 50, 0, now + 300); + expect(app.getCurrentSessionId()).assertEqual(forced); + }); + + it('aSessionTheHookKeepsIsNeverEndedByARate', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(0, 1, null, null)); + const allowList: BeforeSamplingCallback = + (_c: BeforeSamplingContext): number | undefined => 100; + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 0, store, allowList); + const now: number = Date.now(); + startView(app, now); + const kept: string = app.getCurrentSessionId() as string; + expect(scope.written.length > 0).assertTrue(); + + // The allow-list already answers the question the rate is asking, in + // both directions: cutting these sessions would split the timeline of + // exactly the visitors someone chose to keep. + store.write(new RemoteConfigValues(5, 2, null, null)); + app.onConfigurationChanged('next_session', 0, 5, now + 1000); + expect(app.getCurrentSessionId()).assertEqual(kept); + + store.write(new RemoteConfigValues(0, 3, null, null)); + app.onConfigurationChanged('next_session', 5, 0, now + 2000); + expect(app.getCurrentSessionId()).assertEqual(kept); + }); + + it('theConsoleTurningCollectionOffStopsTheRunningSession', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const core: FakeCore = new FakeCore(); + const scope: CapturingScope = new CapturingScope(); + const store: RemoteConfigStore = newStore(core); + store.write(new RemoteConfigValues(100, 1, null, null)); + const app: RumApplicationScope = new RumApplicationScope(scope, core, 'app-1', 100, store); + const now: number = Date.now(); + startView(app, now); + expect(app.getCurrentSessionId() !== undefined).assertTrue(); + + store.write(new RemoteConfigValues(0, 2, null, null)); + app.onConfigurationChanged('next_session', 100, 0, now + 1000); + expect(app.getCurrentSessionId()).assertUndefined(); + + const written: number = scope.written.length; + startView(app, now + 2000); + app.handleEvent(raw('addAction', now + 3000)); + expect(scope.written.length).assertEqual(written); // and the replacement collects nothing + }); + }); +} diff --git a/flashcat-rum/src/test/SessionLifecycle.test.ets b/flashcat-rum/src/test/SessionLifecycle.test.ets index 487902c..25835f0 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'; @@ -17,7 +17,7 @@ function testContext(): FlashcatContext { version: '1.0.0', bundleId: 'com.example.demo', source: 'harmony', - sdkVersion: '0.5.1', + sdkVersion: '0.6.0', device: { brand: 'HUAWEI', model: 'Pura', osName: 'HarmonyOS', osVersion: 'NEXT', apiVersion: 18, deviceType: 'phone' @@ -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 { @@ -196,6 +211,23 @@ export default function sessionLifecycleTests(): void { scope.ofType('view')[scope.ofType('view').length - 1]; expect(sessionIdOf(latest) === sessionA).assertFalse(); }); + + it('stopSessionAfterExpiryClosesViewAtLastActivity', + TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => { + const scope: CapturingScope = new CapturingScope(); + const app: RumApplicationScope = new RumApplicationScope(scope, new FakeCore(), 'app-1', 100); + const t0: number = Date.now(); + const start: RumRawEvent = raw('startView', t0, 'home'); + start.name = 'Home'; + app.handleEvent(start); + app.handleEvent(raw('keepAlive', t0 + 5 * 60 * 1000)); + + app.stopCurrentSession(t0 + 3 * MIN_15); + const views: Array> = scope.ofType('view'); + const closing: Record = views[views.length - 1]['view'] as Record; + // Closed at the last keep-alive, the latest moment the session was seen alive. + expect(closing['time_spent'] as number).assertEqual(5 * 60 * 1000 * 1000000); + }); }); describe('RumConfigTrackErrors', (): void => { diff --git a/flashcat-trace/CHANGELOG.md b/flashcat-trace/CHANGELOG.md index 6bbf777..33fe1e5 100644 --- a/flashcat-trace/CHANGELOG.md +++ b/flashcat-trace/CHANGELOG.md @@ -1,5 +1,10 @@ # Changelog +## 0.6.0 + +- Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` + 0.6.0: remote configuration). + ## 0.5.1 - Version bump to keep the SDK packages in lockstep (see `@flashcatcloud/rum` diff --git a/flashcat-trace/oh-package.json5 b/flashcat-trace/oh-package.json5 index 7dd05f8..bbac469 100644 --- a/flashcat-trace/oh-package.json5 +++ b/flashcat-trace/oh-package.json5 @@ -1,12 +1,12 @@ { name: "@flashcatcloud/trace", - version: "0.5.1", + version: "0.6.0", description: "FlashCat HarmonyOS trace-context propagation: W3C traceparent generation + rcp interceptor + manual headers.", main: "Index.ets", license: "Apache-2.0", author: "FlashCat (https://flashcat.cloud)", repository: "https://github.com/flashcatcloud/fc-sdk-harmony", dependencies: { - "@flashcatcloud/core": "0.5.1" + "@flashcatcloud/core": "0.6.0" } }