Skip to content
Merged
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
18 changes: 16 additions & 2 deletions entry/src/main/ets/common/DemoSdk.ets
Original file line number Diff line number Diff line change
Expand Up @@ -52,10 +52,12 @@ export class DemoSdk {
* @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.
* @param sessionOnError keep the sessions the rate misses when they error.
*/
static init(context: common.Context, prod: boolean, customEndpoint: string,
recover: boolean = false, trackErrors: boolean = true, pacing: string = 'demo',
remoteConfig: string = '', initSampleRate: number = 100): void {
remoteConfig: string = '', initSampleRate: number = 100, sessionOnError: boolean = false,
jsCrashPolicy: string = ''): void {
if (DemoSdk.initialized) {
return;
}
Expand Down Expand Up @@ -93,6 +95,7 @@ export class DemoSdk {
.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)
.setSessionOnError(sessionOnError)
.setEventMapper(DemoSdk.demoEventMapper); // phase 2 R3: PII scrubbing / drop
if (remoteConfig.length > 0) {
rumBuilder.setRemoteConfigurationEnabled(true);
Expand All @@ -105,6 +108,8 @@ export class DemoSdk {
const crashBuilder: CrashConfigurationBuilder = new CrashConfigurationBuilder();
if (recover) {
crashBuilder.setJsCrashPolicy(JsCrashPolicy.REPORT_AND_RECOVER);
} else if (jsCrashPolicy === 'observe_only') {
crashBuilder.setJsCrashPolicy(JsCrashPolicy.OBSERVE_ONLY);
}
FlashcatCrash.enable(crashBuilder.build());

Expand Down Expand Up @@ -149,7 +154,8 @@ export class DemoSdk {
/**
* Phase 2 R3 demo mapper. Two redactions you can verify in HiLog / NDJSON:
* - resource events: strip the query string from `resource.url` (tokens, PII);
* - action events whose target name contains "secret": dropped entirely (null).
* - action events whose target name contains "secret": dropped entirely (null);
* - error events whose message contains "secret": dropped entirely (null).
* Everything else passes through unchanged. Must never throw.
*/
static demoEventMapper(event: Record<string, Object>): Record<string, Object> | null {
Expand Down Expand Up @@ -179,6 +185,14 @@ export class DemoSdk {
}
}
}
} else if (type === 'error') {
const error: Object | undefined = event['error'];
if (typeof error === 'object') {
const message: Object | undefined = (error as Record<string, Object>)['message'];
if (typeof message === 'string' && message.indexOf('secret') >= 0) {
return null; // drop secret-marked errors
}
}
}
return event;
}
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 @@ -119,6 +119,10 @@ export default class EntryAbility extends UIAbility {
// 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');
// '1' keeps the sessions the rate misses when they error (sessionOnError).
EntryAbility.forwardParam(params, 'soe', 'soe');
// 'observe_only' runs the crash feature's OBSERVE_ONLY policy.
EntryAbility.forwardParam(params, 'crash_policy', 'jsCrashPolicy');
hilog.info(DOMAIN, TAG, 'e2e params from %{public}s: endpoint=%{public}s scenario=%{public}s',
from, e2eEndpoint, scenario);
} else {
Expand Down
56 changes: 55 additions & 1 deletion entry/src/main/ets/pages/Index.ets
Original file line number Diff line number Diff line change
Expand Up @@ -108,8 +108,10 @@ struct Index {
const pacing: string = AppStorage.get<string>('netbenchPacing') ?? 'demo'; // SDK init only
const rcMode: string = AppStorage.get<string>('rcMode') ?? '';
const rcInitRate: number = Number.parseInt(AppStorage.get<string>('rcInitRate') ?? '100', 10);
const sessionOnError: boolean = (AppStorage.get<string>('soe') ?? '') === '1';
const jsCrashPolicy: string = AppStorage.get<string>('jsCrashPolicy') ?? '';
DemoSdk.init(this.context, this.useProd, this.customEndpoint, false, trackErrors, pacing,
rcMode, rcInitRate);
rcMode, rcInitRate, sessionOnError, jsCrashPolicy);
} catch (e) {
this.append(e instanceof Error ? e.message : 'SDK initialization failed');
return;
Expand Down Expand Up @@ -188,11 +190,25 @@ struct Index {
this.runNetBench(`${endpoint}/e2e/echo`);
} else if (name === 'remoteconfig') {
this.runRemoteConfigScenario();
} else if (name.startsWith('soe')) {
this.runSessionOnErrorScenario(name);
} else if (name === 'native-crash') {
this.append('e2e: native crash in 800ms');
setTimeout(() => {
testNapi.triggerNativeCrash();
}, 800);
} else if (name === 'crash') {
this.append('e2e: crashing in 800ms');
setTimeout(() => {
throw new Error('E2E crash marker');
}, 800);
} else if (name === 'crash-secret') {
// A crash the demo mapper drops ("secret" in the message): it must not
// count, release, or be reported on the next launch.
this.append('e2e: secret crash in 800ms');
setTimeout(() => {
throw new Error('E2E secret crash');
}, 800);
}
}

Expand Down Expand Up @@ -232,6 +248,44 @@ struct Index {
}, 3000);
}

