HarmonyOS

This documentation is for the Countly HarmonyOS SDK version 26.1.X. The SDK is written in ArkTS and targets HarmonyOS NEXT using the Stage model.

The Countly HarmonyOS SDK requires HarmonyOS NEXT 5.0 or later (API level 12+), the Stage model, and ArkTS strict mode. The SDK is shipped as a standard HarmonyOS library module and does not depend on any platform-specific UI framework beyond what the Stage model provides.

To examine the example integrations please have a look here.

Adding the SDK to the Project

The SDK is distributed as a prebuilt .har attached to GitHub Release.

Download countly-sdk-hos-26.1.0.har from the published release. Optionally verify the artifact against the published .sha256:

shasum -a 256 -c countly-sdk-hos-26.1.0.har.sha256

Drop the file into your application module, for example entry/libs/countly-sdk-hos-26.1.0.har. And, reference it from your module-level oh-package.json5:

{
  "dependencies": {
    "countly-sdk-hos": "file:./libs/countly-sdk-hos-26.1.0.har"
  }
}

If you prefer to vendor the SDK directly, clone the repository into your project's libs folder and reference it as a local path:

{
  "dependencies": {
    "countly-sdk-hos": "file:../libs/countly-sdk-hos"
  }
}

SDK Integration

Before you can use any functionality, you have to initialize the SDK. The recommended place to do this is in an AbilityStage. Initialising from UIAbility.onCreate also works (covered below) but has a cold-boot timing caveat.

Recommended: AbilityStage Initialization

AbilityStage.onCreate runs once per HAP module process, before any UIAbility is created. Initialising the SDK there means the internal applicationStateChange and abilityLifecycle subscriptions are registered ahead of every platform foreground transition: the very first session and auto-view on cold boot are captured cleanly.

Create entry/src/main/ets/myabilitystage/MyAbilityStage.ets:

import AbilityStage from '@ohos.app.ability.AbilityStage';
import { Countly, CountlyConfig } from 'countly-sdk-hos';

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    const config = new CountlyConfig(
      this.context,          // common.Context (AbilityStageContext here)
      'https://YOUR_SERVER', // your Countly server URL
      'YOUR_APP_KEY'         // your app key from the dashboard
    );
    // onCreate is a synchronous void hook, fire-and-forget the async init.
    Countly.initShared(config).catch((err) => console.error(`Countly init failed: ${err}`));
  }
}

Wire the AbilityStage at the module level in entry/src/main/module.json5 (this is what tells the platform to load MyAbilityStage instead of the default no-op stage):

{
  "module": {
    "srcEntry": "./ets/myabilitystage/MyAbilityStage.ets"
  }
}

With this in place, your EntryAbility carries no SDK code: no Countly.initShared call, no onForeground or onBackground overrides.

AbilityStageContext vs UIAbilityContext. The context passed into CountlyConfig from an AbilityStage is an AbilityStageContext. Sessions, views, events, crashes, user profiles, and storage all work fine off it. The external-URL-opener path used by Content and Feedback overlays needs startAbility which is only on UIAbilityContext. If you re-enable those modules, call setExternalUrlOpener(...) from a UIAbility's onForeground.

Alternative: UIAbility Initialization

If you would rather not add an AbilityStage, you can initialize the SDK from EntryAbility.onCreate instead. The SDK still works end-to-end, but be aware that the platform's first foreground transition may fire before the long async init() chain (storage + device-id resolve + module inits + Server Behavior Settings fetch) resolves. In that race the very first session and auto-view of a cold launch may be missed; subsequent foreground/background cycles are unaffected.

import UIAbility from '@ohos.app.ability.UIAbility';
import { Countly, CountlyConfig } from 'countly-sdk-hos';

export default class EntryAbility extends UIAbility {
  async onCreate(want, launchParam): Promise<void> {
    const config = new CountlyConfig(this.context, 'https://YOUR_SERVER', 'YOUR_APP_KEY');
    await Countly.initShared(config);
  }
}

The call is asynchronous because HarmonyOS @ohos.data.preferences initialization is asynchronous. Make sure you await it before calling any other SDK method.

