Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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';
46 changes: 44 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,53 @@ export class FlashcatRum {
// monitor: attachMonitor synchronously triggers the pending-crash replay,
// and a replayed crash error must pass through the mapper like any event.
RumEventMapperHolder.configure(configuration.eventMapper);
const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration);

// Remote configuration is off unless the app asked for it, and everything
// about it is best-effort: whatever fails here, RUM still collects with the
// values this configuration was built with.
let store: RemoteConfigStore | null = null;
let controller: RemoteConfigController | null = null;
if (configuration.remoteConfigurationEnabled) {
try {
const intake: IntakeTarget = core.getIntakeTarget();
const context: FlashcatContext = core.getContext();
store = new RemoteConfigStore(
core, RemoteConfigStore.buildStoreKey(context, intake.host, configuration.applicationId));
controller = new RemoteConfigController(
store,
new HttpRemoteConfigFetcher(`flashcat-sdk-harmony/${context.sdkVersion}`),
buildConfigUrl(intake.host, intake.clientToken, context),
configuration.sessionSampleRate,
// Looked up when it fires rather than captured now: the monitor is
// registered after this is built, and by the time a response comes
// back it is there.
(): void => GlobalRumMonitor.get().stopSession()
);
} catch (e) {
// Whatever went wrong here, the app keeps collecting with the values it
// was initialised with — never a reason to fail enable().
store = null;
controller = null;
FlashcatLog.e(`rum.remoteconfig: not started (${e instanceof Error ? e.message : 'error'}); init values apply`);
}
}

const monitor: DefaultRumMonitor = new DefaultRumMonitor(core, scope, configuration, store, controller);
feature.attachMonitor(monitor);
GlobalRumMonitor.register(monitor);

// Phase 2: make the auto-instrumentation toggles consultable by the tap /
// navigation trackers. Inert unless the corresponding flag is enabled.
RumAutoInstrumentation.configure(configuration);

// Started only after the monitor is registered: the response may ask for the
// session to be restarted, and that goes through the global monitor.
if (controller !== null) {
controller.start();
}
}


/**
* Auto-record a TAP action on `target`. No-op unless RUM is enabled with
* `setTrackUserInteractions(true)`. Wrap a component's `onClick` (or one shared
Expand Down
Loading
Loading