/**
* Error-session capture runs (launch with `--ps soe 1`). Every step is logged
* under FCSOE so a run can be lined up with what the intake received:
* - soe: activity, then an error 2 s later — the error releases the held minute;
* - soe-quiet: activity, then the session ends without an error;
* - soe-secret: activity, then an error the event mapper drops;
* - soe-quiet-native: the session ends without an error, then a native crash.
*/
private runSessionOnErrorScenario(name: string): void {
const monitor: RumMonitor = DemoSdk.monitor();
monitor.addAction(RumActionType.TAP, 'soe-tap');
monitor.getCurrentSessionId((sessionId: string | undefined): void => {
hilog.info(0x0000, 'FCSOE', '%{public}s session=%{public}s', name, sessionId ?? 'none');
});
setTimeout(() => {
if (name === 'soe') {
monitor.addError('soe manual error', RumErrorSource.CUSTOM, 'at Index.soe');
hilog.info(0x0000, 'FCSOE', 'error at %{public}d', Date.now());
} else if (name === 'soe-secret') {
monitor.addError('soe secret error', RumErrorSource.CUSTOM, 'at Index.soe');
monitor.stopSession();
hilog.info(0x0000, 'FCSOE', 'dropped error, session stopped at %{public}d', Date.now());
} else if (name === 'soe-quiet-native') {
// The session is thrown away, then the process dies of a native crash
// before any view is written: the next launch must not rebuild it.
monitor.stopSession();
hilog.info(0x0000, 'FCSOE', 'session stopped at %{public}d; native crash in 1 s', Date.now());
setTimeout(() => {
testNapi.triggerNativeCrash();
}, 1000);
} else {
monitor.stopSession();
hilog.info(0x0000, 'FCSOE', 'session stopped at %{public}d', Date.now());
}
this.append(`e2e: ${name} done`);
}, 2000);
}