The CountlyConfig builder exposes fluent setters for every init-time option. To configure the SDK, chain them on the config object and pass the result to initShared.

If you are in doubt about the correctness of your Countly SDK integration, enable logging and inspect the hilog output to confirm each request is recorded.

Lifecycle Callbacks

The SDK auto-subscribes to HarmonyOS's applicationStateChange and abilityLifecycle events during initShared / createInstance, so foreground / background transitions drive sessions, views, and the event queue without any UIAbility wiring on your side. With AbilityStage initialization (recommended, see above), the subscriptions are in place before the first UIAbility is created: every transition, including the cold-boot one, reaches the SDK.

import AbilityStage from '@ohos.app.ability.AbilityStage';
import { Countly, CountlyConfig } from 'countly-sdk-hos';

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    const config = new CountlyConfig(this.context, URL, APP_KEY);
    Countly.initShared(config).catch((err) => console.error(`Countly init failed: ${err}`));
    // No onForeground / onBackground overrides needed in any UIAbility.
  }
}

Each CountlyInstance owns its own subscription, so multi-instance setups (one shared + one or more named) all receive the same fg/bg events independently. Halt deregisters that instance's observer cleanly.

Manual override. Tests and non-standard ability hosts (services, app extensions) can drive lifecycle directly: Countly.lifecycleForegroundAll() / Countly.lifecycleBackgroundAll() still exist and are the same code path the auto-subscription uses internally.

If multiple UIAbilities run concurrently (multi-window), the SDK uses an internal foreground counter so a begin_session is only sent on the 0 → 1 transition and an end_session only on 1 → 0. Rapid rotations and configuration changes do not trigger phantom sessions.

Required App Permissions

The SDK needs access to the network. Add the following permission to your module.json5:

{
  "module": {
    "requestPermissions": [
      { "name": "ohos.permission.INTERNET" },
      { "name": "ohos.permission.GET_NETWORK_INFO" }
    ]
  }
}

SDK Logging

The first thing you should do while integrating the SDK is enable logging. If logging is enabled, the SDK prints debug messages about its internal state and encountered problems. Messages are routed through the standard console API which in turn reaches hilog.

import { Countly, CountlyConfig, LogLevel } from 'countly-sdk-hos';

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.logging
  .enableLogging()
  .setMinLevel(LogLevel.DEBUG);

await Countly.initShared(config);

For production builds where writing logs to the system console is not desirable, you can attach a log listener. The listener receives each log message together with its level and can forward it to your own logging pipeline.

config.logging.setListener((message: string, level: LogLevel) => {
  myInternalLogger.log(level, message);
});

Log Line Shape

Every SDK log line follows the form <brand> <module-tag> <message>:

  • Brand prefix identifies which SDK instance produced the line:
    • [Countly] for the shared instance created via Countly.initShared(config).
    • [Countly:<name>] for a named instance created via Countly.createInstance('<name>', config). The <name> is the value passed to createInstance; it lets you tell concurrent instances apart in a single hilog stream.
  • Module tag identifies which internal subsystem emitted the line, e.g. [Network], [RequestQueue], [ModuleEvents], [ModuleSessions], [ModuleViews], [ModuleCrashes], [ModuleConsent], [ModuleConfiguration], [ModuleHealthCheck], [ModuleRemoteConfig], [Storage], and the public facade traces [Events] / [Views] / [Crashes].
I  [Countly] [ModuleEvents] recordEvent, queued key='login' count=1 sum=0 dur=0 segmentation={...} queueSize=1
I  [Countly:analytics] [Network] REQUEST SENDING endpoint=/i usePost=true bytes=330 data=...
W  [Countly:analytics] [ModuleSessions] onEnterBackground, unbalanced lifecycle (counter went negative), clamping to 0

Log Levels and Routing

The SDK exposes six logical levels. Each maps to a console.* call, which HarmonyOS routes through hilog with the matching severity column — so the message body does NOT carry an [Info] / [Debug] text tag (hilog already displays it).

LogLevel console method hilog severity
VERBOSE console.debug D
DEBUG console.debug D
INFO console.info I
WARNING console.warn W
ERROR console.error E
OFF (no console call)

