Skip to content
Merged
57 changes: 51 additions & 6 deletions entry/src/main/ets/common/DemoSdk.ets
Original file line number Diff line number Diff line change
@@ -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';
Expand Down Expand Up @@ -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;
}
Expand Down Expand Up @@ -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) {
Expand All @@ -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<string, Object> | 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<string, Object> | null, id: string): boolean {
if (custom === null) {
return false;
}
const vip: Object | undefined = custom['vip'];
if (!Array.isArray(vip)) {
return false;
}
const entries: Array<Object> = vip as Array<Object>;
return entries.includes(id);
}

static monitor(): RumMonitor {
return GlobalRumMonitor.get();
}
Expand Down
4 changes: 4 additions & 0 deletions entry/src/main/ets/entryability/EntryAbility.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
46 changes: 44 additions & 2 deletions entry/src/main/ets/pages/Index.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -105,7 +106,10 @@ struct Index {
private initSdkE2e(trackErrors: boolean): void {
try {
const pacing: string = AppStorage.get<string>('netbenchPacing') ?? 'demo'; // SDK init only
DemoSdk.init(this.context, this.useProd, this.customEndpoint, false, trackErrors, pacing);
const rcMode: string = AppStorage.get<string>('rcMode') ?? '';
const rcInitRate: number = Number.parseInt(AppStorage.get<string>('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;
Expand Down Expand Up @@ -182,6 +186,8 @@ struct Index {
const endpoint: string = AppStorage.get<string>('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(() => {
Expand All @@ -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<string, Object> | 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<string, Object> | 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;
Expand Down
2 changes: 1 addition & 1 deletion flashcat-core/Index.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
30 changes: 30 additions & 0 deletions flashcat-core/src/main/ets/api/SdkCore.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down Expand Up @@ -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({})). */
Expand Down
52 changes: 50 additions & 2 deletions flashcat-core/src/main/ets/internal/FlashcatCore.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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
Expand Down Expand Up @@ -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<string, Object>);
},
onApplicationBackground: () => {
this.flushAll();
}
Expand Down Expand Up @@ -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
Expand Down
3 changes: 2 additions & 1 deletion flashcat-rum/Index.ets
Original file line number Diff line number Diff line change
Expand Up @@ -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';
51 changes: 49 additions & 2 deletions flashcat-rum/src/main/ets/FlashcatRum.ets
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
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';
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`.
Expand Down Expand Up @@ -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
Expand Down
Loading
Loading