aboutToDisappear(): void {
if (!this.faultWatcherRegistered) {
return;
Expand Down
3 changes: 3 additions & 0 deletions flashcat-core/src/main/ets/internal/FlashcatCore.ets
Original file line number Diff line number Diff line change
Expand Up @@ -242,6 +242,9 @@ export class FlashcatCore implements SdkCore {
// included. Revocation has to take it too.
this.bus.send(CRASH_STORAGE_TARGET,
{ 'type': 'crash_storage_delete_last_view' } as Record<string, Object>);
// RUM may still hold events in memory that have not reached a batch;
// those are the refused user's data as much as the batches are.
this.bus.send(RUM_FEATURE_NAME, { 'type': 'consent_revoked' } as Record<string, Object>);
this.deferredUploadRegistrar.deregister(this.deferredWorkOptions());
} else {
// → PENDING: stop uploading; new events buffer separately until a decision.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,11 @@ export class CrashPendingStore implements FeatureEventReceiver {
CrashPendingStore.stringValue(event['incident_id']));
const serialized: string = CrashPendingStore.stringValue(event['serialized']);
event['success'] = this.writeIncident(incidentId, serialized);
if (event['success'] === true) {
// The name is this store's to choose; the writer gets it back so it can
// delete exactly what it wrote.
event['file_name'] = `${INCIDENT_PREFIX}${incidentId}${INCIDENT_SUFFIX}`;
}
} else if (type === 'crash_storage_list') {
event['files'] = this.readIncidents();
} else if (type === 'crash_storage_delete') {
Expand Down
20 changes: 20 additions & 0 deletions flashcat-core/src/test/ConsentPolicy.test.ets
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { common } from '@kit.AbilityKit';
import { ConfigurationBuilder } from '../main/ets/config/Configuration';
import { TrackingConsent } from '../main/ets/privacy/TrackingConsent';
import { FlashcatCore } from '../main/ets/internal/FlashcatCore';
import { FeatureEventReceiver, RUM_FEATURE_NAME } from '../main/ets/api/feature/Feature';

// NOTE (headless runner): Preferences are process-wide per name, so two
// FlashcatCore constructions in one test behave like two app launches sharing
Expand Down Expand Up @@ -91,6 +92,25 @@ export default function consentPolicyTests(): void {
main2.stop();
});

it('revocationIsAnnouncedToRum',
TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => {
// RUM may hold events in memory that never reached a batch; the wipe
// of the batches has to reach those too.
const core: FlashcatCore = build(TrackingConsent.GRANTED);
const received: string[] = [];
const receiver: FeatureEventReceiver = {
onReceive: (event: Record<string, Object>): void => {
received.push(event['type'] as string);
}
};
core.setEventReceiver(RUM_FEATURE_NAME, receiver);
core.setTrackingConsent(TrackingConsent.PENDING);
expect(received.indexOf('consent_revoked')).assertEqual(-1); // undecided is not refused
core.setTrackingConsent(TrackingConsent.NOT_GRANTED);
expect(received.indexOf('consent_revoked') !== -1).assertTrue();
core.stop();
});

it('stopDeactivatesTheCore',
TestType.FUNCTION | Size.SMALLTEST | Level.LEVEL0, (): void => {
const core: FlashcatCore = build(TrackingConsent.GRANTED);
Expand Down
48 changes: 47 additions & 1 deletion flashcat-crash/src/main/ets/internal/CrashFeature.ets
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,8 @@ export class CrashFeature implements Feature, FeatureEventReceiver {
// Newest incident persisted with REPORT_AND_RECOVER this run, so a failed
// restart can downgrade it before exit (crash.recovered must not lie).
private lastRecoveryIncident: CrashIncidentRecord | null = null;
// The file the last pending write landed in, as the storage named it.
private lastPersistedFileName: string = '';
// Incidents whose replay delivery FAILED this launch (retained on disk).
// The in-process watcher must not report their crashes a second time — the
// retained incident is the canonical carrier and replays next launch.
Expand Down Expand Up @@ -263,6 +265,14 @@ export class CrashFeature implements Feature, FeatureEventReceiver {
}
}
}
if (written && this.droppedByRumMapper(this.lastRecoveryIncident ?? incident, fingerprint, nowMs)) {
this.lastRecoveryIncident = null;
event['suppressed'] = true;
event['pending_written'] = false;
event['report_async'] = false;
event['should_exit'] = true;
return;
}
event['pending_written'] = written;
// Termination is policy-driven, not write-driven: a rare pending-write
// failure must not silently revert to keep-alive semantics. The report
Expand Down Expand Up @@ -322,7 +332,43 @@ export class CrashFeature implements Feature, FeatureEventReceiver {
'success': false
};
this.sendStorageCommand(command);
return command['success'] === true;
if (command['success'] === true) {
this.lastPersistedFileName = CrashFeature.stringValue(command['file_name']);
return true;
}
return false;
}

/**
* RUM's event mapper has the last word on whether a persisted crash is an
* error at all, and in a session that withholds its events that word has to
* be spoken in this process: the crash counts on the view and releases what
* the session held here, while the error itself is only assembled on the next
* launch. RUM is asked with the very event the replay would deliver, built
* from the persisted record, so the mapper judges one event, not two. A crash
* it drops is removed again, remembered as consumed so the watcher's copy does
* not come back, and never reported.
*/
private droppedByRumMapper(incident: CrashIncidentRecord, fingerprint: string, nowMs: number): boolean {
const rum: FeatureScope | null = this.core.getFeature(RUM_FEATURE_NAME);
if (rum === null) {
return false;
}
const probe: Record<string, Object> = CrashEventMapper.fromPendingIncident(incident);
probe['type'] = 'crash_mapper_probe';
probe['target'] = RUM_FEATURE_NAME;
rum.sendEvent(probe);
if (probe['kept'] !== false) {
return false;
}
if (this.lastPersistedFileName.length > 0) {
this.deletePending(this.lastPersistedFileName);
}
this.consumed.push(new ConsumedCrashIncident(incident.incidentId, fingerprint, nowMs, nowMs));
this.consumed = CrashDeduplication.retainRecent(this.consumed, nowMs, CONSUMED_RETENTION_MS);
this.persistConsumed();
FlashcatLog.d(`crash.js: event mapper dropped crash incident=${incident.incidentId}; not persisted, not reported`);
return true;
}

private enableRecovery(): void {
Expand Down
28 changes: 28 additions & 0 deletions flashcat-rum/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,33 @@
# Changelog

## Unreleased

- **Error-session capture.** `RumConfigurationBuilder.setSessionOnError(true)`
also keeps the sessions `sessionSampleRate` does not draw, but only those
that report an error. Such a session is collected in memory and none of its
events is uploaded until it reports an error the event mapper keeps; then
the minute leading up to it (its views, and up to 200 actions, resources and
errors within 64 KiB) is uploaded after a short per-session delay, and the
rest of the session is collected normally. A session that ends without an
error is thrown away with everything it held. Off by default; the console's
`sessionOnError` overrides the init value when remote configuration is on.
Its view events report `session.sampled_for_error: true` and a session
sample rate of 0, so each stands for itself.
- `RumMonitor.getCurrentSessionId()` reports `undefined` for such a session
until it has been released: before that the backend has no session under
its id.
- A rate-0 configuration with `sessionOnError` on no longer ends the session
it kept when the configuration is fetched; turning the switch on redraws a
session drawn dark at rate 0.
- Revoking tracking consent now also discards what RUM holds in memory for a
session that has not reached storage.
- Known limitations of error-session capture: a crash replayed on a later
launch that nothing attributes to a session is not reported while the
current session is withheld; and the event mapper must be a pure function
of the event — an uncaught exception that terminates the process is mapped
once at crash time and again when it is replayed on the next launch, and
the two verdicts must agree.

## 0.6.0

- **Remote configuration.** `RumConfigurationBuilder.setRemoteConfigurationEnabled(true)`
Expand Down
42 changes: 40 additions & 2 deletions flashcat-rum/src/main/ets/RumConfiguration.ets
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ export class RumConfiguration {
/** Whether to read the sampling settings the console publishes. Default false. */
readonly remoteConfigurationEnabled: boolean;
readonly beforeSampling: BeforeSamplingCallback | null;
/** Keep the sessions `sessionSampleRate` misses when they report an error. Default false. */
readonly sessionOnError: boolean;

constructor(
applicationId: string,
Expand All @@ -27,7 +29,8 @@ export class RumConfiguration {
eventMapper: RumEventMapper | null = null,
trackErrors: boolean = true,
remoteConfigurationEnabled: boolean = false,
beforeSampling: BeforeSamplingCallback | null = null
beforeSampling: BeforeSamplingCallback | null = null,
sessionOnError: boolean = false
) {
this.applicationId = applicationId;
this.sessionSampleRate = sessionSampleRate;
Expand All @@ -39,6 +42,7 @@ export class RumConfiguration {
this.eventMapper = eventMapper;
this.remoteConfigurationEnabled = remoteConfigurationEnabled;
this.beforeSampling = beforeSampling;
this.sessionOnError = sessionOnError;
}
}

Expand All @@ -56,6 +60,7 @@ export class RumConfigurationBuilder {
private eventMapper: RumEventMapper | null = null;
private remoteConfigurationEnabled: boolean = false;
private beforeSampling: BeforeSamplingCallback | null = null;
private sessionOnError: boolean = false;

constructor(applicationId: string) {
this.applicationId = applicationId;
Expand Down Expand Up @@ -148,6 +153,38 @@ export class RumConfigurationBuilder {
return this;
}

/**
* Also keep the sessions `sessionSampleRate` does not sample, but only those
* that report an error. Default false.
*
* Such a session is collected in memory and none of its events is uploaded until it
* reports an error of its own — one that survives the event mapper. Then the
* minute leading up to that error (its views, and up to 200 actions, resources
* and errors within 64 KiB, the least informative dropped first when there are
* more) is uploaded, after a delay of up to 3 s that spreads out the uploads a
* fleet-wide failure would otherwise send all at once, and the rest of the
* session is collected normally. A session that ends without an error is
* thrown away with everything it held, and nothing of it — not even a crash
* replayed on the next launch — reaches the backend afterwards. An uncaught
* ArkTS exception the event mapper keeps releases what was held at once,
* before the process exits, with or without the crash feature; one the
* mapper drops releases nothing and is not reported. A native crash or a
* freeze, reported on the next launch, is delivered with the last view of
* the session only — what was held in memory died with the process.
*
* Such sessions report a session sample rate of 0 and
* `session.sampled_for_error: true`: each one stands for itself rather than
* for `100 / rate` sessions. A rate the `beforeSampling` hook sets to 0 turns
* this off for that session — 0 means never collect.
*
* With remote configuration enabled, the console's `sessionOnError` overrides
* this value for the sessions drawn after it arrives.
*/
setSessionOnError(enabled: boolean): RumConfigurationBuilder {
this.sessionOnError = enabled;
return this;
}

build(): RumConfiguration {
return new RumConfiguration(
this.applicationId,
Expand All @@ -159,7 +196,8 @@ export class RumConfigurationBuilder {
this.eventMapper,
this.trackErrors,
this.remoteConfigurationEnabled,
this.beforeSampling
this.beforeSampling,
this.sessionOnError
);
}
}
Loading
Loading