The default minLevel is DEBUG. VERBOSE is intended for SDK-internal triage; it intentionally floods the queue + request lifecycle.

The listener registered via config.logging.setListener(...) receives the fully formatted message (brand prefix included) and the numeric level, so a listener that forwards to a remote logger can preserve both the originating instance and the severity. Listener exceptions are caught and surfaced once via console.error, after which subsequent listener failures are suppressed to avoid recursive crashes.

Crash Reporting

The SDK can automatically capture uncaught exceptions via @ohos.app.ability.errorManager. It can also record handled exceptions that the developer explicitly reports.

Setup

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.crashes
  .enableCrashReporting()
  .setCustomCrashSegmentation({ 'build': 'debug' })
  .setGlobalCrashFilterCallback((crash) => {
    // Redact before sending; return true to drop the report.
    crash.stackTrace = crash.stackTrace.replace(/token=[^\s]+/g, 'token=REDACTED');
    return false;
  })
  .enableRecordAllThreadsWithCrash();

await Countly.initShared(config);

Reporting Handled and Unhandled Exceptions

try {
  doRiskyWork();
} catch (err) {
  await Countly.sharedInstance().crashes.recordHandledException(err as Error, {
    'area': 'checkout'
  });
}

// For explicit fatal cases:
await Countly.sharedInstance().crashes.recordUnhandledException(err as Error, null);

Worker Thread Stacks

When enableRecordAllThreadsWithCrash() is set, the SDK includes the main thread stack by default. To enrich reports with stacks from your TaskPool / Worker threads, register a provider that returns the latest known stack for each worker (typically maintained over postMessage):

Countly.sharedInstance().crashes.setThreadStackProvider(() => [
  { name: 'worker-1', stack: latestStackFromWorker1 },
  { name: 'worker-2', stack: latestStackFromWorker2 }
]);

Breadcrumbs

Breadcrumbs are short free-form log lines you add as you navigate the app. When a crash is recorded, the most recent breadcrumbs are included as the _logs field so you can see the path that led to the crash.

Countly.sharedInstance().crashes.addCrashBreadcrumb('entered-checkout');
Countly.sharedInstance().crashes.addCrashBreadcrumb('tapped-pay');

Events

Events capture actions or interactions. The SDK batches them until the event queue threshold is reached, then combines them into a single request.

Recording Events

const events = Countly.sharedInstance().events;

// simplest form
await events.recordEvent('login');

// with segmentation
await events.recordEvent('level_completed', {
  level: 2,
  score: 500
});

// with count, sum, duration
await events.recordEvent('purchase', { screen: 'main' }, 1, 2.99, 30);

// record an event that happened in the past, pass a Unix epoch in ms
await events.recordPastEvent('signup', { source: 'import' }, 1, 0, 0,
  Date.now() - 60_000);

Timed Events

Start a timer, do work, then end it: the SDK calculates the duration and records it automatically.

const events = Countly.sharedInstance().events;
events.startEvent('checkout');
// ... user flows through checkout ...
await events.endEvent('checkout', { status: 'completed' }, 1, 0);

// or discard:
events.cancelEvent('checkout');

Sessions

By default, sessions are managed automatically. If your app needs fine- grained control (for example to express logical "logged-in" sessions rather than "app in foreground" sessions), enable manual session control.

Automatic Sessions

No wiring required. The SDK subscribes to applicationStateChange during init and takes care of begin_session, periodic session_duration updates (every 60s by default), and end_session. To drive sessions manually instead, use config.sessions.enableManualControl() below.

Manual Sessions

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.sessions.enableManualControl();
await Countly.initShared(config);

// later
await Countly.sharedInstance().sessions.beginSession();
await Countly.sharedInstance().sessions.updateSession();
await Countly.sharedInstance().sessions.endSession();

Hybrid Manual Sessions

In hybrid mode the developer manually calls beginSession/endSession, but the SDK still sends the periodic session_duration heartbeat on its own. Manual updateSession calls are ignored.

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.sessions.enableManualHybridMode();

View Tracking

Views are tracked as internal events with the key [CLY]_view. Two flavors are supported: auto-stopped views (only one at a time, replaced by the next) and manual views (any number, explicitly stopped).

Auto-Stopped Views

const views = Countly.sharedInstance().views;
await views.startAutoStoppedView('HomeScreen');
// ... user navigates away ...
await views.startAutoStoppedView('SettingsScreen'); // HomeScreen is auto-stopped

Manual Views

const id = await views.startView('Checkout');
// ... later ...
await views.stopViewWithID(id!, { completed: true });

// or by name:
await views.stopViewWithName('Checkout');

// stop every open view in one call
await views.stopAllViews({ reason: 'logout' });

// attach segmentation to an in-flight view
views.addSegmentationToViewWithID(id!, { variant: 'A' });
views.addSegmentationToViewWithName('Checkout', { variant: 'A' });

Pausing and Resuming

views.pauseViewWithID(id);
// ... while paused, duration is not accumulated ...
views.resumeViewWithID(id);

Global View Segmentation

Attach segmentation that is automatically added to every view event:

views.setGlobalViewSegmentation({ environment: 'production' });
views.updateGlobalViewSegmentation({ buildNumber: 3401 });

You can also seed the global segmentation at init time so it is in place before the first view fires:

config.views.setGlobalViewSegmentation({ environment: 'production' });

Automatic View Tracking

When automatic tracking is on, every UIAbility entering the foreground starts an auto-stopped view named after the ability. The other toggles let you trim the name, exclude abilities, opt out of orientation events, and keep a paused manual view paused across fg/bg cycles.

config.views
  .enableAutomaticViewTracking()
  .enableAutomaticViewShortNames()                   // strip module prefix from the view name
  .setAutomaticViewTrackingExclusions(['DebugAbility'])
  .setTrackOrientationChanges(false)                 // suppress [CLY]_orientation events
  .disableViewRestartForManualRecording();           // do not auto-resume paused views on foreground

Device ID Management

If you do not provide a device ID, the SDK generates one (UUID v4) on first launch and stores it in preferences. You can also supply your own device ID or switch it at runtime.

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.deviceId.setId('logged-in-user-42');

Changing the Device ID at Runtime

setID is the recommended method: it picks "merge" or "no merge" automatically based on the current ID's source. Use the specialized forms only when you need to force a specific behavior.

const deviceId = Countly.sharedInstance().deviceId;

// Recommended: SDK picks the correct merge semantics.
await deviceId.setID('new-user-id');

// Advanced:
await deviceId.changeWithMerge('new-user-id');   // merges old + new profiles on server
await deviceId.changeWithoutMerge('fresh-user'); // treat as new user, clear consent

// Inspect current device ID state:
const id = deviceId.getID();
const type = deviceId.getType();             // DeviceIdType: SDK_GENERATED | DEVELOPER_SUPPLIED | TEMPORARY_ID
const isTemp = deviceId.isTemporaryIdMode();

Offline and Temporary ID Mode

Call enableTemporaryIdMode at runtime (also opt in at init via config.deviceId.enableTemporaryMode()) to record data locally without sending it. When a real ID is later provided, queued requests are replayed with that ID.

await Countly.sharedInstance().deviceId.enableTemporaryIdMode();
// ... user is in onboarding; nothing is sent ...
await Countly.sharedInstance().deviceId.setID('user-1234'); // queued requests flush

User Location

Location can be provided at init or at any time during runtime. Any of the four fields (country code, city, GPS, IP) may be null.

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.location.set('US', 'New York', '40.7,-74.0', null);

// later, updating location mid-session:
await Countly.sharedInstance().location.setLocation('DE', 'Berlin', null, null);

// to opt out:
await Countly.sharedInstance().location.disableLocation();

Remote Config

Fetch server-side configuration values and read them out by key. The SDK uses the modern method=rc API (the legacy method=fetch_remote_config endpoint is not exposed). Downloaded values are cached in memory and persisted so they survive SDK restart.

import { RCData, RCDownloadCallback, RequestResult } from 'countly-sdk-hos';

const rc = Countly.sharedInstance().remoteConfig;

// Full download, replaces the whole cache.
await rc.downloadAllKeys((result, error, fullValueUpdate, downloaded) => {
  if (result !== RequestResult.Success) {
    console.warn(`RC failed: ${error}`);
    return;
  }
  // `downloaded` is a Record<string, RCData> of the just-fetched values.
});

// Partial downloads (merge into the existing cache).
await rc.downloadSpecificKeys(['hero_variant', 'checkout_discount'], null);
await rc.downloadOmittingKeys(['legacy_flag'], null);

// Read values.
const entry: RCData | null = rc.getValue('hero_variant');
if (entry && entry.isCurrentUsersData) {
  applyVariant(entry.value);
}
const all: Record<string, RCData> = rc.getValues();

// Register a global callback so every download flows through one listener.
const rcListener: RCDownloadCallback = (result, error, fullValueUpdate, values) => {
  console.info(`RC update, full=${fullValueUpdate} keys=${Object.keys(values).length}`);
};
rc.registerDownloadCallback(rcListener);

// Detach the listener later (the SDK matches by reference).
rc.removeDownloadCallback(rcListener);

// Clear the persisted + in-memory cache (e.g., on user logout).
await rc.clearAllRemoteConfig();

Optional init-time flags (set on config.remoteConfig):

// Auto-enroll users in A/B testing on every download (sends oi=1 instead of oi=0).
config.remoteConfig.enrollABonDownload();

// Fire downloadAllKeys automatically on: init (if not temp-id), exit-temp-id,
// device-ID change, and remote-config consent grant. Without this flag the
// integrator calls downloadAllKeys() manually.
config.remoteConfig.enableAutomaticTriggers();

// On device-ID change, preserve values instead of wiping, they stay in the
// cache with isCurrentUsersData=false until the next successful download.
config.remoteConfig.enableValueCaching();

// Global callback, fires on every download (including partial).
config.remoteConfig.registerGlobalCallback((result, error, full, values) => { /* ... */ });

A/B Testing

Enroll / exit users in A/B tests. The SDK sends /o/sdk?method=ab (enroll) or method=ab_exit (exit). All of these require remote-config consent and are skipped in temporary-device-id mode.

const rc = Countly.sharedInstance().remoteConfig;

await rc.enrollIntoABTestsForKeys(['hero_variant', 'checkout_discount']);
await rc.exitABTestsForKeys(['hero_variant']);
await rc.exitABTestsForKeys(null);   // null / empty = exit all

// Fetch + enroll in one call.
const entry: RCData | null = await rc.getValueAndEnroll('hero_variant');
const all: Record<string, RCData> = await rc.getAllValuesAndEnroll();

Not implemented (developer-tooling paths for A/B test design): testingDownloadVariantInformation, testingGetAllVariants, testingGetAllExperimentInfo, testingEnrollIntoVariant. These are for the dashboard A/B designer, not production runtime.

User Profiles

Attach identity and behavioral data to the device ID. Changes are buffered in memory and flushed when you call save(), or automatically before sessions and events.

const userProfile = Countly.sharedInstance().userProfile;

userProfile.setProperties({
  name: 'Jane Doe',
  email: 'jane@example.com',
  byear: 1990
});

// Custom properties with modifier semantics:
userProfile.increment('launches');
userProfile.incrementBy('points', 50);
userProfile.multiply('score', 2);
userProfile.saveMax('highScore', 100);
userProfile.saveMin('lowestPing', 42);
userProfile.setOnce('signupDate', '2026-01-01');
userProfile.push('badges', 'gold');
userProfile.pushUnique('tags', 'beta');
userProfile.pull('tags', 'alpha');

await userProfile.save();

// drop any buffered changes without flushing
userProfile.clear();

User Consent

When consent is required, no feature collects or sends data until the user has explicitly opted in. The runtime consent surface is intentionally binary, the SDK exposes only giveConsentAll, removeConsentAll, and checkAllConsent. Per-feature consent decisions are not user-callable; configure consent up front via CountlyConfig.consent. The SDK does not persist consent state, the host app must store the user's choice and re-apply it on every launch.

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.consent
  .setRequiresConsent(true)
  .giveAll();   // pre-grant every feature at init

await Countly.initShared(config);

// Runtime: switch every feature on or off in one call.
Countly.sharedInstance().consent.giveConsentAll();
Countly.sharedInstance().consent.removeConsentAll();

// Inspect current state:
const allGranted = Countly.sharedInstance().consent.checkAllConsent();

When the user has not yet made a consent decision but you still want the SDK to collect telemetry locally, enable Unknown Consent Mode at init. The SDK records sessions, views, events, etc. into the request queue while the queue is paused AND the network transport is silenced, nothing reaches the server until the integrator resolves the unknown state.

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.consent.enableUnknownConsentMode();  // implies setRequiresConsent(true)

await Countly.initShared(config);

// ... user makes their decision in your UI ...

// Resolution: pick exactly one. The instance keeps running through both,
// no halt, no separate re-init call for the give path.
//
//   giveConsentAll , "consent given, network calls can start." Unsilences
//                     the transport and resumes the queue; buffered data
//                     drains to the server. SDK continues with full consent.
//
//   removeConsentAll, "consent revoked, erase collected data, keep running
//                      without consent." Wipes the buffered queue, ships a
//                      single revocation consent= snapshot (all features
//                      false), and locks the runtime consent surface.
//                      Subsequent give/removeConsentAll runtime calls log
//                      "consent is set per init" and no-op. To re-enable
//                      consent, just call Countly.initShared(newCfg) again,
//                      initShared detects the lock and replaces the instance
//                      with a fresh one (in-process, no app restart).
Countly.sharedInstance().consent.giveConsentAll();

Stop, Halt, and In-Process Re-Init

The SDK distinguishes between two ways to shut down an instance:

  • instance.stop(), halt in-memory (drains modules, halts the request queue, deregisters lifecycle observer, cancels timers) but KEEPS persisted storage. The motivating use case is config swap at runtime.
  • instance.halt(), does everything stop() does AND wipes every persisted SDK key (device ID, request queue, server-config cache, health-check counters, user-profile cache, remote-config cache). For "delete my data" flows.

Static fan-outs Countly.stopAll() (preserves all storage) and Countly.haltAll() (wipes all storage) iterate over the shared instance plus every named instance.

Both are idempotent and per-instance isolated, stopping or halting one instance does not affect siblings.

In-process re-init uses the same initShared call you'd use at startup:

// initShared is idempotent on a healthy shared.
await Countly.initShared(cfg);     // creates the shared
await Countly.initShared(cfg);     // returns the cached one, no replace

// After UCM revoke / stop / halt, the existing shared is unusable. The
// next initShared call detects that and rebuilds with the new config:
await Countly.sharedInstance().consent.removeConsentAll();
await Countly.initShared(newCfg);  // auto-replaces, fresh instance, new config

// Or explicitly stop first to force a config swap:
await Countly.sharedInstance().stop();
await Countly.initShared(newCfg);  // replaces because the previous was stopped

Replacement only touches the shared instance, named instances created via createInstance are unaffected. Persisted storage survives the replacement (via stop() semantics), so any buffered requests load into the new instance and ship.

Feature names are exported on CountlyFeature for diagnostics and for the onConsentChanged wire payload, the public consent surface itself is all-or-nothing.

import { CountlyFeature, CountlyFeatureNames } from 'countly-sdk-hos';

// CountlyFeature.SESSIONS, .EVENTS, .VIEWS, .CRASHES, .USERS, .LOCATION,
// .PUSH, .STAR_RATING, .REMOTE_CONFIG, .FEEDBACK, .CLICKS, .SCROLLS,
// .CONTENT, .METRICS, .FORMS
// CountlyFeatureNames, string[] of every feature name in wire order.

Security and Privacy

To protect against man-in-the-middle tampering, you can configure a SHA-256 salt. Every request gets a &checksum256=... appended before sending; the server validates it against the same salt.

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.network.setParameterTamperingProtectionSalt('shared-salt-from-server');

Other Features and Notes

Multi-Instance

The SDK supports multiple independent Countly instances in the same process. Each instance has its own storage (segmented by app key), its own device ID, and its own request/event queues. Instances never share mutable state.

import { Countly, CountlyConfig } from 'countly-sdk-hos';

const cfgA = new CountlyConfig(this.context, URL_A, APP_KEY_A);
const cfgB = new CountlyConfig(this.context, URL_B, APP_KEY_B);

const a = await Countly.createInstance('analytics', cfgA);
const b = await Countly.createInstance('crash', cfgB);

await a.events.recordEvent('login');
await b.crashes.recordHandledException(new Error('demo'));

// Look up a named instance later, or enumerate every instance that is currently live.
const same = Countly.getInstance('analytics');     // CountlyInstance | undefined
const names = Countly.listInstances();             // string[]

Each instance auto-subscribes to applicationStateChange independently, so a single fg/bg transition fans out to all of them. Manual control (e.g., tests): Countly.lifecycleForegroundAll() / Countly.lifecycleBackgroundAll().

Custom Network Headers

If you need to proxy requests or attach authentication:

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.network.addCustomNetworkRequestHeaders({
  'X-Internal-Token': 'abc123'
});

Tuning Queue and Timing Limits

All of these have sensible defaults: only touch them if your traffic pattern warrants it. Limits also enforced server-side via SDK Behavior Settings will override these locally when fetched.

config.network
  .setMaxRequestQueueSize(1000)         // hard cap on pending requests (default 1000)
  .setRequestDropAgeHours(72)           // discard requests older than this on send (default 168h / 7 days)
  .setRequestTimeoutDuration(30)        // per-request HTTP timeout in seconds (default 30)
  .setHttpPostForced(true)              // always POST, never GET, regardless of payload size
  .disableBackoffMechanism();           // disable adaptive backoff on repeated failures

Session Update Interval

Automatic / hybrid sessions emit a session_duration heartbeat every 60 seconds by default. Override the cadence at init:

config.sessions.setUpdateIntervalSec(30);

Event Queue Threshold

Events are batched in memory; once the threshold is hit the batch is flushed into a single request. Default is 100.

config.events.setQueueSizeToSend(50);

Overriding Device Metrics

To override any auto-detected metric (for example, to report a custom device name instead of productModel):

new CountlyConfig(this.context, URL, APP_KEY)
  .setMetricOverride({
    '_device': 'Custom Device',
    '_os_version': '5.0'
  });

Clearing the Stored Device ID

To force a fresh device ID on next launch (for example for debug/test resets), set the clear flag in config:

const config = new CountlyConfig(this.context, URL, APP_KEY);
config.deviceId.enableClearStoredDeviceId();

Offline Mode

Offline mode keeps enqueue working but suspends the request processor: telemetry accumulates locally (and persists across restart) until disableOfflineMode is called.

const c = Countly.sharedInstance();
c.requests.enableOfflineMode();
// ... telemetry accumulates with no network traffic ...
await c.requests.disableOfflineMode();            // resume with current device id
await c.requests.disableOfflineMode('new_user');  // resume and swap device id first

You can also start the SDK offline by calling config.network.enableOfflineMode(). This is useful when you need a consent decision before any traffic ships.

Queue Operations

Queue-related operations live under Countly.sharedInstance().requests (the RequestQueueApi facade, backed by ModuleRequestQueue).

const c = Countly.sharedInstance();

// Drop both queues (event + request). Timed events are preserved.
await c.requests.flushQueues();

// Custom request injected directly, consent-gated.
await c.requests.addDirectRequest({ 'custom_param': 'value' });

// After an app-key change, either rewrite the previously-queued traffic…
await c.requests.replaceAllAppKeysInQueueWithCurrentAppKey();

// …or drop it.
await c.requests.removeDifferentAppKeysFromQueue();

// One-off /i request with the standard device-metrics bag plus overrides.
await c.requests.recordMetrics({ '_app_version': '1.0.0-demo' });

Changing the Server URL

Countly.sharedInstance().setServerURL('https://new.countly.server');
// Validation matches the constructor: http(s) only, no userinfo in authority.
// Queued requests will drain to the new host on the next tick.

SDK Health Check

At init the SDK sends a single immediate report to /i containing internal error/warning log counts, the last failed network request, and backoff stats. No consent is required (diagnostic data only: no PII). On a successful ack the counters are cleared and persisted; on failure they are retained for the next launch.

// Opt out of the one-shot report. Counters still accumulate internally so
// you can re-enable later (e.g., in a debug build).
config.network.disableHealthCheck();

Server Configuration

Server Configuration is enabled by default. Changes made on SDK Manager SDK Configuration on your server will affect SDK behavior directly.

In all cases, the configuration may not be applied during the app’s first run. If this is a security sensitive case for the situations, you can provide the server config to the SDK during initialization.

// Bootstrap with a known-good config so the SDK has working settings before
// the first server fetch (or on networks where the SBS endpoint is locked
// down). The string must be a JSON envelope with v / t / c keys, like the
// server response format.
config.setSDKBehaviorSettings('{"v":1,"t":1742459739383,"c":{"eqs":50}}');

If you want to disable automatic config updates from the server, you can prevent the SDK from making server configuration fetch requests. This is useful if you are trying to reduce network traffic or control request counts.

// Disable the periodic + init-time fetch entirely. The SDK still loads any
// persisted settings; it just never refreshes them from the server.
config.disableSDKBehaviorSettingsUpdates();

Experimental Features

Prototype-stage features with no stability guarantee. Exposed under config.experimental:

config.experimental
  // Tag every event/view with cly_v (1 in foreground, 0 in background).
  .enableVisibilityTracking()
  // Add cly_pen (previous event name) + cly_cvn (current view name) to
  // custom events. Useful for reconstructing user navigation from raw data.
  .enablePreviousNameRecording();

Example Integration

The following example shows a realistic integration in an AbilityStage. Initialising at the stage level avoids the cold-boot timing race documented in Alternative: UIAbility Initialization:

import AbilityStage from '@ohos.app.ability.AbilityStage';
import {
  Countly,
  CountlyConfig,
  CountlyFeature,
  LogLevel
} from 'countly-sdk-hos';

const APP_KEY = 'YOUR_APP_KEY';
const URL = 'https://try.count.ly';

export default class MyAbilityStage extends AbilityStage {
  onCreate(): void {
    const config = new CountlyConfig(this.context, URL, APP_KEY)
      .setAppVersion('1.4.2');

    config.logging
      .enableLogging()
      .setMinLevel(LogLevel.DEBUG);

    config.consent
      .setRequiresConsent(true)
      .giveAll();

    config.crashes
      .enableCrashReporting()
      .setCustomCrashSegmentation({ 'buildType': 'release' });

    config.views
      .enableAutomaticViewTracking()
      .enableAutomaticViewShortNames();

    // onCreate is synchronous-void: fire-and-forget the async init.
    // Lifecycle is auto-wired by the SDK, no onForeground / onBackground
    // overrides are needed in your UIAbility.
    Countly.initShared(config).catch((err) => console.error(`Countly init failed: ${err}`));
  }
}

Make sure the AbilityStage is registered at the module level in entry/src/main/module.json5:

{
  "module": {
    "srcEntry": "./ets/myabilitystage/MyAbilityStage.ets"
  }
}

FAQ

Why does initShared return a Promise?

HarmonyOS's preferences API (@ohos.data.preferences) is asynchronous. The SDK needs to load the stored device ID and any queued requests before accepting new calls, which means init must be async. await the call before using any other SDK method.

Will unhandled Promise rejections be captured?

Promise rejections reach the errorManager handler only if they bubble up to the ability scope. Where you can, try/catch critical async calls and use crashes.recordHandledException so the crash metadata matches the point where the error occurred.

How do I reset the SDK for tests?

Call Countly.haltAll(). This flushes the in-memory event queue, stops all timers, and clears the static registry of instances. The next initShared/createInstance call starts fresh.

Can I use both the shared instance and named instances at once?

Yes. The shared instance and named instances share no state; they simply coexist. Each instance has its own applicationStateChange subscription, so the same fg/bg event reaches all of them independently. Manual helpers like lifecycleForegroundAll still iterate every active instance for test-driven control.

What happens to queued requests if the app is killed?

The request queue is persisted via @ohos.data.preferences after every enqueue. On next launch the SDK loads the queue and resumes sending. Requests older than the configured drop age are discarded before being sent.

Was this page helpful?
Reach out to us for any other questions.
Helpful?

Looking for more Help?