SDK Development Guide

Would you like to develop a new SDK for Countly? Then this guide is for you. Before starting, bear in mind that there are a lot of SDKs that Countly has already developed. Please check whether the SDK you are going to develop is not already available.

Integration

Initialization

To start making requests to the server, SDK needs 3 things: the URL of the server where you will be making requests, the app_key of the app for which you will be reporting, and your current device_id to uniquely identify this device.

Server URL - The SDK needs to provide the ability for the user to specify the URL for the server where their Countly instance is installed. This be used for all requests. The SDK must validate the server URL:

  • URL must be non-null and non-empty
  • URL must be a valid URL format (throw/warn on invalid)
  • Trailing slashes must be stripped automatically
  • If certificate/public key pinning is enabled, the URL must use HTTPS

App Key - The App key should be provided by the SDK user. This value identifies to which dashboard applications this request is going to be tied to. Your app should be created on the Countly server. After app creation, the server will provide an app key for the user. The same app key is used for the same app on different platforms.

Device ID - A device ID is required to uniquely identify a device or user. If you have some unique user ID which you can retrieve, you may use it. If not, you may provide platform-specific device identification (as Advertising identifier in Google Play Services on Android) or use existing implementations (as OpenUDID). More info about device ID is described here.

Init Configuration

The SDK should take a config (short for configuration) object during initialization. In that config object should all the init specic fields added. All initial configuration should be doable through that object and if possible, no configuration should be done with function calls before init.

That config object should also have the option to change all the available SDK tweakables like queue sizes and thresholds.

Depending on what features are implemented in the SDK, tweakables for them would also be provided through the config object, for example: debug parameters, id generation methods, user location, consent etc.

Depending on the SDK platform, there might be some other mandatory fields. If during init those are not provided, an exception can be thrown to indicate that.

For more information of what values can be sent to the server, look api reference below:

Networking Configuration

The following config options control how the SDK communicates with the server:

CountlyConfig.setHttpPostForced(isForced: boolean)

// Logic
Forces the SDK to use HTTP POST for all requests instead of GET.
Default is true (POST is used).
CountlyConfig.addCustomNetworkRequestHeaders(headers: Map<String, String>)

// Logic
Adds custom HTTP header key/value pairs to every request sent to the server.
Useful for authentication or routing through proxies.
CountlyConfig.enablePublicKeyPinning(certificates: String[])

// Logic
Enables SSL public key pinning. The SDK will only accept server connections
if the public key of the server's SSL certificate matches one of the provided certificates.
Certificates should be base64-encoded strings.
CountlyConfig.enableCertificatePinning(certificates: String[])

// Logic
Enables SSL certificate pinning. The SDK will only accept server connections
if the full certificate matches one provided. Certificates should be base64-encoded strings.
CountlyConfig.setRequestDropAgeHours(dropAgeHours: int)

// Logic
Sets a time frame in hours after which old requests are dropped from the queue.
For example, setting this to 10 means any request created more than 10 hours ago
will be dropped when the SDK attempts to send it. Default is 0 (disabled).
CountlyConfig.setRequestTimeoutDuration(timeout: int)

// Logic
Sets the network request timeout in seconds.
Default is 30 seconds. Minimum value is 1 second.
CountlyConfig.setParameterTamperingProtectionSalt(salt: String)

// Logic
Enables parameter tampering protection. When set, the SDK will compute a
SHA256 checksum of the request data combined with the salt and append it as
"checksum256" to every request. The server verifies this checksum to ensure
the request data has not been tampered with in transit.

SDK Tweakables

The following config options control SDK behavior thresholds:

CountlyConfig.setEventQueueSizeToSend(threshold: int)

// Logic
Sets the number of events that will trigger a request to the server.
When the event queue reaches this threshold, events are combined into a request.
Default is typically 100.
CountlyConfig.setUpdateSessionTimerDelay(delay: int)

// Logic
Sets the interval in seconds for automatic session update requests.
Minimum value is 1. Default is 60 seconds.
CountlyConfig.setMaxRequestQueueSize(maxSize: int)

// Logic
Sets the maximum number of requests that can be stored in the request queue.
When the queue exceeds this size, oldest requests are removed. Default is 1000.
CountlyConfig.enableManualSessionControl()

// Logic
Enables manual session handling. When enabled, the SDK will not automatically
manage sessions. The developer must call beginSession, updateSession, and
endSession manually.
CountlyConfig.enableManualSessionControlHybridMode()

// Logic
Enables hybrid manual session control. Sessions are managed manually but
certain automatic behaviors (like session extension on activity) are preserved.
CountlyConfig.disableBackoffMechanism()

// Logic
Disables the exponential backoff mechanism for failed network requests.
When disabled, the SDK will retry failed requests without increasing delays.
CountlyConfig.enableExplicitStorageMode()

// Logic
Prevents the SDK from writing request/event queues to disk automatically.
The developer must explicitly signal when to persist data. Platform-specific.
CountlyConfig.setRequiresConsent(requiresConsent: boolean)

// Logic
When set to true, no features will function until consent is explicitly given.
All features are disabled by default until consent is granted for each feature group.
CountlyConfig.setConsentEnabled(featureNames: String[])

// Logic
Gives consent to specific feature groups during init. Only has effect if
requiresConsent is set to true. See User Consent section for all feature names.
CountlyConfig.giveAllConsents()

// Logic
Gives consent for all available features during init. Shorthand for enabling every
consent feature individually.

Location Configuration

CountlyConfig.setLocation(countryCode: String, city: String, gpsCoordinates: String, ipAddress: String)

// Logic
Sets initial location data during init. All parameters are optional (nullable).
- countryCode: ISO 3166-1 alpha-2 country code (e.g., "US")
- city: city name (e.g., "New York")
- gpsCoordinates: latitude,longitude string (e.g., "40.7128,-74.0060")
- ipAddress: IP address for geolocation
CountlyConfig.disableLocation()

// Logic
Disables location tracking. When set, an empty location request is sent to the
server to prevent it from inferring location from the IP address.

Custom Metrics

CountlyConfig.setMetricOverride(metricOverride: Map<String, String>)

// Logic
Allows overriding default device metrics or adding additional custom metrics
that will be sent with session requests. This can be used to override values like
device name, OS version, app version, or to add entirely new metrics.

User Properties During Init

CountlyConfig.setUserProperties(userProperties: Map<String, Object>)

// Logic
Allows providing user properties during initialization. These properties are
sent to the server after init completes. This is useful for setting user data
without needing to call setProperties + save after init.

Logging / Debug Mode

The SDK should have a logging mode flag. If that is enabled, the SDK should print logs to the console.

If this flag is not enabled, no logs should be printed to the console.

This flag functions independently of the log listener (described below).

  Log printed to console Log printed to listener
Nothing set No No
Only flag set Yes No
Only listener set No Yes
Flag and listener set Yes Yes

Log Messages

No two places should have the same log message. This means that all messages should be unique. Knowing the log message and the sdk version it should be unambiguous to know which line printed that message.
To help with being unambiguous, the log messages could include the internal "module" or "section" name from where it was called. If the function called is part of the public API of the SDK, that log should include the function name.

[ModuleName] functionName, Some message

If there is a message tag/group mechanism on the platform then the tag name "Countly" should be used. If there is no tag/group mechanism available then the name "Countly" should be added to each message.

[Countly] [ModuleName] functionName, Some message

All calls, that can be called by developers, should produce a log message to indicate what is being called.

The goal is to have a good enough log coverage so that it's easy to understand what was happening with the SDK and how it was used when a SDK integrator provides his logs.

Log Levels

When implementing logs, the SDK should follow these levels:

  • Error - this is an issue that needs attention right now. E.g.:
    • try-catch block errors
    • missing `url` or `app_key`
  • Warning - this is something that is potentially an issue. E.g.:
    • deprecated methods
    • wrong parameters provided
    • SDK is not initialized yet
  • Info - informs about the usual working of the SDK. E.g.:
    • public method calls
    • provided init config options
    • initializations of modules
  • Debug - this should contain logs from the internal workings of the SDK. E.g.:
    • request success response
    • failed request
    • unique inner function checks
  • Verbose - this should give an even deeper look into the SDK's inner working (noisy). E.g.:
    • No consent given

By using log levels "info" and above it should be clear what the dev was doing with the SDK and what was the call order. "Debug" and above should give us a good enough look into the SDK's internal state and a good overview of it's configuration.

Platforms that don't have explicit log levels should print them manually at the start of the log in square brackets. Either of these variants currently seem fine (as long as it's consistent for the SDK)

  • [ERROR], [WARNING], [INFO], [DEBUG], [VERBOSE]
  • [Error], [Warning], [Info], [Debug], [Verbose]
  • [error], [warning], [info], [debug], [verbose]

If there are the same amount of log levels available but with different names then the closest ones available for the platform should be picked.

If there are less than 5 log levels available then multiple ones should be printed in the same log level and the appropriate tag in square brackets should be printed at the start.

For example, if there are only 3 levels "error", "info", "debug". "Error" and "Warning" would be printed in the error "channel" and "debug" and "verbose" would be printed in the "debug" channel".

E: [Error] [Countly] [ModuleName] functionName, Countly issue bla bla
E: [Warning] [Countly] [ModuleName] functionName, You are trying to use this feature in a deprecated way

Depending on the function call, the function parameters should also be printed, and they should be enclosed in closed brackets. For example event keys or for simple numerical values.

I: [Info] [Countly] [ModuleEvent] recordEvent, event is being recorded key: [Login], count: [2], segmentation: [{"isLoggedIn":true, "name":"Something"}]

For functions which receive a callback, a bool is enough to indicate if a callback was or wasn't provided.

Log Listener

In some cases there might be situations where an integrated SDK is not behaving as expected in a release build which is used by clients. In those cases it would be valuable to see the inner workings of the SDK but it's not possible to access SDK logs. It wouldn't also be recommendable to print those logs is a published setting as they might leak private info to other apps on the same device.

For this reason exists the log listener feature. During init the developer can setup a log listener. For example, it could be a callback. If such a listener is set, logs should be printed to it. For every log that is provided, there should be 2 values. First the string of the message that would be printed and then the log level of that message so that it can be used as an indication of the message importance.

Countly Code Generator

If you would like to understand how SDKs work by generating mobile or web code for events, user profiles, crash reporting, and all other features that come with Countly in general, we suggest you use the below tool which is a point and click service that builds the necessary code for you.

SDK Storage and Requests

Making Requests

The Countly server is a simple HTTP-based REST API server, and all SDK requests should be made to /i endpoint with two required parameters: app_key and device_id.

Other optional parameters need to be provided based on what this request should do. You may checklist all the parameters that the Countly Server can accept in api reference /i endpoint.

There are some parameters that should be added to all requests, even though they are not mandatory. Together with the required parameters, they form the base request. Every request sent to the server should be formed from this base request. The parameters in this base request are:

  • "app_key" - the application key for this countly app (retrievable on the dashboard)
  • "device_id" - the current user's device ID
  • "timestamp" - the timestamp in ms of when this request is created
  • "hour" - the hour of the timestamp
  • "dow" - the day of the week for this timestamp. 0 - sunday, ... , 6 - saturday.
  • "tz" - this device's timezone offset
  • "sdk_version" - the SDK's version
  • "sdk_name" - the SDK's name
  • "av" - the application's version string
  • "rr" - the remaining request count in the queue minus 1 (appended at send time, not at creation)
  • "t" - the device ID type (platform-specific, sent by iOS and Web SDKs)

In cases where some devices may be offline, etc., and requests should be queued, it is highly recommended you add a timestamp to each request, displaying when it was created.

Encoding URI Components

Due to the possible use of the ‘&’ and ‘?’ symbols in encoded JSON strings, SDKs should encode uri components before adding them to the request and sending it to the server.

Using GET or POST

By default, the preferred method is to make GET requests for the Countly servers. However, there may be some length limitations for GET requests based on specific platform or server settings. Thus, the best practice is to make a POST request when the data reaches over 2,000 characters for a single request.

Before making each request, you will need to check if the data you are going to send is less than 2,000 characters. If so, use GET. If you have more characters, use POST.

Additionally, the SDK should be able to switch to post completely if a user should so specify in the SDK configuration/settings.

When making POST requests, the used content type should be "application/x-www-form-urlencoded".

SDK Metadata

The SDK should send the following metadata with every request.

  • SDK name:

Query String Key: sdk_name Query String Value: [language]-[origin]-[platform] Example: &sdk_name=objc-native-ios

  • SDK version:

Query String Key: sdk_version Query String Value: SDK version as string Example: &sdk_version=20.10.0

The SDK versions decode to [year].[month].[minor release number]. The SDK major versions  (year, month) should follow the server versions. Those are usually incremented twice a year during our major releases. Minor release numbers should start at "0". Major version numbers (year, month) should stay the same even if the SDK version is released a couple of months after the indicated date. If the servers version is released in October 2020 the major versions would be "20.10". The first version released by the SDK, that would be in sync with this server version, would have the version "20.10.0". If the SDK needs to release an update in January of 2021 and no major server release has happened, the next version released by the SDK will be "20.10.1".

No zeroes should be added to the second number (indicating the month) in case the release happens before October.

Storage

Some things in the SDK are stored persistently, for example, request queue, event queue etc. Those should be stored in the device storage.

If possible, those persistent values should be segmented by the appKey. That means that for every appKey there should be different storage for their queues.

Request Queue

In some cases, users might be offline, thus they are not able to make requests to the server. In other cases, the server may be down or in maintenance, thus unable to accept requests. In both cases, the SDK should handle queuing and persistently storing requests made to the Countly server and should wait for a successful response from the server before removing a request from the queue.

Note that requests should be made in historical order, meaning you must also preserve the order of your queue.

Simple flow on how requests should appear as follows:

  1. Initiating request - either a new event reported or session call, etc.
  2. Creating a payload - take all the parameters (including the current timestamp) and the values needed for a request and generate a payload which will be included in the HTTP request
  3. This payload is inserted into the queue (First In, First Out)
  4. All updates to the queue should be persistently stored. Based on the environment, you may directly use storage for the queue
  5. On some other thread there should be a request processor which takes the first request in the queue, applies the checksum if needed, determines the request type (GET or POST) based on the length, and makes the HTTP request
    • if the request is successful (defined below), then it should be removed from the queue and the next request will be processed upon the next iteration
    • if the request failed, the request processor should have a cool-down period, lasting a minute or so (configurable value), and it will then try the same request again until it is completed

There are multiple scenarios why a request might fail, so to ensure that the request is successfully delivered to the server SDK, you will need to assure the following has taken place:

  1. The HTTP response code was successful (which is any 2xx code or code between 200 <= x < 300)
  2. The returned request is a JSON object
  3. That JSON object contains the field "result" (there can be other fields)

When sending request to a Countly server, it would respond with a JSON object, which should have a property named "result". Usually that value will be "Success". There may be scenarios where a different "result" value is returned or where additional fields may be added.

If the previously described things are "true", then it means the request was successfully delivered to the server and can be removed from the queue.

Queue Size Limit

We need to limit the queue size so that it doesn’t overflow, and so that syncing up won’t take too long if some specific server is down for too long. This limit would be in the number of stored queries, and this limit should be available for the end-user to change as the SDK settings.

In case this limit is reached, the SDK should remove older queries and insert new ones. The default limit may change from what the SDK needs, but the suggested limit is 1,000 queries.

Request Drop by Age

SDKs should support dropping requests that exceed a configurable age (in hours). When enabled via setRequestDropAgeHours, requests created more than the specified hours ago are discarded before sending. The request's timestamp is compared against the current time to determine age.

Request Queue Storage Format

The storage format is platform-specific but should meet these requirements:

  • Requests must be stored as serialized query strings
  • Android: uses SharedPreferences with request strings joined by ":::" delimiter
  • iOS: uses NSKeyedArchiver to serialize an NSMutableArray of query strings to file
  • Web: uses localStorage with a JSON array of request objects
  • Storage should be segmented by app_key to support multi-instancing

GET vs POST Request Threshold

Requests are sent as POST by default. When the request data length exceeds 2,048 characters (Android/iOS) or 2,000 characters (Web), the SDK should switch to POST. Additionally, crash requests (&crash=) should always use POST regardless of size. If setHttpPostForced is enabled, all requests use POST.

Parameter Tampering (checksum256) Algorithm

// Algorithm: SHA-256(request_data_string + salt)
// The salt is a shared secret configured by the developer

1. Take the full request query string (e.g., "app_key=xxx&device_id=yyy&...")
2. Append the salt directly: requestData + salt
3. Compute SHA-256 hash of the combined string
4. Convert to hex string
5. Append as &checksum256={hex_hash} to the request

// Platform differences:
- Android/iOS: use natural parameter insertion order, lowercase hex output
- Web: sorts parameter keys alphabetically before building query string, UPPERCASE hex output
- For file/picture uploads: URL-decode the request data before hashing

// If salt is not configured, no checksum should be appended.

Bot/Crawler Detection

SDKs should implement bot/crawler detection to avoid polluting analytics data. When a request is identified as coming from a bot or crawler, it should be silently dropped. Android checks the device name against a configurable list (default: "Calypso AppCrawler"). Web uses a comprehensive regex of ~40 bot user-agent strings (Googlebot, Baiduspider, HeadlessChrome, etc.) and sets an ignore_visitor flag that prevents all requests from being queued.

Custom Endpoint Override

Some requests need to be sent to endpoints other than /i (e.g., /o/sdk for remote config, feedback widget data). SDKs use an internal &new_end_point= tag embedded in the request data. The request processor strips this tag before sending and uses its value to route to the correct endpoint.

Event Queue

Similary to the request queue, each SDK should also have the event queue. It is used for the purposes of combining multiple events together and decreasing the total request amount.

This queue is also FIFO, and has a maximum size.

Other Storage

Some other features might also require storage to store some things persistently (remote config, etc).

Those would have their own storage format.

Recording Time of Data

To properly report and process data (especially queued data), you should also provide the time when the data was recorded. You will need to provide 3 parameters with each request:

  • timestamp: 13-digit UTC millisecond unique timestamp of the moment of action
  • hour: Current user local hour (0 - 23)
  • dow: Current user day of the week (0-Sunday, 1 - Monday, ... 6 - Saturday)
  • tz: Current user time zone in minutes (120 for UTC+02, -300 for UTC-05)

As multiple events may be combined in a single request, you should also provide these parameters automatically in every event object.

The suggested millisecond timestamp should be unique, meaning if events were reported in the same timestamp, the SDK should update the millisecond timestamp in the order in which the events were reported. The pseudo-code to the unique millisecond timestamp could appear as follows:

//variable to hold last used timestamp
lastMsTs = 0;

function getUniqueMsTimestamp(){
  //get current timestamp in miliseconds
  ts = getMsTimestamp();
  
  //if last used timestamp is equal or greater
  if(lastMsTs >= ts){
    //increase last used
    lastMsTs++;
  }
  else{
    //store current timestamp as last used
    lastMsTs = ts;
  }
  //return timestamp
  return lastMsTs;
}

If it’s impossible to use a millisecond timestamp on a specific platform, you may also use a 10-digit UTC seconds timestamp.

General SDK Structure Overview

Depending on the SDK’s environment/language, a different set of features could be supported. Some of these features may be supported on any platform, whereas others are quite platform-specific. For example, a desktop app may not provide telecom operator information.

Note that function and argument namings are only examples of what it could be. Try to follow your platform/environment/language best practices when creating and naming functions and variables.

Core features are the minimal set of features that the SDK should support, and these features are platform-independent.

Initialization

In its official SDKs, Countly is used as a singleton object or basically an object with a shared instance. Still, there are some parameters that need to be provided before the SDK can work. Usually, there is an "init" method that accepts the URL, app key, and device_id (or the SDK generates it itself if it’s not provided):

Countly.init(string url="https://try.count.ly", string app_key, string device_id, ...)

Multi-Instance Support

The SDK should support running multiple independent instances simultaneously. Each instance operates with its own configuration (server URL, app key, device ID), its own request and event queues, and its own persistent storage. Instances must not share state or interfere with each other.

A typical multi-instance pattern:

CountlyConfig config1 = new CountlyConfig(url1, appKey1);
CountlyConfig config2 = new CountlyConfig(url2, appKey2);

Countly instance1 = Countly.createInstance(config1);
Countly instance2 = Countly.createInstance(config2);

// Each instance tracks independently
instance1.events().recordEvent("event_a");
instance2.events().recordEvent("event_b");

Multi-instancing requirements:

  • Each instance must have isolated persistent storage, segmented by app key
  • Each instance must maintain its own device ID, session state, and consent state
  • Lifecycle callbacks (foreground/background) must be forwarded to all active instances
  • The singleton/shared instance pattern should still be available as a convenience for single-instance usage
  • Instances should be independently initializable and destroyable without affecting other instances

Modular Architecture

The SDK should be structured as a collection of independent modules, each responsible for a single feature (sessions, events, views, crashes, etc.). Modules should communicate through a well-defined internal interface rather than directly referencing each other.

Each module should:

  • Have a clear lifecycle (init, start, stop, consent change, device ID change)
  • Be independently testable
  • Only activate when its required consent is granted
  • React to cross-cutting events (device ID change, consent change) through a shared notification mechanism

Thread Safety

All public API methods must be thread-safe. The SDK should handle being called from any thread without causing race conditions or crashes. Internal state (queues, maps, counters) should be protected using the platform’s appropriate concurrency primitives (synchronized blocks, locks, serial queues, etc.).

Network operations and disk I/O should be performed off the main/UI thread. Callbacks to the developer should be delivered on the main/UI thread where the platform convention expects it.

Testing Requirements

SDKs must maintain a minimum of 90% code coverage across unit and integration tests. Test suites should cover:

  • Unit tests for each module in isolation (mocked dependencies)
  • Integration tests for cross-module interactions (e.g., consent change affecting sessions and views)
  • Request validation tests verifying the correct parameters are sent for each feature
  • Edge case tests for invalid inputs, out-of-order calls, and boundary conditions
  • Concurrency tests to verify thread safety of public APIs
  • Storage tests to verify persistence and recovery across SDK restarts

Tests should be automated and run as part of CI/CD. Coverage reports should be generated and tracked over time. Any new feature or bug fix must include corresponding test cases.

Crash Reporting

Exposed Methods

Config Methods

CountlyConfig.crashes.enableCrashReporting()
CountlyConfig.crashes.setCustomCrashSegmentation(crashSegment: Map<String, Object>)
CountlyConfig.crashes.setGlobalCrashFilterCallback(callback: GlobalCrashFilterCallback)
CountlyConfig.crashes.enableRecordAllThreadsWithCrash()

Instance Methods

CountlyInstance.addCrashBreadcrumb(record: String)
CountlyInstance.recordHandledException(exception: Error/Exception)
CountlyInstance.recordHandledException(exception: Error/Exception, customSegmentation: Map<String, Object>)
CountlyInstance.recordUnhandledException(exception: Error/Exception)
CountlyInstance.recordUnhandledException(exception: Error/Exception, customSegmentation: Map<String, Object>)

Implementation Details

For config method enableCrashReporting

CountlyConfig.crashes.enableCrashReporting()

// Logic

Enables automatic unhandled crash reporting.
When enabled, the SDK will set up an uncaught exception handler
that will capture unhandled crashes and report them to the server.
- Crash recording should be gated by server-side configuration (e.g., getCrashReportingEnabled())
  If the server config disables crash tracking, crashes should not be recorded.

For config method setCustomCrashSegmentation

CountlyConfig.crashes.setCustomCrashSegmentation(crashSegment: Map<String, Object>)

// Valid values
Accepted value types are: Integer, String, Double, Boolean, Arrays
Non valid types are warned and ignored

// Logic
Sets custom crash segmentation which will be added to ALL recorded crashes
(both handled and unhandled). This is global crash segmentation.

For config method setGlobalCrashFilterCallback

CountlyConfig.crashes.setGlobalCrashFilterCallback(callback: GlobalCrashFilterCallback)

// Valid values
Value is not nullable

// Logic
Registers a callback that will be called for each crash before it is sent to
the server. The callback receives a CrashData object that can be modified or
used to filter (omit) the crash entirely.

The callback's signature is GlobalCrashFilterCallback:

GlobalCrashFilterCallback {
  boolean filterCrash(CrashData crash)
}

// Parameters
- crash, CrashData object containing all crash-related data
// Return value
- true: the crash should NOT be sent to the server (omit it)
- false: the crash should be sent to the server

For config method enableRecordAllThreadsWithCrash

CountlyConfig.crashes.enableRecordAllThreadsWithCrash()

// Logic
Enables recording of all threads with crash reports. When enabled,
the SDK will capture stack traces of all running threads, not just the
crashing thread.

// Internal Limits Applied
- maxStackTraceThreadCount (default: 50) caps the number of threads recorded
- maxStackTraceLinesPerThread (default: 30) caps lines per thread
- maxStackTraceLineLength caps individual line length

For instance method addCrashBreadcrumb

CountlyInstance.addCrashBreadcrumb(record: String)

// Valid values
Only non null and not empty strings are accepted
Non valid values are warned and ignored

// Logic
Adds a breadcrumb log entry that will be sent together with crash reports.
Breadcrumbs are stored in memory in an array. When a crash occurs, the array
is concatenated with newline symbols and submitted under the "_logs" property.
Breadcrumbs are limited by the "maxBreadcrumbCount" internal limit.
Breadcrumb text is limited by the "maxValueSize" internal limit.

For instance method recordHandledException

CountlyInstance.recordHandledException(exception: Error/Exception)
CountlyInstance.recordHandledException(exception: Error/Exception, customSegmentation: Map<String, Object>)

// Valid values
Exception/error object is not nullable
customSegmentation is nullable, accepted value types: Integer, String, Double, Boolean, Arrays

// Logic
Records a handled (non-fatal) exception/error. The "_nonfatal" property is set to true.
- Stack trace is extracted and truncated to max 20,000 characters
- If recordAllThreadsWithCrash is enabled, all thread stack traces are appended
- Stack trace lines are limited by maxStackTraceLineLength and maxStackTraceLinesPerThread
- Custom segmentation, if provided, is merged with global crash segmentation (custom overrides global)
- Internal limits are applied to the combined segmentation
- A CrashData object is created with: stackTrace, combinedSegmentation, breadcrumbs, crashMetrics, fatal flag
- The crash is sent through the crash filter callback if registered (if it returns true, crash is dropped)
- After filtering, the "_ob" metric is calculated to indicate which fields were modified
- Internal limits are re-applied after filtering
- The crash report is serialized to JSON and added to the request queue
- Crash recording should be gated by server-side configuration (e.g., getCrashReportingEnabled())
  If the server config disables crash tracking, crashes should not be recorded.

For instance method recordUnhandledException

CountlyInstance.recordUnhandledException(exception: Error/Exception)
CountlyInstance.recordUnhandledException(exception: Error/Exception, customSegmentation: Map<String, Object>)

// Valid values
Exception/error object is not nullable
customSegmentation is nullable, accepted value types: Integer, String, Double, Boolean, Arrays

// Logic
Records an unhandled (fatal) exception/error. The "_nonfatal" property is set to false.
- Same crash preparation flow as recordHandledException applies
- For automatic unhandled crash reporting (enableCrashReporting):
  The SDK sets an UncaughtExceptionHandler that captures unhandled crashes
  After recording, the previous exception handler (if any) is also notified
- Native crash dumps (NDK on Android, PLCrash on iOS) are read from the crash dump
  folder at init, base64-encoded, and sent to the server. Dump files are deleted after recording.
- Crash recording should be gated by server-side configuration (e.g., getCrashReportingEnabled())
  If the server config disables crash tracking, crashes should not be recorded.

General Information

On some platforms, the automatic detection of errors and crashes is possible. In this case, your SDK may report them to the Countly server, and this is also optional as with other similar functions. If a crash report is not sent, it won't be displayed on the dashboard under the Crashes section. Below are more information on crash reporting parameters that you may use in your SDK.

In regard to crashes, all information, except the app version and OS, is optional, but you should collect as much information about the device as possible to assure each crash may be more identifiable with additional data. You should also provide a way for users to log errors manually (for example, logging handled exceptions which are not fatal).

Complete Crash Metrics Fields - The following device/environment metrics should be collected and sent alongside the crash report where available:

// Core crash fields
_error        - stack trace string
_nonfatal     - boolean (true = handled, false = unhandled/fatal)
_logs         - breadcrumb logs concatenated with newlines
_custom       - custom crash segmentation (key/value pairs)
_run          - app run duration in seconds
_background   - whether the app was in background when crash occurred

// Device metrics (collected automatically)
_device       - device model name
_os           - operating system name
_os_version   - OS version
_resolution   - screen resolution
_app_version  - application version
_cpu          - CPU info
_opengl       - OpenGL/WebGL version
_root         - whether device is rooted/jailbroken
_ram_total    - total RAM
_ram_current  - available RAM at crash time
_disk_total   - total disk space
_disk_current - available disk space at crash time
_bat          - battery level
_orientation  - device orientation at crash time
_online       - network connectivity status
_muted        - whether device is muted (platform-specific)
_name         - crash name/title
_type         - crash type/category

// Platform-specific fields
_native_cpp       - boolean, true for NDK/native crash dumps (Android)
_binary_images    - binary image addresses/UUIDs for symbolication (iOS)
_build_uuid       - app build UUID (iOS)
_executable_name  - executable binary name (iOS)
_architecture     - CPU architecture (iOS)
_app_build        - app build number, separate from _app_version (iOS)
_plcrash          - flag for PLCrashReporter-originated reports (iOS)
_javascript       - always true (Web)
_not_os_specific  - always true (Web)
_view             - current view name at time of crash (Web)

Basically, for automatically captured errors, you should set the _nonfatal property to false, whereas on user logged errors the _nonfatal property should be true. You should also provide a way to set custom key/values to be reported as segments with crash reports, either by providing global default segments or setting separately for automatically tracked errors and user logged errors.

Additionally, there should be a way for the SDK user to leave breadcrumbs that would be submitted together with the crash reports. In order to collect breadcrumbs as logs, create an empty array upon initialization and provide a method to add breadcrumbs as strings into that array as elements for log. Also, in the event of a crash, concatenate the array with new line symbols and submit under the _logs property. There is no need to persistently save those logs on a device, as we would like to have a clean log on every app start. Exception: when using native crash reporters (e.g., PLCrashReporter on iOS), breadcrumbs must be persisted to file since in-memory data is lost during a native crash.

The end API could look like this (but it should be totally based on the specific platform error handling):

  • Countly.enable_auto_error_reporting(map segments)
  • Countly.log_handled_error(string title, string stack, map segments)
  • Countly.log_unhandled_error(string title, string stack, map segments)
  • Countly.add_breadcrumb(string log)

Networking and Params

Crash object that can be sent to the server is:

crash : all parameters (core crash fields, device metrics, platform specific fields) formes a flattened json object

Crash requests are sent to the "/i" endpoint.

Crash requests should be sent through the request queue.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'crash={"_os":"Android","_os_version":"4.1","_manufacture":"Samsung","_device":"Galaxy S4","_resolution":"1900x1080","_app_version":"2.1","_cpu":"armv7","_opengl":"2.1","_ram_current":1024,"_ram_total":4096,"_disk_current":3000,"_disk_total":10240,"_bat":99,"_orientation":"portrait","_root":false,"_online":true,"_muted":false,"_background":false,"_name":"Null Pointer exception","_error":"Some error stack here","_nonfatal":true,"_logs":"logs provided here","_run":180,"_custom":{"facebook_sdk":"3.5","admob":"6.5"}}' \
  --data ...remaining common params

Storage

Crash information is not stored persistently (in a way that would survive SDK shutdowns and further inits). A request is formed and it is stored as request in the request queue.

The feature depends on "crashes" consent.

Crash Filtering

There should be a way to filter and edit the crash report before sending it to the server. To do this, a callback should be registered while initializing the SDK.

config.crashes.setGlobalCrashFilterCallback(callback);
Countly.init(config);

This callback will take a "CrashData" object containing all crash-related data and modify or omit it. If "true" is returned from the callback, the crash must be omitted.

interface CrashFilterCallback {
    boolean filterCrash(CrashData crash);
}

The crash data object must contain:

  • Stack trace string that is concatenated with new lines.
  • List of breadcrumbs
  • Crash metrics
  • Crash segmentation
  • Crash Name (iOS only)
  • Crash Description (iOS only)
  • Whether or not a crash is unhandled (fatal)

While preparing "CrashData", SDK internal limits should be applied to the stack trace, crash segmentation, and breadcrumbs.

All of those attributes can be modified. "CrashData" must provide setters and getters to achieve that.

Setters mustn't accept invalid values like null, undefined, etc.

Also, there must be a way to report which fields have been modified for the server.

A metric for crashes will be calculated as an integer of "_ob" bits. If some fields changed while filtering, bits must be set as:

  • 01000000 for description -> "_ob" metric will be 64 (iOS only)
  • 00100000 for name -> "_ob" metric will be 32 (iOS only)
  • 00010000 for stack trace -> "_ob" metric will be 16
  • 00001000 for crash segmentation -> "_ob" metric will be 8
  • 00000100 for breadcrumbs -> "_ob" metric will be 4
  • 00000010 for crash metrics -> "_ob" metric will be 2
  • 00000001 for unhandled -> "_ob" metric will be 1

For example, if crash segmentation, unhandled, and stack trace is changed, the "_ob" metric must be 25.

Platform Note: Android uses 5 checksum slots (stack trace, crash segmentation, breadcrumbs, crash metrics, fatal) while iOS uses all 7 (adding name and description). The "_ob" calculation uses SHA256 checksums computed before and after filtering to detect changes. Web SDKs do not implement "_ob" calculation.

After the crash filtering is completed, SDK internal limits must be applied to the stack trace, crash segmentation, and breadcrumbs. Unsupported data types must also be removed from crash metrics.

The "_ob" metric calculation must not be affected by SDK internal limit changes. It must only indicate whether or not the developer modified that field.

The "_ob" metric must be appended to the crash metrics and sent to the server.

Events

Exposed Methods

Instance Methods

CountlyInstance.recordEvent(key: String)
CountlyInstance.recordEvent(key: String, count: int)
CountlyInstance.recordEvent(key: String, count: int, sum: double)
CountlyInstance.recordEvent(key: String, segmentation: Map<String, Object>)
CountlyInstance.recordEvent(key: String, segmentation: Map<String, Object>, count: int)
CountlyInstance.recordEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double)
CountlyInstance.recordEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double, dur: double)
CountlyInstance.startEvent(key: String)
CountlyInstance.cancelEvent(key: String)
CountlyInstance.endEvent(key: String)
CountlyInstance.endEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double)

Implementation Details

For instance method recordEvent

CountlyInstance.recordEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double, dur: double)

// Valid values
key is not nullable and not empty, non valid values are warned and call is omitted
segmentation is nullable, accepted value types: Integer, String, Double, Boolean, Arrays
count must be = 1, defaults to 1
sum defaults to 0
dur defaults to 0

// Logic
Records an event with the given parameters.
- A unique event ID is generated for each event
- Event key is truncated by the "maxKeyLength" internal limit
- Segmentation keys are truncated by "maxKeyLength"
- Segmentation string values are truncated by "maxValueSize"
- Segmentation entry count is limited by "maxSegmentationValues"
- Unsupported data types are removed from segmentation (only Integer, String, Double, Boolean, Arrays allowed)
- Before recording, any pending user profile data is saved
- If visibility tracking is enabled, a "cly_v" segmentation key is added (1=foreground, 0=background)
- If previous name recording is enabled, "cly_pen" (previous event name) and "cly_cvn" (current view name) are added
  (only for custom events, not internal events)
- A "peid" (previous event ID) is tracked and assigned to each custom event (not internal events).
  The peid is initialized to empty string and updated to the last custom event's ID after recording.
- "pvid" (previous view ID) is attached to view internal event.
- "cvid" (current view ID) is attached to ALL events (both custom and internal) except view internal event.
- Event key truncation and segmentation limits are only applied to custom events, not internal events
- Negative count values are corrected to 1 with a warning (not rejected)
- Event filtering via SDK Behavior Settings (blacklist/whitelist) is applied for custom events
- Custom event tracking can be disabled entirely via server config (getCustomEventTrackingEnabled)
- The event is added to the event queue with: key, segmentation, count, sum, dur, timestamp, hour, dow, eventID, pvid (previous view ID), cvid (current view ID)
- When event queue reaches the threshold, events are flushed to a request
- Internal events (views, feedback, push actions) check their own specific consent before recording
- Feedback widget events (NPS, Survey) are sent immediately (force flush after recording)

For instance method startEvent

CountlyInstance.startEvent(key: String)

// Valid values
key is not nullable and not empty

// Logic
Starts a timed event. The event key and start timestamp are stored.
- If an event with the same key is already running, the call is ignored and a warning is logged.
- The timed event runs until endEvent or cancelEvent is called for the same key.

For instance method endEvent

CountlyInstance.endEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double)

// Valid values
key is not nullable and not empty
segmentation is nullable
count defaults to 1
sum defaults to 0

// Logic
Ends a previously started timed event.
- Consent for "events" is checked; if not given, the call silently returns without recording
- The duration is calculated from the difference between start time and current time
- The event is recorded with the ORIGINAL start timestamp, hour, and day-of-week (not the end time)
- If no timed event with the given key exists, the call is ignored and a warning is logged
- The event is recorded with the calculated duration
- All timed events are cleared on device ID change (without merge) and SDK halt/reset

For instance method cancelEvent

CountlyInstance.cancelEvent(key: String)

// Valid values
key is not nullable and not empty

// Logic
Cancels a previously started timed event without recording it.
- If no timed event with the given key exists, the call is ignored and a warning is logged
- The timed event entry is removed from the timed events map

General Information

Events are the basic Countly data reporting tool that conveys something has happened in the app. Common examples are: someone clicked a button, performed a specific action, etc.

Events can be grouped under two categories:

  • Internal Events
  • Developer provided Events

Internal Events has a [CLY]_ prefix for its key and used for providing a specific kind of information (a feature) to the server. For example Views feature is reported as an internal Event and will be displayed at a unique section on the server (Analytics > Page Views).

Developer provided Events can be about any kind of information (non specific). So all developer provided Events will have an aggregate display section (Events > All Events).

An Event would have the following properties:

  • key - Mandatory, non empty string
  • count - Mandatory, integer, minimum 1
  • sum - Optional value, double, can be negative
  • dur - Optional value, double, minimum 0
  • segmentation - Optional, object with key-value pairs
  • id - Mandatory, generated internally, event id
  • pvid - Mandatory for view internal event, previous view id, tracked internally
  • cvid - Mandatory for all events except view internal event, current view id, tracked internally
  • peid - Mandatory for all event except view and widget internal events, previous event id, tracked internally
  • cly_v - Mandatory if enableVisibilityTracking called, will be added to segmentation, indicate app is in foreground or background, not added to the view internal end events,tracked internally
  • cly_pen - Mandatory if enablePreviousNameRecording called, will be added to segmentation, previous event name, only added to the custom events, tracked internally
  • cly_pvn - Mandatory if enablePreviousNameRecording called, will be added to segmentation, previous view name, only added to the view internal events, tracked internally
  • cly_cvn - Mandatory if enablePreviousNameRecording called, will be added to segmentation, current view name, only added to the custom events, tracked internally

More on Event formatting and information about feature could be found below documents:

Here are some examples:

User logged into the game:

{ key: 'login', count: 1 }

User completed a level in the game with a score of 500:

{ key: 'level_completed', count: 1, segmentation: { level: 2, score: 500 } }

User purchased something in the app worth 2.99 on the main screen:

{ key: 'purchase', count: 1, sum: 2.99, dur: 30, segmentation: { screen: 'main' } }

As you can imagine, your SDK should provide methods to cover these combinations, either by default values or by function parameter overloading, etc.:

  • Countly.events.recordEvent(string key)
  • Countly.events.recordEvent(string key, int count)
  • Countly.events.recordEvent(string key, double sum)
  • Countly.events.recordEvent(string key, double duration)
  • Countly.events.recordEvent(string key, int count, double sum)
  • Countly.events.recordEvent(string key, map segmentation)
  • Countly.events.recordEvent(string key, map segmentation, int count)
  • Countly.events.recordEvent(string key, map segmentation, int count, double sum)
  • Countly.events.recordEvent(string key, map segmentation, int count, double sum, double duration)

Note: count value defaults to 1 internally if not provided as it is mandatory.

Event Queue Threshold: The default event queue size threshold is 100 events across all SDKs. When the queue reaches this threshold, events are batched into a JSON array and combined into a request. This threshold can be overridden by the server via SDK Behavior Settings (eqs key). If the queue exceeds the batch size, only the threshold number of events are spliced into each request, leaving the rest for the next batch.

Event ID Generation: Each event gets a unique ID. The format varies by platform but typically consists of a random component plus a timestamp (e.g., iOS uses 6 random bytes base64-encoded + timestamp in ms; Web uses 8 hex chars + timestamp). The ID must be unique per event instance.

Immediate Flush Events: Certain internal events bypass the queue threshold and trigger an immediate flush: NPS events ([CLY]_nps), Survey events ([CLY]_survey), and Push action events ([CLY]_push_action). Star rating and other internal events do NOT trigger immediate flush.

Networking and Params

Event request structure is:

events : json array contains event objects
event object: {
    "key":"EVENT_NAME",
    "count":EVENT_COUNT,
    "sum":EVENT_SUM,
    "dur":EVENT_DUR,
    "segmentation":EVENT_SEGMENTATION, 
    "timestamp": EVENT_TIMESTAMP, 
    "hour": HOUR, 
    "dow": DoW,
    "id": EVENT_ID,
    ... other event related params
}

Event requests are sent to the "/i" endpoint.

Event requests should be sent through the request queue.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'events=[{"key":"EVENT_NAME","count":EVENT_COUNT,"sum":EVENT_SUM,"dur":EVENT_DUR,"segmentation":EVENT_SEGMENTATION}]' \
  --data ...remaining common params

Storage

Events are not persisted directly. They have their dedicated queue, see. They are only stored in memory and packed to the request queue. The default event queue size threshold is 100 events across all SDKs. When the queue reaches this threshold, events are batched into a JSON array and combined into a request then added to the request queue

The feature depends on "events" consent.

Internal Events

The call for recording events should support recording Countly internal events. If consent is required then the ability to record them should be governed only by their respective feature consents. The ability to record internal events is in no way influenced by the " event" consent. If consent for some feature is given and "recordEvent" is used to record that features internal event, it should be recorded even if no "event" consent is given. If for some feature consent is not given and "recordEvent" is used to record it's internal event, it should not be recorded even if "event" consent is given. If no consent is required, they should be recorded as well.

At the current moment there are the following internal events and their respective required consent:

  • [CLY]_nps - "feedback" consent
  • [CLY]_survey - "feedback" consent
  • [CLY]_star_rating - "star_rating" consent
  • [CLY]_view - "view" consent
  • [CLY]_orientation - "users" consent
  • [CLY]_push_action - "push" consent
  • [CLY]_action - "clicks" or "scroll" consent

Example 1:

  1. event consent is given, but 'view' consent is not given
  2. dev calls "recordEvent('[CLY]_view')
  3. event is not recorded

Example 2:

  1. event consent is given, and 'view' consent is also given
  2. dev calls "recordEvent('[CLY]_view')
  3. event is recorded

Example 3:

  1. event consent is not given, but 'view' consent is given
  2. dev calls "recordEvent('[CLY]_view')
  3. event is recorded

Timed Events

In short, you may report time with the dur property in an event. It is good practice to allow the user to measure some periods internally using the SDK API. For that purpose, the SDK needs to provide the methods below:

  • startEvent(string key) - which will internally save the event key and current timestamp in the associative array/map.
  • endEvent(string key, map segmentation, int count, double sum) - which will take the event starting timestamp by the event key from the map, get the current timestamp and calculate the duration of the event. It will then fill it up as a dur property and report an event, just as you would report any regular event.
  • endEvent(string key) - which will simply end the event with a 1 as the count, 0 as the sum, and nil as the segmentation values.

If possible, the SDK may provide a way to start multiple timed events with the same key, such as returning an event instance in the method and then calling the end method on that instance.

If not, the following calls should be ignored: 1. events which have already started 2. events which have attempted to start again 3. events which have already ended 4. events which have attempted to end. Otherwise, they will provide an informative error.

Device Metrics

Exposed Methods

Config Methods

CountlyConfig.setMetricOverride(metricOverride: Map<String, String>)

Instance Methods

CountlyInstance.recordMetrics(metricOverride: Map<String, String>)

Implementation Details

For config method setMetricOverride

CountlyConfig.setMetricOverride(metricOverride: Map<String, String>)

// Valid values
Function should not accept empty or null value

// Logic
It should accept values and add to an empty metric override map that SDK internally has. 
And in every metric retrieval, SDK should append metric overrides after forming metrics.

For instance method recordMetrics

CountlyInstance.recordMetrics(metricOverride: Map<String, String>)

// Valid values
Function should not accept empty or null value

// Logic
It should accept values and gather the SDK collected metrics and 
should apply metric overrides provided from configuration if any 
then metric overrides provided through function. 
After forming metrics, it should generate a request with common parameters
and a "metrics" parameter where SDK will attach formed metrics to there. 
Afterwards, SDK will add created request to the request queue.
- Requires "metrics" consent

General Information

Metrics can be reported together with the begin_session=1 parameter on every session start. Or it can have its dedicated call. Collect as many metrics as possible or allow some values to be provided by the user upon initialization. Possible metrics are listed in below documentations:

Complete Device Metrics List:

_device          - device model name
_os              - operating system name
_os_version      - OS version
_resolution      - screen resolution (e.g., "1080x1920")
_density         - screen density (e.g., "XHDPI" on Android, "@2x" on iOS)
_locale          - device locale (e.g., "en_US")
_app_version     - application version
_carrier         - mobile carrier name (deprecated on iOS 16.4+)
_store           - app store source (Android-specific)
_manufacturer    - device manufacturer (e.g., "Samsung")
_device_type     - device form factor: "mobile", "tablet", "smarttv", "desktop"
_has_hinge       - whether device is foldable (Android-specific)
_browser         - browser name (Web-specific, via User-Agent Client Hints)
_browser_version - browser version (Web-specific)
_ua              - user agent string (Web-specific, sent with every request)

One thing that we should agree on is identifying platforms with the same string overall SDKs, so here is the list of how we would suggest identifying platforms for the server through the _os metric.

  • Android - for Android
  • BeOS - for BeOS
  • BlackBerry - for BlackBerry
  • iOS - for iOS
  • Linux - for Linux
  • Open BSD - for Open BSD
  • os/2 - for OS/2
  • macOS - for Mac OS X
  • QNX - for QNX
  • Roku - for Roku
  • SearchBot - for SearchBots
  • Sun OS - for Sun OS
  • Symbian - for Symbian
  • Tizen - for Tizen
  • tvOS - for Apple TV
  • Unix - for Unix
  • Unknown - if the operating system is unknown
  • watchOS - for Apple Watch
  • Windows - for Windows
  • Windows Phone for Windows Phone

Networking and Params

Manual metrics request is formed like:

metrics : metrics formed like json object

Metrics requests are sent to the "/i" endpoint.

Manual metrics requests should be sent through the request queue.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'metrics={"_device":"name of the device","_os":"device OS","_os_version":"device OS version","_resolution":"resolution of the device/application","_app_version":"application version","_manufacturer":"device manufacturer","_carrier":"device carrier","_orientation":"device orientation","_has_hinge":"device has hinge sensor"}' \
  --data ...remaining common params

# and if you are sending with begin session add following parameter
# --data 'begin_session=1'

Storage

Metrics are not stored. They are fetched from device on demand. Only metric overrides provided from configuration are stored in memory.

Consent

Only manual recording metrics require "metrics" consent.

Sessions

Exposed Methods

Config Methods

CountlyConfig.enableManualSessionControl()
CountlyConfig.enableManualSessionControlHybridMode()
CountlyConfig.setUpdateSessionTimerDelay(delay: int)

Instance Methods

CountlyInstance.beginSession()
CountlyInstance.updateSession()
CountlyInstance.endSession()

Implementation Details

For config method enableManualSessionControl

CountlyConfig.enableManualSessionControl()

// Logic
Enables manual session control. When enabled, the SDK does not automatically
handle beginning, updating, and ending sessions. The developer must call
beginSession, updateSession, and endSession manually.

For config method enableManualSessionControlHybridMode

CountlyConfig.enableManualSessionControlHybridMode()

// Logic
Enables hybrid manual session control mode. In this mode:
- The developer must manually call beginSession and endSession
- Session updates (updateSession) are handled automatically by the SDK
- This only works when manual session control is also enabled
- Calls to updateSession are ignored in hybrid mode

For instance methods beginSession / updateSession / endSession

CountlyInstance.beginSession()
CountlyInstance.updateSession()
CountlyInstance.endSession()

// Logic
These methods are only functional when manual session control is enabled.
If manual session control is not enabled, calls are ignored with a warning.

beginSession:
- Requires "sessions" consent and session tracking must be enabled via SDK behavior settings if disabled
- If a session is already running, the call is ignored and a health check metric is logged
- Prepares device metrics (with metric overrides if set)
- Saves any pending user profile data before sending
- Sends a begin_session request that includes: metrics, location data (if set), and session flag
- If orientation tracking is enabled, records the initial orientation
- Marks the session start timestamp for duration calculation
- Resets the "first view" flag for view tracking

updateSession:
- Requires "sessions" consent and session tracking enabled
- If no session is running, the call is ignored and a health check metric is logged
- Sends a session duration update with seconds elapsed since last update
- Saves any pending user profile data before sending
- Ignored in hybrid mode (SDK handles updates automatically)

endSession:
- Requires "sessions" consent
- If no session is running, the call is ignored and a health check metric is logged
- Calculates final session duration
- Sends an end_session request with the remaining duration

General Information

For SDK's to track use sessions, there are 3 kinds of calls:

  • begin_session - indicates that a session was just started or the app came to the foreground
  • end_session - indicates the the session ended or the app went to the background
  • session_duration (update) - indicates that the session is still continuing and records the elapsed time since the last update or begin_session call

SDK sessions can be managed in 2 ways:

  • automatically by the SDK (with sufficient integration) - session requests would be sent according to the app lifecycle together with automatic session update requests after periodic timeframes (60 seconds by default)
  • manually by the developer - session requests would be triggered by the developer. This includes also the periodic update requests

Networking and Params

Here is the API documentation for sessions:

Starting a Session

The SDK should then send the begin_session=1 param. This same request should also contain metrics parameters.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'begin_session=1' \
  --data 'metrics={...}' \
  --data ...remaining common params

Session Update

Each minute of the session should be extended by sending the session_duration param with the number of seconds that passed since the previous session request (begin_session or session_duration, whichever was last). It might look something like:

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'session_duration=60' \
  --data ...remaining common params

Ending a Session

The SDK should send the end_session=1 param, including the session_duration parameter with how many seconds passed since the last session request (begin_session or session_duration, whichever was last). It might look something like this:

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'end_session=1' \
  --data 'session_duration=60' \
  --data ...remaining common params

Here are a few example requests generated by different session lengths:

2 min 30 second session 30 second session
begin_session=1&metrics={...} // first begin session request
session_duration=60 // first update session request
session_duration=60 // second update session request
end_session=1&session_duration=30 // end session request

Storage

Session are not stored directly. They are packed as request and added to the request queue.

The feature depends on "sessions" consent.

Automatic Session Tracking

Automatic session tracking is the default behavior. The SDK manages the full session lifecycle, begin, periodic updates, and end, without any explicit calls from the developer. Sessions are tied to the platform's app lifecycle.

How It Works

For apps that have a visual component (not command line or server-side tracking), the SDK should track sessions when the app is visible and in the foreground:

  • Session begins when the app enters the foreground (first visible screen)
  • Session updates are sent periodically (every 60 seconds by default) while the app remains in the foreground
  • Session ends when the app goes to the background (no visible screens remain)

Returning to the foreground after being in the background starts a new session (subject to session cooldown behavior).

Lifecycle Integration

The SDK must integrate with the platform's app lifecycle to trigger session events correctly. Regardless of platform, the core principles are:

  • Hook into the platform's lifecycle observers to detect foreground/background transitions.
  • On platforms where multiple visual components have independent lifecycles, maintain an internal counter to track active components. A begin_session is sent only on 0 > 1 transition, and an end_session only on 1 > 0. Intermediate transitions must NOT trigger session events.
  • Where possible, register lifecycle callbacks automatically during initialization. Otherwise, the developer must forward lifecycle events to the SDK manually.
  • While a session is active, send periodic session_duration updates every 60 seconds (configurable, minimum 1 second). Updates report elapsed seconds since the last session request. The SDK may provide a configuration to disable update requests entirely, sending only begin and end.

Precautions

  • Component recreation events (e.g., screen rotation) should NOT trigger session begin/end, the internal counter handles this correctly.
  • If the SDK is initialized while the app is already in the foreground, it should detect this and send begin_session immediately.
  • Unbalanced lifecycle calls (counter going negative) should be ignored with an error log.
  • Consent withdrawal mid-session should end the session immediately. Consent granted while in foreground should begin one automatically.
  • Changing the device ID without merge should end the current session and start a new one.

Manual Session Tracking

Most official SDKs implement automatic session handling, meaning SDK users don't need to separately manage session calls. However, SDKs should provide a way to disable automatic session handling and allow developers to make session calls themselves.

Enabling Manual Mode

Manual session control is enabled through a configuration flag enableManualSessionControl during SDK initialization.

When enabled, the SDK will not automatically send any session requests. The developer is fully responsible for calling begin, update, and end session methods.

All manual session methods require "sessions" consent if consent is enabled.

Hybrid Mode

Some SDKs also support a hybrid mode that combines manual session control with SDK-managed periodic updates, enabled via configuration method enableManualSessionControlHybridMode.

In hybrid mode:

  • The developer manually calls beginSession() and endSession()
  • The SDK automatically sends periodic session_duration updates (every 60 seconds by default)
  • Manual calls to updateSession() are disabled and will be ignored

This is useful for apps that need explicit control over when sessions start and end but don't want to manage the periodic heartbeat themselves.

Precautions

  • Out-of-order calls: The SDK must handle invalid call sequences gracefully. Calling updateSession() or endSession() without a prior beginSession() should be silently ignored with a warning log. Calling beginSession() while a session is already running should also be ignored. These invalid sequences should be tracked via internal health monitoring.
  • No automatic lifecycle tracking: When manual mode is enabled, the SDK must not react to app lifecycle events (foreground/background) for session management. Activity lifecycle callbacks (Android) or app state observers should not trigger any session requests.
  • Developer responsibility for updates: In pure manual mode (non-hybrid), the developer is responsible for sending periodic updateSession() calls. If they forget, the server may record unusually long gaps between session activity. The recommended interval is 60 seconds, matching the automatic mode default.
  • First view tracking reset: When a session ends (manually or automatically), the SDK should reset the "first view" state so that the next session correctly identifies the first viewed screen.
  • Web platform considerations: On web, manual session mode is particularly useful for single-page applications (SPAs) where the developer wants fine-grained control over what constitutes a "session" beyond simple page visibility.

Session Cooldown

In some cases, it is difficult to know for sure if a session has ended, such as with web analytics when a user is leaving the page, and whether they will visit another page or not. This is why there is a small cooldown time of 15 seconds. If the end_session request is sent and then the begin_session request is sent within 15 seconds, it will be counted as the same session, and the session duration will extend this session instead of applying it to the new one.

This makes it easier to call the end_session on each page unload without worrying about starting a new session if the user visits another page.

If you don't need this behavior, simply pass the ignore_cooldown=true parameter to all the session requests and the server will not extend the session. Rather, it will always count it as a new session.

The 15-second cooldown is a default value and may be configured on the server, so don't rely on it being 15 seconds.

Content Zone

Content Builder is a feature that allows displaying server-driven content (HTML-based) to the user. The SDK periodically fetches content from the server and displays it as an overlay on top of the application. This feature is primarily available on mobile platforms (iOS, Android, Flutter, React Native).

Exposed Methods

Config Methods

CountlyConfig.content.setContentCallback(callback: ContentCallback)
CountlyConfig.content.setZoneTimerInterval(zoneTimerIntervalSeconds: int)

Instance Methods

CountlyInstance.enterContentZone()
CountlyInstance.exitContentZone()
CountlyInstance.refreshContentZone()
CountlyInstance.previewContent(contentId: String)

Implementation Details

For config method setContentCallback

CountlyConfig.content.setContentCallback(callback: ContentCallback)

// Valid values
Value is not nullable, when null value is given it warns

// Logic
Registers a global callback to be notified about content lifecycle events.
The callback is called when a content is completed or closed by the user.

The callback's signature is ContentCallback:

ContentCallback {
  void onContentCallback(ContentStatus contentStatus, Map<String, Object> contentData)
}

// Parameters
- contentStatus, one of the values: COMPLETED, CLOSED
  - COMPLETED: the user completed the content interaction
  - CLOSED: the user dismissed/closed the content
- contentData, associated data for the content event

For config method setZoneTimerInterval

CountlyConfig.content.setZoneTimerInterval(zoneTimerIntervalSeconds: int)

// Valid values
Must be greater than 15 seconds, values < 15 are ignored
Default value is 30 seconds

// Logic
Sets the interval for automatic content fetch requests while in a content zone.
The SDK will periodically check the server for new content at this interval.

For instance method enterContentZone

CountlyInstance.enterContentZone()

// Logic
Enables content fetching and updates for the user.
- If consent is not granted for "content", the call is omitted
- If temporary device ID is enabled, the call is omitted
- If already in a content zone (content is currently displayed), the call is omitted
- Starts a periodic timer that fetches content from the server at the configured interval
- On first call after SDK start, there is a 4 second initial delay if the SDK was
  just initialized (to allow the app to fully load)
- When content is received, it is displayed as an overlay on the current activity/view that should be always at the top
- After content is closed, there is a wait period of 2x zone timer interval before the next fetch

For instance method exitContentZone

CountlyInstance.exitContentZone()

// Logic
Disables content fetching and updates for the user.
- Stops the periodic content fetch timer
- Removes and destroys any currently displayed content overlay
- Resets the content zone state
- If consent is not granted for "content", the call is omitted

For instance method refreshContentZone

CountlyInstance.refreshContentZone()

// Logic
Triggers a manual refresh of the content zone.
- If the "rcz" (refresh content zone) SDK behavior setting is disabled, the call is omitted
- If content is already being displayed, the call is omitted
- Flushes the request queue first, then re-enters the content zone
- If consent is not granted for "content", the call is omitted

For instance method previewContent

CountlyInstance.previewContent(contentId: String)

// Valid values
contentId is not nullable and not empty

// Logic
Previews a specific content by its ID without starting periodic updates.
- Performs a one-time fetch for the given content ID
- The content_id and preview=true parameters are added to the request
- If consent is not granted for "content", the call is omitted
- If temporary device ID is enabled, the call is omitted
- If content is already being displayed, the call is omitted

Networking and Params

Content fetch requests consist of the following parameters:

method: this is always "queue"
resolution: JSON object with portrait and landscape screen dimensions
  {
    "p": {"w": portraitWidth, "h": portraitHeight},
    "l": {"w": landscapeWidth, "h": landscapeHeight}
  }
  Dimensions are in dp (density-independent pixels)
category: array of content categories (can be empty)
la: device language code (e.g., "en", "de")
dt: device type (e.g., "phone", "tablet")
content_id: (optional) specific content ID for preview
preview: (optional) "true" when previewing specific content

Content fetch requests are sent to the "/o/sdk/content" endpoint.

Content fetch requests are directly sent to the server, they will not be added to the request queue.

# Common way to fetch content
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk/content' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=queue' \
  --data 'resolution={"p":{"w":360,"h":640},"l":{"w":640,"h":360}}' \
  --data 'category=[]' \
  --data 'la=en' \
  --data 'dt=phone' \
  --data ...remaining common params

# Preview specific content
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk/content' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=queue' \
  --data 'resolution={"p":{"w":360,"h":640},"l":{"w":640,"h":360}}' \
  --data 'category=[]' \
  --data 'la=en' \
  --data 'dt=phone' \
  --data 'content_id=someContentId' \
  --data 'preview=true' \
  --data ...remaining common params

Response would look like this:

{
  "html": "<URL of content>",
  "geo": {
    "p": {
      "x": 0,
      "y": 0,
      "w": 360,
      "h": 640
    },
    "l": {
      "x": 0,
      "y": 0,
      "w": 640,
      "h": 360
    }
  }
}

The response contains "html" (the content to display) and "geo" (placement coordinates for portrait "p" and landscape "l" orientations). Coordinates x, y, w, h are in dp and must be converted to pixels using screen density. The response is validated by checking that both "geo" and "html" fields are present.

The feature depends on "content" consent.

If consent is revoked, the content zone is exited and any displayed content is removed.

Display Behavior

Content is displayed as a WebView overlay on top of the current activity/screen. The overlay supports two display modes:

  • IMMERSIVE (default) - Full screen content using total device dimensions
  • SAFE_AREA - Content respects device safe area insets (notch, status bar, etc.)

The overlay handles orientation changes by maintaining separate portrait and landscape configurations. It should be always at the top of all views. If pages changes, overlay should be affected by it.

Overlay should have a mechanism for WebView/iframe to be fully loaded. It should have a mechanism to await all content's resources (mainly js and css). If one of them fails to be loaded or 60 seconds passes while waiting to load content url (or its resources), content must be closed and fetching contents should continue to work. While waiting for content to be fully loaded, SDK should not block UI, underlying app must continue to work (buttons work, UI responsible).

Content overlay is not shown if a feedback widget is currently being displayed. After content is closed, there is a wait period (default ~1 minute based on timer interval) before the next content fetch occurs.

Content Communication Protocol

Content displayed in the overlay (WebView or iframe) communicates with the SDK bidirectionally using a message-passing protocol. This allows server-driven content to trigger SDK actions and receive layout updates from the host environment.

Messages FROM Content TO SDK

The content sends messages to the SDK to trigger actions. Each message is an object with one or more of the following fields:

// Close the content overlay
{
  "close": 1
}

// Open a URL in the device browser or a new tab
{
  "link": "https://example.com"
}

// Record one or more analytics events
{
  "event": [
    { "key": "eventName1", ... },
    { "key": "eventName2", ... }
  ]
}

// Resize the content overlay to new dimensions (overrides server geo)
{
  "resize_me": {
    "p": { "x": 0, "y": 0, "w": 360, "h": 400 },
    "l": { "x": 0, "y": 0, "w": 640, "h": 300 }
  }
}

Fields may be combined in a single message. The SDK processes all present fields. Coordinates in resize_me follow the same dp-based format as the server geo response. If close is gotten, other retrieved actions should be done first. Close should take effect as final action.

Messages FROM SDK TO Content

The SDK notifies content of environment changes so it can adapt its layout:

// Sent when the viewport or device orientation changes
{
  "type": "resize",
  "width": currentViewportWidth,
  "height": currentViewportHeight
}

Resize messages are debounced (typically ~200ms) to avoid flooding the content on rapid orientation changes.

Origin Validation

The SDK validates the origin of incoming messages against a configurable whitelist. The base SDK server URL is always included in the whitelist. Additional origins can be added at initialization time. Messages from unlisted origins are silently ignored.

Platform-Specific Request Parameters

In addition to the common parameters listed under Networking and Params, some platforms include additional fields in content fetch requests:

// Web Platform only
cly_ws: 1                          // Identifies request as a web SDK content request
cly_origin: window.location.origin // The origin of the page loading content

Device ID Change

On a device ID change without merge, the content zone is exited and any displayed content is removed.

View Tracking

Exposed Methods

Config Methods

CountlyConfig.views.enableAutomaticViewTracking()
CountlyConfig.views.enableAutomaticViewShortNames()
CountlyConfig.views.setAutomaticViewTrackingExclusions(exclusions: List)
CountlyConfig.views.setGlobalViewSegmentation(segmentation: Map<String, Object>)
CountlyConfig.views.setTrackOrientationChanges(enabled: boolean)
CountlyConfig.views.disableViewRestartForManualRecording()

Instance Methods

String CountlyInstance.startAutoStoppedView(viewName: String)
String CountlyInstance.startAutoStoppedView(viewName: String, viewSegmentation: Map<String, Object>)
String CountlyInstance.startView(viewName: String)
String CountlyInstance.startView(viewName: String, viewSegmentation: Map<String, Object>)
CountlyInstance.stopViewWithName(viewName: String)
CountlyInstance.stopViewWithName(viewName: String, viewSegmentation: Map<String, Object>)
CountlyInstance.stopViewWithID(viewID: String)
CountlyInstance.stopViewWithID(viewID: String, viewSegmentation: Map<String, Object>)
CountlyInstance.pauseViewWithID(viewID: String)
CountlyInstance.resumeViewWithID(viewID: String)
CountlyInstance.stopAllViews(viewSegmentation: Map<String, Object>)
CountlyInstance.addSegmentationToViewWithID(viewID: String, viewSegmentation: Map<String, Object>)
CountlyInstance.addSegmentationToViewWithName(viewName: String, viewSegmentation: Map<String, Object>)
CountlyInstance.setGlobalViewSegmentation(segmentation: Map<String, Object>)
CountlyInstance.updateGlobalViewSegmentation(segmentation: Map<String, Object>)

Implementation Details

For config method enableAutomaticViewTracking

CountlyConfig.views.enableAutomaticViewTracking()

// Logic
Enables automatic view tracking during SDK initialization.
- When enabled, the SDK hooks into the platform's lifecycle to detect screen/view changes
- Each screen transition automatically starts an auto-stopped view using the screen's
  identifier (e.g., Activity class name on Android, ViewController name on iOS)
- The previous auto-stopped view is automatically stopped when a new screen appears
- Automatic views use global view segmentation if set

For config method enableAutomaticViewShortNames

CountlyConfig.views.enableAutomaticViewShortNames()

// Logic
When automatic view tracking is enabled, this option controls the view name format.
- When enabled: uses the short/simple class name (e.g., "MainActivity")
- When disabled (default): uses the full qualified name (e.g., "com.example.MainActivity")
- Only affects automatically tracked views, not manually started views

For config method setAutomaticViewTrackingExclusions

CountlyConfig.views.setAutomaticViewTrackingExclusions(exclusions: List)

// Logic
Excludes specific screens/views from automatic view tracking.
- Excluded screens will not trigger automatic view start/stop events
- Only applies when automatic view tracking is enabled
- Useful for splash screens, loading screens, or internal framework screens

For config method setTrackOrientationChanges

CountlyConfig.views.setTrackOrientationChanges(enabled: boolean)

// Logic
Enables or disables orientation change tracking. Default is true.
- When enabled, orientation changes are tracked as "[CLY]_orientation" events
- Requires "users" consent (not "views" consent)
- The initial orientation is reported on session begin

For config method disableViewRestartForManualRecording

CountlyConfig.views.disableViewRestartForManualRecording()

// Logic
Prevents manually recorded views from restarting on foreground transitions.
- By default, all views (including manual) restart when the app returns to foreground
- When this is enabled, only automatically tracked views restart on foreground
- Manual views that were stopped on background will NOT be restarted

For instance method startAutoStoppedView

String CountlyInstance.startAutoStoppedView(viewName: String, viewSegmentation: Map<String, Object>)

// Valid values
viewName is not nullable and not empty, non valid values are warned and call returns null
viewSegmentation is nullable

// Logic
Starts a view that will be automatically stopped when a new view starts (auto-stopped view).
- Only one auto-stopped view can be active at a time
- When a new auto-stopped view starts, the previously active auto-stopped view is stopped
- Returns a unique view ID string
- A "[CLY]_view" internal event is recorded with segmentation:
  - "name": the truncated view name (truncated by maxKeyLength)
  - "visit": "1" (always set on view start)
  - "start": "1" (only on the first view in a session)
  - "segment": platform name (e.g., "Android", "iOS")
  - Plus global view segmentation and custom view segmentation merged
- Reserved segmentation keys ("name", "visit", "start", "segment") cannot be overridden by custom segmentation
- The view ID is used as the event ID for the view event
- Previous view ID and current view ID are tracked and sent with all events
- View segmentation is subject to internal limits (key length, value size, segmentation count)

For instance method startView

String CountlyInstance.startView(viewName: String, viewSegmentation: Map<String, Object>)

// Valid values
viewName is not nullable and not empty, non valid values are warned and call returns null
viewSegmentation is nullable

// Logic
Starts a manually managed view. Multiple manual views can be active simultaneously.
- Manual views are NOT auto-stopped when a new view starts
- They must be explicitly stopped using stopViewWithID or stopViewWithName
- Returns a unique view ID string

For instance method stopViewWithName

CountlyInstance.stopViewWithName(viewName: String, viewSegmentation: Map<String, Object>)

// Valid values
viewName is not nullable and not empty
viewSegmentation is nullable

// Logic
Stops a view by its name. If multiple views share the same name, the last one started is stopped.
- Duration is calculated from the time the view was started
- If no view with that name exists, the call is ignored and a warning is logged

For instance method stopViewWithID

CountlyInstance.stopViewWithID(viewID: String, viewSegmentation: Map<String, Object>)

// Valid values
viewID is not nullable and not empty
viewSegmentation is nullable

// Logic
Stops a view by its view ID (returned from startView/startAutoStoppedView).
- Duration is calculated in seconds: currentTimestamp - viewStartTimestamp
- If the view was paused (viewStartTimeSeconds == 0), duration is 0
- A "[CLY]_view" event is recorded with the calculated duration and no "visit" key (end event)
- Custom segmentation is merged with global view segmentation for the end event
- The view entry is removed from the active views map
- If no view with that ID exists, the call is ignored and a warning is logged
- Requires "views" consent and view tracking must be enabled via SDK behavior settings

For instance method pauseViewWithID

CountlyInstance.pauseViewWithID(viewID: String)

// Valid values
viewID is not nullable and not empty

// Logic
Pauses a running view. While paused, the view duration timer stops accumulating.
- The view remains in the active views map but does not count time
- If the view is already paused or does not exist, the call is ignored

For instance method resumeViewWithID

CountlyInstance.resumeViewWithID(viewID: String)

// Valid values
viewID is not nullable and not empty

// Logic
Resumes a previously paused view. The duration timer starts accumulating again.
- If the view is not paused or does not exist, the call is ignored

For instance method stopAllViews

CountlyInstance.stopAllViews(viewSegmentation: Map<String, Object>)

// Valid values
viewSegmentation is nullable

// Logic
Stops all currently running views, both auto-stopped and manually managed.
- Each view's duration is calculated individually
- The provided segmentation is added to each stopped view

For instance method addSegmentationToViewWithID

CountlyInstance.addSegmentationToViewWithID(viewID: String, viewSegmentation: Map<String, Object>)

// Valid values
viewID is not nullable and not empty
viewSegmentation is not nullable and not empty

// Logic
Adds or updates segmentation to an active view identified by its view ID.
- The segmentation is merged with the view's existing segmentation in memory
- It will be included when the view is stopped

For instance method addSegmentationToViewWithName

CountlyInstance.addSegmentationToViewWithName(viewName: String, viewSegmentation: Map<String, Object>)

// Valid values
viewName is not nullable and not empty
viewSegmentation is not nullable and not empty

// Logic
Adds or updates segmentation to an active view identified by its name.
- If multiple views share the same name, it updates the last one started
- The segmentation is merged with the view's existing segmentation in memory

For instance method setGlobalViewSegmentation

CountlyInstance.setGlobalViewSegmentation(segmentation: Map<String, Object>)

// Valid values
segmentation is not nullable

// Logic
Replaces the global view segmentation entirely.
- Global segmentation is automatically added to all view events
- Overwrites any previously set global view segmentation

For instance method updateGlobalViewSegmentation

CountlyInstance.updateGlobalViewSegmentation(segmentation: Map<String, Object>)

// Valid values
segmentation is not nullable

// Logic
Merges the provided segmentation with the existing global view segmentation.
- New keys are added, existing keys are updated with new values
- Keys not in the provided map remain unchanged

General Information

Reporting views allows you to analyze which views/screens/pages were visited by the app user and how long they spent on each. If it is possible to automatically determine when a user visits a specific view on your platform, the SDK should provide an option to automatically track views. Manual view tracking must also be available.

View ID Tracking (pvid/cvid): The SDK maintains a currentViewID and previousViewID internally. When a new view starts, the current view ID becomes the previous view ID. View events include a pvid (previous view ID) field. Non-view events (crashes, custom events, etc.) include a cvid (current view ID) field to track which view the event occurred in.

First View Flag: The "start": "1" segmentation key is only set on the first view recorded in a session, and only if a session is currently running. This flag is reset when a new session begins.

Networking and Params

View data is sent as events through the standard event pipeline. Views use the internal event key [CLY]_view and are included in regular event requests to the "/i" endpoint. No separate view-specific requests are made.

Starting a View

When a view is entered, the SDK sends a view event with visit and segmentation data. The start key is set to 1 only for the first view in a session.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'events=[{"key":"[CLY]_view","count":1,"segmentation":{"name":"VIEW_NAME","visit":1,"view":"END_POINT","domain":"DOMAIN"},"timestamp":TIMESTAMP,"hour":HOUR,"dow":DAYOFTHEWEEK}]' \
  --data ...remaining common params

Ending a View (Duration)

When a view is exited, the SDK sends a view event with the calculated dur (duration in seconds) since the view was started.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'events=[{"key":"[CLY]_view","count":1,"dur":DURATION,"segmentation":{"name":"VIEW_NAME"},"timestamp":TIMESTAMP,"hour":HOUR,"dow":DAYOFTHEWEEK}]' \
  --data ...remaining common params

Storage

Active views are held in memory (not persisted to disk). If the app is terminated unexpectedly, any in-progress view durations are lost. Global view segmentation provided via configuration is stored in memory for the lifetime of the SDK instance.

The feature depends on "views" consent. Orientation tracking requires "users" consent, not "views" consent. If "views" consent is removed while views are running, all active views are stopped immediately.

Background/Foreground View Management

When the app goes to background, all running views should be stopped (with their accumulated duration) using a willStartAgain flag. When the app returns to foreground, views that were stopped with this flag are automatically restarted. The original start segmentation is preserved and re-applied on restart.

A config option disableViewRestartForManualRecording can be used to prevent automatic restart of manually recorded views on foreground transitions.

When automatic view tracking is enabled, manual startView/stopView calls may be rejected with a warning (platform-specific behavior).

Orientation Tracking

Orientation changes are tracked as a SEPARATE internal event with key [CLY]_orientation, NOT as part of view events. The event has a segmentation of "mode": "portrait" or "mode": "landscape". Orientation tracking requires "users" consent, not "views" consent. The initial orientation is sent on session begin.

View Structure

View information is packaged into events. There are 2 kinds of view events:

  • A start event to indicate a view was entered
  • An end event to report the duration and indicate the view was exited

All view events use the event key [CLY]_view, are sent with a "count" of 1, and require "views" consent.

Start event segmentation keys:

  • "name" - view name (e.g., "HomeScreen"). Truncated by maxKeyLength.
  • "segment" - SDK platform name (e.g., "Android", "iOS", "Web")
  • "visit" - always set to "1" on view start
  • "start" - set to "1" only for the first view in a session (requires a running session; flag resets when a new session begins)

End event fields:

  • "dur" - view duration in seconds (event-level field, not segmentation)
  • "name" - same view name as the start event
  • No "visit" or "start" keys on end events

View ID: Each view is assigned a unique ID set as the event-level "id" field (not a segmentation key). The same ID links the start and end events for a given view. The ID format is platform-dependent (typically base64-encoded random bytes + timestamp).

View ID context (pvid/cvid): View events carry a "pvid" (previous view ID) field. Non-view events carry a "cvid" (current view ID) field to associate them with the active view.

Reserved segmentation keys (cannot be overridden by custom segmentation): "name", "visit", "start", "segment", "bounce", "exit", "view", "domain", "dur".

A sample event for reporting the first view:

events=[
    {
        "key": "[CLY]_view",
        "id": "AbCdEf1234567890",
        "count": 1,
        "pvid": "",
        "segmentation": {
            "name": "HomeScreen",
            "segment": "Android",
            "visit": "1",
            "start": "1"
        }
    }
]

Sample event for reporting this view's duration:

events=[
    {
        "key": "[CLY]_view",
        "id": "AbCdEf1234567890",
        "count": 1,
        "dur": 30,
        "pvid": "",
        "segmentation": {
            "name": "HomeScreen",
            "segment": "Android"
        }
    }
]

View Manual Reporting

The following section will describe a sample implementation of manual views.

First, you will need to have 2 internal private properties as string lastView and int lastViewStartTime. Then, create an internal private method reportViewDuration, which checks if lastView is null, and if not, it should report the duration for lastView by calculating it based off the current timestamp and lastViewStartTime.

After those steps, provide a reportView method to set the view name as a string parameter inside this method call reportViewDuration to report the duration of the previous view (if there is one). Then set the provided view name as lastView and the current timestamp as lastViewStartTime. Report the view as an event with the visit property and segment as your platform name. Additionally, if this is the first view a user visits in this app session, then also report the start property as true. You will also need to call reportViewDuration with the app exit event.

After manual view tracking has been implemented, you may also implement automatic view tracking (if it is available on your platform). To implement automatic view tracking, you will need to catch your platform's specific event when the view is changed and call your implemented reportView method with the view name.

Additionally, you will need to implement enabling and disabling automatic view tracking, as well as status checking, despite whether automatic view tracking is currently enabled or not.

The pseudo-code to implement view tracking could appear as follows:

class Countly {
    String lastView = null;
    int lastViewStartTime = 0;
    boolean autoViewTracking = false;
    
    private void reportViewDuration(){
        if(lastView != null){
             //create event with parameters and 
             //calculating dur as getCurrentTimestamp()-lastViewStartTime
        }
    }
    
    void onAppExit(){
        reportViewDuration();
    }
    
   void onViewChanged(String view){
      if(autoViewTracking)
          reportView(view);
   }
    
    public void reportView(String name){
        //report previous view duration
        reportViewDuration();
        lastView = name;
        lastViewStartTime = getCurrentTimestamp();
        //create event with parameters without duration
       // duration will be calculated on next view start or app exit
    }
    
    public void setAutoViewTracking(boolean enable){
        autoViewTracking = enable;
    } 
    
    public boolean getAutoViewTracking(){
        return autoViewTracking;
    }
}

Additionally, if your platform supports actions on view, such as clicks, you may report them as well. Here is more information on reporting actions for views.

Device ID Management

Exposed Methods

Config Methods

CountlyConfig.setDeviceId(deviceId: String)
CountlyConfig.enableTemporaryDeviceIdMode() // rename suggestion: enableOfflineMode()
CountlyConfig.enableClearStoredDeviceId()

Instance Methods

CountlyInstance.setID(deviceId: String)
String CountlyInstance.getID()
DeviceIdType CountlyInstance.getType()
CountlyInstance.enableTemporaryIdMode() // rename suggestion: enableOfflineMode()

Advanced Instance Methods

CountlyInstance.changeWithoutMerge(deviceId: String)
CountlyInstance.changeWithMerge(deviceId: String)

Implementation Details

For config method setDeviceId

CountlyConfig.setDeviceId(deviceId: String)

// Valid values
deviceId is nullable. Null means the SDK will fall back to generating a UUID.

// Logic
Sets the device ID to use during SDK initialization.
- If a valid string is provided, the SDK uses it as a DEVELOPER_SUPPLIED device ID
- If null is provided, the SDK generates an SDK_GENERATED UUID
- This only takes effect during init. If a stored device ID already exists
  (from a previous session), the stored value takes precedence unless the
  "clear stored device ID" flag is set
- Has no effect if called after SDK initialization

For config method enableTemporaryDeviceIdMode (rename suggestion: enableOfflineMode)

This mode is functionally an offline mode, the SDK records all data locally but sends nothing to the server until a real device ID is provided. The name "temporary device ID mode" describes the implementation detail; "offline mode" better communicates the intent to integrators.

CountlyConfig.enableTemporaryDeviceIdMode() // rename suggestion: enableOfflineMode()

// Logic
Enables offline / temporary device ID mode at initialization time.
- When enabled (and no custom device ID is provided via config), the SDK
  starts in offline mode (temporary device ID mode)
- All requests created during this mode use a placeholder ID
  (e.g., "CLYTemporaryDeviceID") and are queued but NOT sent to the server
- Once a real device ID is provided at runtime (via setID, changeWithoutMerge,
  or changeWithMerge), the placeholder is replaced in all queued requests
  and they are sent
- If a custom device ID is also provided via setDeviceId, the custom ID
  takes precedence and offline mode is not entered

For config method enableClearStoredDeviceId

CountlyConfig.enableClearStoredDeviceId()

// Logic
Instructs the SDK to clear the persisted device ID during initialization.
- When enabled, the SDK performs the following during init:
  1. Retrieves the previously stored device ID (if any)
  2. Temporarily uses it to flush any pending events from the previous session
  3. Clears the stored device ID, device ID type, and session data from storage
  4. Proceeds with normal device ID acquisition (either from config or SDK-generated)
- If offline mode (temporary ID) was active in the previous session, the clearing
  logic is skipped to avoid sending orphaned offline data
- This effectively forces a fresh start: the next init will either use a
  config-provided device ID or generate a new one
- Useful for testing, privacy-sensitive flows, or forced user resets

For instance method setID

This is the recommended method for changing the device ID at runtime. It automatically determines whether to merge or not based on the current device ID type, so most developers do not need to think about merge semantics.

CountlyInstance.setID(deviceId: String)

// Valid values
deviceId is not nullable, null values are warned and ignored

// Logic
Sets the device ID, automatically selecting the correct merge behavior:
- If the current device ID type is DEVELOPER_SUPPLIED: changes without merge
  (treats it as a user switch, ends session, clears consent, starts fresh)
- If the current device ID type is SDK_GENERATED: changes with merge
  (associates the anonymous profile with the known user)
- If the provided ID is the same as the current ID, the call is silently ignored

For advanced instance method changeWithoutMerge

Advanced, most integrations should use setID() instead. Use this only when you explicitly need to force a non-merge device ID change regardless of the current device ID type.

CountlyInstance.changeWithoutMerge(deviceId: String)

// Valid values
deviceId is not nullable, null values are warned and ignored

// Logic
Changes the current device ID to the provided one without server-side merge.
- If the provided ID is the same as the current ID, the call is silently ignored
- If already in temporary ID mode and temp ID is provided, the call is silently ignored
- Flushes any pending user profile data before proceeding
- Ends the current session (if running and manual session control is NOT enabled)
- REMOVES ALL CONSENT without sending consent-removal requests to the server.
  This is critical: the consent-change source is marked as DeviceIDChangedNotMerged
  so that no consent requests are sent to server.
- Changes the stored device ID
- Clears remote config values (or marks as cached if caching enabled)
- Clears content zone state
- Clears all started timed events
- Begins a new session with the new device ID
- Triggers automatic remote config download if enabled
- Triggers SDK behavior settings fetch
- Notifies all modules of the device ID change (without merge)
- Does NOT send an old_device_id parameter (no merge on server)

For advanced instance method changeWithMerge

Advanced, most integrations should use setID() instead. Use this only when you explicitly need to force a merge regardless of the current device ID type.

CountlyInstance.changeWithMerge(deviceId: String)

// Valid values
deviceId is not nullable, null values are warned and ignored

// Logic
Changes the current device ID to the provided one with server-side merge.
- Changes the stored device ID
- If the provided ID is the same as the current ID, the call is silently ignored
- Sends a special request with "device_id=<new>" and "old_device_id=<old>"
  This tells the server to merge the old and new user profiles
- Does NOT end/restart the session (session continues with new ID)
- Does NOT remove consent (consent state is preserved)
- Triggers automatic remote config download if enabled
- Triggers SDK behavior settings fetch
- Notifies all modules of the device ID change (with merge)

For instance method getID

String CountlyInstance.getID()

// Logic
Returns the current device ID used by the SDK.

For instance method getType

DeviceIdType CountlyInstance.getType()

// Logic
Returns the type of the current device ID.
Possible values: DEVELOPER_SUPPLIED, SDK_GENERATED, TEMPORARY_ID

For instance method enableTemporaryIdMode (rename suggestion: enableOfflineMode)

Enters offline mode at runtime. Same behavior as the config-time equivalent, but can be triggered after initialization (e.g., on user logout).

CountlyInstance.enableTemporaryIdMode() // rename suggestion: enableOfflineMode()

// Logic
Puts the SDK into offline mode (temporary device ID mode).
- All requests are created with a special temporary device ID placeholder
- Requests are stored in the queue but NOT sent to the server
- Feedback widget presentation and remote config downloads are blocked
- Content zone enters/refreshes are blocked
- SDK behavior settings fetches are omitted
- Health check sending is omitted
- When a real device ID is later provided (via setID, changeWithoutMerge,
  or changeWithMerge): all queued requests have their temporary ID replaced
  with the real ID, then sent
- If the SDK initializes with a custom device ID while offline mode was active
  from a previous session, it exits offline mode automatically at init
- Replacement mechanism: iterate stored requests, find "&device_id=<tempPlaceholder>",
  replace with "&device_id=<realId>".
- On init, orphaned temp ID requests are cleaned up even if offline mode is not active

General Information

During the first SDK initialization, the SDK should acquire a device ID. It is a String value that will identify the user.

Device ID is either generated by the SDK or provided by the integrator as part of init configuration. The standard generation algorithm is UUID v4. If on a specific platform there are multiple ways a device ID could be generated, it should be possible to specify which method should be used.

To understand which configuration options take precedence during init, have a look at the below table.

Whatever value is acquired, it should be stored persistently. If another value was already acquired before, that one should be used unless the enableClearStoredDeviceId config flag is set. When enabled, the SDK clears the persisted device ID during init (after flushing any pending events) and proceeds with fresh device ID acquisition.

When acquiring a device ID, the SDK should take note of the source of the ID. It should know if it is SDK generated or provided by the developer.

Device ID State Management During Init

There are different state combinations possible during init. This table covers all possible combinations and should be looked as a "truth table" of how the SDK should function.

Stored Device ID Provided ID During Init Action Taken by SDK
Non-Temp ID Temp ID Custom ID Temp ID URL Provided 'clear Stored Device ID' flag is not set 'clear Stored Device ID' flag is set
First Init - - - SDK generates ID SDK generates ID
First Init - - Sets custom ID Sets custom ID
First Init - - Enters Temp mode Enters Temp mode
First Init - - Sets URL Provided ID Sets URL Provided ID
First Init - Sets custom ID Sets custom ID
First Init - Sets URL Provided ID Sets URL Provided ID
First Init - Sets URL Provided ID Sets URL Provided ID
First Init Sets URL Provided ID Sets URL Provided ID
- - -  - Uses Stored ID SDK generates ID
- - Uses Stored ID Sets custom ID
- - Uses Stored ID Enters Temp mode
- - - Uses Stored ID Sets URL Provided ID
-  - Uses Stored ID Sets custom ID

 
- - Uses Stored ID Sets URL Provided ID

 
- - Uses Stored ID Sets URL Provided ID

 
- Uses Stored ID Sets URL Provided ID
- - -  - Stays in Temp mode SDK generates ID
- - Sets custom ID,
exits Temp mode
Sets custom ID
- - Stays in Temp mode Enters Temp mode
- - - Sets URL provided ID,
exits Temp mode
Sets URL Provided ID
-  - Sets custom ID,
exits Temp mode
Sets custom ID
- - Sets URL provided ID, exits Temp mode Sets URL Provided ID
- - Sets URL provided ID, exits Temp mode Sets URL Provided ID
- Sets URL provided ID, exits Temp mode Sets URL Provided ID

Changing Device ID

In addition to initialization, developers may need to change the device ID while the app is running. For example, when an end-user signs out and another end-user signs in. The SDK must provide a way to change the device ID at any point while the app is running.

This change can be done with or without a server-side merge. If the new and current device ID are exactly the same, the SDK must ignore the call. If an invalid value (empty or null) is provided, the request is ignored with a warning.

Without Merging

Replaces the internally used device ID with the new one and uses it for all new requests, persistently storing it for further sessions. Steps:

  • Add currently recorded, but not queued, events to the request queue
  • End the current session
  • Clear all started timed-events
  • Change the device ID and store it persistently
  • Begin a new session with the new device ID

With Merging

Allows changing a device ID to an internal user ID while merging server-side data from the unauthenticated session. Steps:

  • Temporarily keep the current device ID
  • Change the device ID and store it persistently
  • Use the old_device_id API with the old device ID to merge the data on the server
  • No need to end/restart the current session or clear started timed-events

Retrieving the Current Device ID and Type

There should be a call that returns the currently used device ID (e.g., "GetDeviceId()") and another that returns the current device ID type (e.g., "GetDeviceIdType()").

There are 3 device ID types:
* SDK_GENERATED - generated by the SDK.
* DEVELOPER_SUPPLIED - provided by the developer, either during init or by changing the device ID after init.
* TEMPORARY_ID - the SDK is in offline mode (temporary ID mode).

Offline Mode (Temporary ID Mode)

Also known as "temporary device ID mode". The name "offline mode" is preferred as it better describes the behavior from the integrator's perspective: the SDK records all data locally but sends nothing to the server until a real device ID is provided.

When the SDK enters offline mode, all requests are queued under a temporary ID placeholder. Once a real device ID is acquired (via setID() or the advanced changeWithoutMerge/changeWithMerge methods), the placeholder is replaced in all queued requests and they are sent.

Storage

The device ID and its type must be stored persistently using the platform's preferred storage mechanism (e.g., SharedPreferences, UserDefaults, localStorage). On each subsequent initialization, the stored device ID takes precedence over any config-provided value (unless the "clear stored device ID" flag is set).

Device ID management itself does not require a specific consent feature group. However, changing the device ID without merge removes all granted consent without sending consent-removal requests to the server. This ensures the new user profile starts with a clean consent state. Changing the device ID with merge preserves the current consent state.

Push Notifications

Exposed Methods

Config Methods

CountlyConfig.push.setMessagingProvider(provider: MessagingProvider)
CountlyConfig.push.setTestMode(mode: PushTestMode)
CountlyConfig.push.setSendPushTokenAlways(enabled: boolean)
CountlyConfig.push.setNotificationActionHandler(handler: PushActionHandler)
CountlyConfig.push.setAllowedIntentTargets(allowList: List<String>)

Instance Methods

CountlyInstance.askForNotificationPermission()
CountlyInstance.askForNotificationPermission(options, completionHandler)
CountlyInstance.onTokenRefresh(token: String, provider: MessagingProvider)
CountlyInstance.recordPushNotificationToken()
CountlyInstance.clearPushNotificationToken()

Implementation Details

For config method setMessagingProvider

CountlyConfig.push.setMessagingProvider(provider: MessagingProvider)

// Logic
Sets the push messaging provider for token acquisition.
- On platforms with multiple push providers, specifies which service to use
- The provider value is sent to the server as "token_provider" in the token session request

For config method setTestMode

CountlyConfig.push.setTestMode(mode: PushTestMode)

// Valid values
mode: DEVELOPMENT (1), TEST_DISTRIBUTION (2), or PRODUCTION (0, default)

// Logic
Controls the push gateway environment.
- DEVELOPMENT (1): uses sandbox/development push gateway
- TEST_DISTRIBUTION (2): uses production gateway but marks as test
- PRODUCTION (0): default, uses production push gateway
- Sent as "test_mode" parameter in the token session request

For config method setSendPushTokenAlways

CountlyConfig.push.setSendPushTokenAlways(enabled: boolean)

// Logic
When enabled, the SDK sends the push token to the server even if the user has not
granted notification permission. This is useful for silent/data notifications that
do not require display permission. Default is false.

For config method setNotificationActionHandler

CountlyConfig.push.setNotificationActionHandler(handler: PushActionHandler)

// Logic
Sets a custom handler for notification action button URLs.
- The handler is called when a user taps a notification body or action button
- If the handler returns true, the SDK skips its default URL opening behavior
- If the handler returns false or is not set, the SDK opens the URL via the platform's
  default URL opening mechanism

For config method setAllowedIntentTargets

CountlyConfig.push.setAllowedIntentTargets(allowList: List<String>)

// Logic
Configures an allow list for push notification action targets.
- When set, the SDK enforces additional security checks before launching
  any component or URL from a push notification
- Only targets matching entries in the allow list are processed
- This mitigates exploits where a malicious notification payload could
  launch arbitrary components or redirect to untrusted destinations

For instance method askForNotificationPermission

CountlyInstance.askForNotificationPermission()
CountlyInstance.askForNotificationPermission(options, completionHandler)

// Logic
Requests notification permission from the user via the platform's permission system.
- On success, automatically registers for remote notifications and obtains a push token
- The overload with options allows specifying permission types (alert, badge, sound, etc.)
- Requires "push" consent if consent is enabled
- Once permission is granted, the token is automatically sent to the server

For instance method onTokenRefresh

CountlyInstance.onTokenRefresh(token: String, provider: MessagingProvider)

// Logic
Called when the push messaging service provides a new or refreshed token.
- The developer must call this from the platform's token refresh callback
- The token is sent to the server via a token session request
- Debounced: if the same token/provider is sent within 60 seconds, the call is ignored
- Requires "push" consent

For instance method recordPushNotificationToken / clearPushNotificationToken

CountlyInstance.recordPushNotificationToken()
CountlyInstance.clearPushNotificationToken()

// Logic
recordPushNotificationToken:
- Manually triggers sending the current push token to the server
- Useful after device ID changes or when token needs to be re-registered

clearPushNotificationToken:
- Sends an empty token string to the server, effectively unregistering the device
- The server will no longer send push notifications to this device

General Information

Push notifications are platform-specific. Not all platforms support them, but for those that do, the SDK must handle token acquisition, registration with the server, notification display, and action tracking.

The general push notification flow is:

  • Request notification permission from the user (where required by the platform)
  • Register with the platform's push service and obtain a device token
  • Send the token to the Countly server via a token session request
  • When a notification arrives, display it (if applicable) and handle user interactions
  • Record a [CLY]_push_action event when the user taps the notification or an action button

Push Notification Payload Structure

Push notification payloads from the Countly server include a container key "c" with Countly-specific data nested inside the platform's native push payload structure.

Countly payload keys (nested under "c"):

"i"  - Notification ID (required, used for action tracking)
"l"  - Default URL / deep link (opened on notification body tap)
"m"  - Media URL (image for rich notifications)
"a"  - Attachment URL (rich media attachment)
"b"  - Buttons array, each element containing:
       "t" - button title
       "l" - button link/URL

Example payload:

{
  "platform_envelope": {
    "alert": "Your message",
    "badge": 1,
    "sound": "default"
  },
  "c": {
    "i": "598e7e0f4d8c000001000001",
    "l": "https://example.com",
    "m": "https://example.com/image.jpg",
    "b": [
      { "t": "Accept", "l": "https://example.com/accept" },
      { "t": "Decline", "l": "https://example.com/decline" }
    ]
  }
}

Actioned Events

When a user interacts with a push notification (taps the body or an action button), the SDK records an internal event with the key [CLY]_push_action.

Event segmentation:

  • "i" - the notification ID (from c.i in the payload)
  • "b" - button index (0 for notification body tap, 1+ for action buttons)
  • "p" - platform identifier:
    • "a" - Android
    • "i" - iOS
    • "m" - macOS

After recording the action event, the SDK opens the associated URL (if any) via the platform's default URL handler. A small delay may be applied before opening the URL to ensure the action event is recorded first.

Action and URL Handling

When a user taps the notification body, the SDK extracts the default URL from c.l. When a user taps an action button, the SDK extracts the URL from the corresponding button entry in c.b.

If a custom action handler is configured via setNotificationActionHandler, it is invoked first. If the handler returns true, the SDK does not open the URL. Otherwise, the SDK opens it via the platform's default mechanism.

Rich Push / Media Attachments

When a media URL is present in the payload (c.m or c.a), the SDK should download the media and attach it to the notification before display. On platforms that support notification service extensions, the media download and attachment happens in the extension process.

Media download should have a reasonable timeout (e.g., 15 seconds) and retry logic (e.g., up to 3 attempts). If the download fails, the notification should still be displayed without the attachment.

Networking and Params

The push token is sent to the server via a dedicated token session request to the "/i" endpoint. This request is separate from begin_session.

Token Session Request

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'token_session=1' \
  --data 'PLATFORM_token=PUSH_TOKEN' \
  --data 'token_provider=PROVIDER' \
  --data 'locale=DEVICE_LOCALE' \
  --data 'test_mode=TEST_MODE' \
  --data ...remaining common params

Token session parameters:

  • token_session=1 - indicates this is a token registration request
  • PLATFORM_token - the platform-specific token key (e.g., android_token, ios_token)
  • token_provider - push provider (e.g., FCM, HMS)
  • locale - device locale
  • test_mode - push gateway mode: 0=Production, 1=Development, 2=Test distribution

Timing: The token session request should be sent with a short delay after begin_session (e.g., 10 seconds) to ensure the session request is processed first. The same token/provider combination should be debounced (e.g., 60-second minimum interval between identical token sends).

Clearing a token: To unregister a device, send a token session request with an empty token string.

Storage

The push token and provider type should be stored persistently so the SDK can resend the token on subsequent initializations if needed. Push action data that arrives before SDK initialization should be cached and processed once the SDK is ready.

The feature depends on "push" consent. All push operations (permission requests, token sending, action recording, notification display) require this consent. When push consent is granted at runtime, the SDK should attempt to acquire and send the push token immediately.

Recording Location

Exposed Methods

Config Methods

CountlyConfig.setLocation(countryCode, city, gpsCoordinates, ipAddress)
CountlyConfig.disableLocation()

Instance Methods

CountlyInstance.setLocation(countryCode, city, gpsCoordinates, ipAddress)
CountlyInstance.disableLocation(countryCode, city, gpsCoordinates, ipAddress)

Implementation Details

For config method setLocation:

CountlyConfig.setLocation(countryCode, city, gpsCoordinates, ipAddress)

// Valid values
All non empty String values are valid.
All nullable. Only non null, non empty String values are used.
Non valid values are warned and ignored.

// Logic
Overrides the internal location cache with given values.
Warns if city is not coupled with country.

For config method setDisableLocation:

CountlyConfig.disableLocation()

// Logic
Sets location tracking off (in a variable) regardless of setLocation.

For instance method setLocation:

CountlyInstance.setLocation(countryCode, city, gpsCoordinates, ipAddress)

// Valid values
All non empty String values are valid.
All nullable. Only non null, non empty String values are used. At least one non-null value should be provided.
Non valid values are warned and ignored. 
countryCode: ISO country code (e.g., "US", "DE")
city: city name (e.g., "New York")
gpsCoordinates: comma-separated latitude and longitude (e.g., "56.42345,123.45325")
ipAddress: IP address string (e.g., "192.168.88.33")

// Logic
Sets location parameters.
- If both city and country code are not provided together, a warning is logged
- If any non-null value is provided, location disabled flag is reset to false
- If begin_session was already sent OR session consent is not given OR session tracking is disabled:
  location is sent as a separate request immediately
- Otherwise: location values are stored and sent as part of the next begin_session request
- If the "lt" (location tracking) SDK behavior setting is disabled, the call is ignored
- Requires "location" consent
Can be called before and after init.
Before init: 
   - overrides the internal location cache with given values. 
After init: 
   - Re-enables location tracking (if disabled)
   - overrides the internal location cache with given values.
   - records a location request to request queue (omitting IP address) if:
      - (a session has started) and (auto sessions are on)
      - (no session consent given) or (auto sessions are off)
      - re-enabled location tracking 
Warns if city is not coupled with country.

For instance method disableLocation:

CountlyInstance.disableLocation()

// Logic
Disables sending of location data.
- Sets the location disabled flag to true
- Clears all stored location values (country code, city, GPS, IP)
- Records an empty location (location="") request to request queue
- Requires "location" consent; if consent is not given, the erasure request is still sent
  but the disable call itself is ignored
- During init, if location consent is not given, location erasure is performed automatically

General Information

SDKs should be able to send location information to the server. This information can include:

  • Country code
  • City name
  • Latitude and longitude values
  • IP address

Automatic Begin Session requests should behave like this:

1. Should add cached location information to itself if:
   - (location consent given) and (location tracking is on)
2. Should add location="" to itself if:
   - (no location consent) or (location tracking off)

SDK records an empty location (location="") request to request queue:

1. At the end of init if:
   - (no location consent or location tracking off) and (no session consent)

Consent removal/addition will have this implications:

Location consent removal:
   - records an empty location (location="") request to request queue
Location consent given:
   - no action

IP Address is only processed in the Server when recorded via CountlyConfig.setLocation and sent with begin session request. So any other record location calls will not trigger IP Address geolocation.

Networking and Params

Four location parameter that can be sent to server are:

country_code : Country code in the two-letter, ISO standard
city : City name
location : Latitude and longitude values separated by a comma, e.g. "56.42345,123.45325"
ip_address : IP address

Location requests are sent to the "/i" endpoint.

Location requests should be sent through the request queue.

Empty country code, city and IP address can not be sent (simply omitted).

# Common way to send all
curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'country_code=us' \
  --data 'city=panama' \
  --data 'location=56.42345,123.45325' \
  --data 'ip_address=0.0.0.0' \
  --data ...remaining common params

# Incase of empty location requests:
curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'location=' \
  --data ...remaining common params

Storage

Location information is not stored persistently (in a way that would survive SDK shutdowns and further inits). Location information is stored only in memory as variables. The SDK should cache only the latest location information meaning the provided values overwrite the previously cached values totally (meaning non valid/non provided values clears the cache for those values too.).

Consent

Feature depends on `Location` consent but `session` consent also affects its behavior.

Test scenarios

Some sample situations for handling location:

1) Dev Sets Location Sometime After Init
init without location
begin_session (without location)
setLocation(gps) (location request with gps)
end_session
begin_session (with location - gps)
end_session
begin_session (with location - gps)
end_session

2) Dev Sets Location During Init and a Separate Call
init with location (city, country)
begin_session (with location - city, country)
setLocation(gps) (location request with gps)
end_session
begin_session (with location - gps)
end_session
begin_session (with location - gps)
end_session

3) Dev Sets Location During Init and After begin_session Calls
init with location (city, country)
begin_session (with location - city, country)
setLocation(gps) (location request with gps)
end_session
begin_session (with location - gps)
setLocation(ipAddress) (location request with ipAddress)
end_session
begin_session (with location - ipAddress)
end_session

4) Dev Sets Location Before First begin_session
init with location (city, country)
setLocation(gps, ipAddress) (location request with gps, ipAddress)
begin_session (with location - gps, ipAddress)
setLocation(city, country, gps2) (location request with city, country, gps2)
end_session
begin_session (with location - city, country, gps2)
end_session

Heatmaps

Heatmaps is a Web SDK-only feature that is no longer receiving new updates. Existing functionality will continue to work, but no new enhancements or features will be added. The documentation below reflects the current stable implementation.

Exposed Methods

Config Methods

CountlyConfig.heatmaps.enableClickTracking()
CountlyConfig.heatmaps.enableScrollTracking()
CountlyConfig.heatmaps.setHeatmapWhitelist(domains: List<String>)

Instance Methods

CountlyInstance.trackClicks()
CountlyInstance.trackScrolls()

Implementation Details

For config method enableClickTracking

CountlyConfig.heatmaps.enableClickTracking()

// Logic
Enables automatic click tracking for heatmap data.
- Attaches a click event listener to the document
- Records each click as a "[CLY]_action" internal event with type "click"
- A 1-second cooldown is applied between tracked clicks to reduce traffic
- Captures: x, y coordinates, viewport width/height, and current view URL

For config method enableScrollTracking

CountlyConfig.heatmaps.enableScrollTracking()

// Logic
Enables automatic scroll tracking for heatmap data.
- Tracks the maximum scroll depth (y position) within each view
- Records a "[CLY]_action" internal event with type "scroll" when:
  - A new view is navigated to, or
  - The page/app is closed
- Only the maximum scroll depth is recorded, not every scroll event
- Captures: max y position, viewport width/height, and current view URL

For config method setHeatmapWhitelist

CountlyConfig.heatmaps.setHeatmapWhitelist(domains: List<String>)

// Valid values
List of domain URL strings, not nullable

// Logic
Sets a whitelist of trusted domains for loading heatmap overlay scripts.
- The Countly server URL is always included by default
- When the heatmap overlay is triggered, the SDK only loads scripts from whitelisted domains
- This prevents XSS attacks through malicious heatmap overlay payloads

General Information

Heatmaps is a plugin, exclusive to Web SDK, that can display the amalgamation of click and the scroll behavior of the users by attaching an overlay on the website that the Countly has integrated. Click and scroll behavior information is provided by the SDK and recorded as an event, internally, then sent into the event queue, if click or scroll tracking is enabled.

Automatic click and scroll tracking can be enabled during init or after init:

Asynchronous Synchronous
Countly.q.push(['track_scrolls']);
Countly.q.push(['track_clicks']);

Heatmap Overlay: A user should be able to go to the Heatmaps section in their server and click on a heatmap from the available list of views. When a user clicks any view from that list the server generates a token and directs the user to that view. The server provides the token and the necessary scripts to load as the heatmaps overlay, by setting the name property of the browser's window interface or the URL hash property to an SDK recognizable message which then should be parsed and used by the SDK to load the necessary scripts for the heatmaps overlay. The message starts with 'cly:' and includes 'app_key', 'token', 'purpose', and 'url' properties as a JSON object.

XSS Prevention: To prevent XSS attempts, the SDK should provide an option to the developer to give a list of trustable domains (a whitelist) for which the SDK would load the provided scripts from and would reject to load scripts from domains outside this list. By default the Countly server URL is included in this list. The list should be provided by the user during init, under the 'heatmap_whitelist' flag as an array of strings.

Asynchronous Synchronous
Countly.app_key = "YOUR_APP_KEY";
Countly.url = "https://try.count.ly";
Countly.heatmap_whitelist = ["https://you.domain1.com", "https://you.domain2.com"];

Tracking Clicks

A sample click event:

{
  "key":"[CLY]_action",
  "count":1,
  "segmentation":{
    "type":"click",
    "x":120,
    "y":200,
    "width":1920,
    "height":1200,
    "view":"https://sth.com"
  }
}

For click tracking it is better to set a cool-down period of 1 second after a click has been recorded to reduce the traffic before another click can be tracked.

Tracking Scrolls

A sample scroll event:

{
  "key":"[CLY]_action",
  "count":1,
  "segmentation":{
    "type":"scroll",
    "y":500,
    "width":1920,
    "height":1200,
    "view":"https://sth.com"
  }
}

Scroll height must be stored internally in memory and with each new scroll within the same view this value must be referred to again to find the max scroll height. When a new view happens or the site is closed this max value must be recorded as the 'y' value like shown. You would not record the 'x' value.

Networking and Params

Heatmap data is sent as events through the standard event pipeline. Both click and scroll events use the internal event key [CLY]_action and are included in regular event requests to the "/i" endpoint. No separate heatmap-specific requests are made.

Click event structure:

event: {
  "key": "[CLY]_action",
  "count": 1,
  "segmentation": {
    "type": "click",
    "x": X_COORDINATE,
    "y": Y_COORDINATE,
    "width": VIEWPORT_WIDTH,
    "height": VIEWPORT_HEIGHT,
    "view": "CURRENT_VIEW_URL"
  }
}

Scroll event structure:

event: {
  "key": "[CLY]_action",
  "count": 1,
  "segmentation": {
    "type": "scroll",
    "y": MAX_SCROLL_Y,
    "width": VIEWPORT_WIDTH,
    "height": VIEWPORT_HEIGHT,
    "view": "CURRENT_VIEW_URL"
  }
}

Storage

Heatmap data is not stored persistently. Click events are recorded and added to the event queue immediately. Scroll depth (maximum y position) is held in memory per view and flushed to the event queue on view change or page close.

Consent

Click tracking requires "clicks" consent. Scroll tracking requires "scrolls" consent. If consents are enabled, user actions should only be collected if the respective consent is provided, otherwise they should be ignored.

Remote Config

A/B testing and Remote Config are different features, but their SDK-related integration is interlinked.

The SDK's core utility for Remote Config and A/B testing is fetching some values from the server. Only the server decides which values to serve.

Remote Config

Downloaded remote config values have to be stored persistently.

Exposed Methods

Config Methods

CountlyConfig.enableRemoteConfigAutomaticTriggers()
CountlyConfig.enableRemoteConfigValueCaching()
CountlyConfig.remoteConfigRegisterGlobalCallback(callback: RCDownloadCallback)

Instance Methods

CountlyInstance.downloadOmittingKeys(keysToOmit: Array<String>, callback: RCDownloadCallback)
CountlyInstance.downloadSpecificKeys(keysToInclude: Array<String>, callback: RCDownloadCallback)
CountlyInstance.downloadAllKeys(callback: RCDownloadCallback)
Map<String, RCData> CountlyInstance.getValues()
RCData CountlyInstance.getValue(key: String)
CountlyInstance.registerDownloadCallback(callback: RCDownloadCallback)
CountlyInstance.removeDownloadCallback(callback: RCDownloadCallback)
CountlyInstance.clearAll()

Implementation Details

For config method enableRemoteConfigAutomaticTriggers

CountlyConfig.enableRemoteConfigAutomaticTriggers()

// Logic

Enables automatic download of the remote config values (in a variable)
This will trigger remote config values to be downloaded in those situations:
- After a device id change, values first cache cleared then downloaded again
- After exiting from temporary device id mode, values are downloaded
- After enrolling into a variant, values are cache cleared and downloaded again
- After remote config consent is given after init, values are downloaded
- After init is finished if we are not in the temporary id mode, values are downloaded

For config method enableRemoteConfigValueCaching

CountlyConfig.enableRemoteConfigValueCaching()

// Logic

Enables caching of remote config values. When this is enabled all remote-config values
are not deleted.

For config method remoteConfigRegisterGlobalCallback

CountlyConfig.remoteConfigRegisterGlobalCallback(callback: RCDownloadCallback)

// Valid values
Value is not nullable, when null value is given it warns

// Logic
Notifies the developer about remote config values update process. 
- If any error is encountered while downloading, the error is returned
- On a successful download, downloaded values are returned

The callback's signature is RCDownloadCallback. And its callback method is

RCDownloadCallback {
  void callback(RequestResult requestResult, String error, boolean fullValueUpdate, Map<String, RCData> downloadedValues)
}

// Parameters
- requestResult, one of the values Success, NetworkIssue, Error indicates result of the download
- error, error message. If it is null, it means there is no error
- fullValueUpdate, "true" - all values updated, "false" - a subset of values updated
- downloadedValues, the whole downloaded RC set, the delta

RCData will be mentioned in the Storage section.

For instance method downloadAllKeys

CountlyInstance.downloadAllKeys(callback: RCDownloadCallback)

// Valid values
All nullable, only non null values are accepted
Non valid values are warned and ignored

// Logic
Before preparing the request, the function will do some checks
- If no device id exists, the call is omitted. Given and registered callbacks are notified.
- If temporary device id enabled or request queue is containing temporary id requests, 
  Given and registered callbacks are notified.

After those checks, SDK will prepare the fetch request using the device metrics.
Then it will send the request immediately without adding it to the request queue.
- If any error encountered while request sent or retrieved, given and registered 
  callbacks are notified.

The function will parse the response and notify the given and registered callbacks.
And the remote config store is updated. We clear all values. If remote config value caching
is enabled we cache them rather than clearing them.

For instance method downloadOmittingKeys

CountlyInstance.downloadOmittingKeys(keysToInclude: Array<String>, callback: RCDownloadCallback)

// Valid values
All nullable, only non null and not empty values are accepted
Non valid values are warned and ignored

// Logic
- If keysToOmit is not empty, a parameter will be added to omit some keys to the remote 
config fetch request. The server will return all values except omitted ones.
- If the keysToOmit is empty, this function will behave like downloading all values.

We only update the keys in the storage that are changed.

For instance method downloadSpecificKeys

CountlyInstance.downloadSpecificKeys(keysToInclude: Array<String>, callback: RCDownloadCallback)

// Valid values
All nullable, only non null and not empty values are accepted
Non valid values are warned and ignored

// Logic
- If keysToInclude is not empty, a parameter will be added to include some keys to the remote 
config fetch request. Server will return only specified values.
- If the keysToInclude is empty, this function will behave like downloading all values.

We only update the keys in the storage that are changed.

For instance method getValues

Map<String, RCData> CountlyInstance.getValues()

// Logic
This will get all saved remote config values from the storage

For instance method getValue

RCData CountlyInstance.getValue(key: String)

// Valid values
Only non null and not empty key is accepted
If non valid key is given, the call will be omitted.

// Logic
This will get the remote config value of the given key if it exists

For instance method registerDownloadCallback

CountlyInstance.registerDownloadCallback(callback: RCDownloadCallback)

// Valid values
Only non null callback is accepted

// Logic
This will add the given callback to the internal callback list

For instance method removeDownloadCallback

CountlyInstance.removeDownloadCallback(callback: RCDownloadCallback)

// Valid values
Only non null callback is accepted

// Logic
This will remove given callback from the internal callback list

For instance method clearAll

CountlyInstance.clearAll()

// Logic
This will clear all remote config storage; caching is not important here

General Information

Remote Config allows app developers to change the behavior and appearance of their applications at any time by creating or updating custom key-value pairs on the Countly Server. The SDK fetches these values and stores them locally for the developer to use.

Downloaded remote config values must be stored persistently. When values are fetched, the behavior depends on the fetch type:

  • Full download (downloadAllKeys): clears all existing values (or caches them if caching is enabled) and replaces with the new set
  • Partial download (downloadSpecificKeys / downloadOmittingKeys): only updates the keys present in the response, leaving other stored keys unchanged

Remote config values are fetched via direct requests to the server (not through the request queue). This is because the developer typically needs the response immediately to configure the app's behavior.

Automatic triggers (when enabled) cause the SDK to re-download remote config values after events such as: device ID changes, exiting temporary ID mode, enrolling into a variant, consent being granted, and SDK initialization.

Value caching (when enabled) preserves old values when a full download occurs. Cached values are marked with isCurrentUsersData = false to indicate they may belong to a previous user. Fresh values are marked with isCurrentUsersData = true.

Networking and Params

Remote config fetch request might consist of 4 parameters:

keys: json array of specific keys
omit_keys: json array of omitted keys
method: this is always "rc"
metrics: this is session metrics and they are added if session consent is given

method parameters is always sent with remote config fetch request.

keys and omit_keys are optional.

Remote config fetch requests are sent to the "/o/sdk" endpoint (NOT /i).

Remote config fetch requests are directly sent to the server, they will not be added to the request queue.

# Common way to send all
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=rc' \
  --data 'keys=["url", "limit"]' \
  --data 'omit_keys=["money"]' \
  --data 'metrics={...}' \ # if session consent is given
  --data ...remaining common params

# No key parameters only required ones
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=rc' \
  --data 'metrics={...}' \ # if session consent is given
  --data ...remaining common params

Response would look like this:

{
  "key": "value",
  "key1": "value2"
}

Storage

Remote config values are stored persistently. And they should have a structure.

There might be a remote config key-value store structure that is keeping track of the remote config values.

Any key-value storage is accepted. For reference JSON could be used.

{
  "key": {
    "v": "value",
    "c": 0
  }
}

c parameter indicates cache. When a remote config value is cached, c value must be 0. 1 means it is fresh, new value.
RCData object contains two fields:

value: any type
isCurrentUsersData: boolean

isCurrentUsersData parameter is calculated like this; if the value is cached it is old user's data, false. If the value is fresh, just downloaded, then it is true.

Consent

The feature depends on "remote-config" consent, and "session" consent affects the remote config fetch request.

If consent is given, it will trigger remote config values to be downloaded if consents are enabled.

If consent is revoked and remote config storage is not cleared, they will stay as they are.

A/B Testing

Users can participate in AB tests. For that to happen, they must enroll in those tests (keys) with specific requests.

Exposed Methods

Config Methods

CountlyConfig.enrollABonRCDownload()

Instance Methods

Map<String, RCData> CountlyInstance.getAllValuesAndEnroll()
RCData CountlyInstance.getValueAndEnroll(key: String)
CountlyInstance.enrollIntoABTestsForKeys(keys: Array<String>)
CountlyInstance.exitABTestsForKeys(keys: Array<String>)
Map<String, String[]> CountlyInstance.testingGetAllVariants()
Map<String, ExperimentInformation> CountlyInstance.testingGetAllExperimentInfo()
String[] CountlyInstance.testingGetVariantsForKey(key: String)
CountlyInstance.testingDownloadVariantInformation(completionCallback: RCVariantCallback)
CountlyInstance.testingDownloadExperimentInformation(completionCallback: RCVariantCallback)
CountlyInstance.testingEnrollIntoVariant(keyName: String, variantName: String, completionCallback: RCVariantCallback)

Implementation Details

For config method enrollABonRCDownload

CountlyConfig.enrollABonRCDownload()

// Logic Enables automatic enrolling to the remote config keys while fetching. This will trigger adding a parameter to the remote config fetch request

For instance method getAllValuesAndEnroll

Map<String, RCData> CountlyInstance.getAllValuesAndEnroll()

// Logic
This will get all saved remote config values from the storage and
will call the enrollIntoABTestsForKeys method.

For instance method getValueAndEnroll

RCData CountlyInstance.getValueAndEnroll(key: String)

// Valid values
Only non null and not empty key is accepted
If non valid key is given, the call will be omitted.

// Logic
This will get the remote config value of the given key if it exists and
will call the enrollIntoABTestsForKeys method for the specified key if the key exist.

For instance method enrollIntoABTestsForKeys

CountlyInstance.enrollIntoABTestsForKeys(keys: Array<String>)

// Valid values
Only non null and not empty keys are accepted
Non valid keys are warned and ignored

// Logic
This call will enroll user to the AB tests for the specified keys.
- If keys are null or empty call will be omitted.
- If temporary device id enabled or request queue is containing temporary id requests or 
device id is not reachable at the moment function is called, call will be omitted. 

For instance method exitABTestsForKeys

CountlyInstance.exitABTestsForKeys(keys: Array<String>)

// Valid values
Keys could be nullable

// Logic
This call will exit user from the AB tests for the specified keys.
- If keys are null or keys are empty, it means exiting from all of AB tests.
- If temporary device id enabled or request queue is containing temporary id requests or 
device id is not reachable at the moment function is called, call will be omitted. 

For instance method testingGetAllVariants

Map<String, String[]> CountlyInstance.testingGetAllVariants()

// Logic
Before using this function, variants must be downloaded.
This function will return all variants.

For instance method testingGetAllExperimentInfo

Map<String, ExperimentInfo> CountlyInstance.testingGetAllExperimentInfo()

// Logic
Before using this function, experiment informations must be downloaded.
This function will return all experiment informations.

ExperimentInfo will be mentioned in the Storage section.

For instance method testingGetVariantsForKey

String[] CountlyInstance.testingGetVariantsForKey(key: String)

// Valid values
Only non null and not empty key is accepted
If non valid key is given, the call will be omitted.

// Logic
Before using this function, variants must be downloaded.
This function will return the variants for the given key.
- If no variants found for the specified key, null is returned.

For instance method testingDownloadVariantInformation

CountlyInstance.testingDownloadVariantInformation(completionCallback: RCVariantCallback)

// Valid values
Callback is nullable

// Logic
This function will download variants from the server.
- If temporary device id enabled or request queue is containing temporary id requests or 
device id is not reachable at the moment function is called, call will be omitted and callback
is notified.

After those checks, SDK will prepare the variant fetch request.
Then it will send the request immediately without adding it to the request queue.
- If any error encountered while request sent or retrieved, given callback is notified.

The function will parse the response and replace all variant with the fresh downloaded values.

The completionCallback's signature is RCVariantCallback. And its callback method is

RCVariantCallback {
  void callback(RequestResult requestResult, String error)
}

// Parameters
- requestResult, one of the values Success, NetworkIssue, Error indicates result of the download
- error, error message. If it is null, it means there is no error

For instance method testingDownloadExperimentInformation

CountlyInstance.testingDownloadExperimentInformation(completionCallback: RCVariantCallback)

// Valid values
Callback is nullable

// Logic
This function will download experiments from the server.
- If temporary device id enabled or request queue is containing temporary id requests or 
device id is not reachable at the moment function is called, call will be omitted and callback
is notified.

After those checks, SDK will prepare the experiment fetch request.
Then it will send the request immediately without adding it to the request queue.
- If any error encountered while request sent or retrieved, given callback is notified.

The function will parse the response and replace all experiments with the fresh downloaded values.

For instance method testingEnrollIntoVariant

CountlyInstance.testingEnrollIntoVariant(keyName: String, variantName: String, completionCallback: RCVariantCallback)

// Valid values
keyName and variantName are not nullable and not empty. 
completionCallback is nullable.
If non valid values are given they are warned and call will be omitted.

// Logic
This function do some checks before enrolling into specified variant
- If temporary device id enabled or request queue is containing temporary id requests or 
device id is not reachable at the moment function is called, call will be omitted and callback
is notified.

After those checks, SDK will prepare the enrolling into variant request.
Then it will send the request immediately without adding it to the request queue.
- If any error encountered while request sent or retrieved, given callback is notified.

If the request is successful, the function will trigger downloading remote config values
with clearing all remote config values from the storage. And the callback is notified with
a Success.

Networking and Params

If the configuration enrollABonRCDownload is called the SDK will add a parameter to the remote config values fetch request: oi=1

# a remote config fetch request
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=rc' \
  --data ...other remote config fetch request params if any \
  --data 'oi=1' \
  --data ...remaining common params

Here is a casual fetching all variants request, it does not have any other parameter than method:

# a variants fetch request
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=ab_fetch_variants' \
  --data ...remaining common params

And the response would look like this:

{
  "key1": [
    {
      "name": "variant1",
      "value": "value1"
    },
    {
      "name": "variant2",
      "value": "value2"
    }
  ],
  "key2": [
    {
      "name": "variant1",
      "value": "value1"
    }
  ]
}

Here is a casual fetching all experiment informations request, it does not have any other parameter than method:

# a experiment information fetch request
curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=ab_fetch_experiments' \
  --data ...remaining common params

And the response would look like this, this will return a json array:

[
  {
    "id": "experiment_id_1",
    "name": "Experiment Name 1",
    "description": "Description for Experiment 1",
    "currentVariant": "variant_A",
    "variants": {
      "variant_A": {
        "property_key_1": "property_value_1",
        "property_key_2": "property_value_2"
      },
      "variant_B": {
        "property_key_1": "property_value_3",
        "property_key_2": "property_value_4"
      }
    }
  },
  {
    "id": "experiment_id_2",
    "name": "Experiment Name 2",
    "description": "Description for Experiment 2",
    "currentVariant": "variant_B",
    "variants": {
      "variant_A": {
        "property_key_1": "property_value_5",
        "property_key_2": "property_value_6"
      },
      "variant_B": {
        "property_key_1": "property_value_7",
        "property_key_2": "property_value_8"
      }
    }
  }
]

Here is a casual enrolling into a variant request:

# an enrolling into a variant request
curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=ab_enroll_variant' \
  --data 'key=keyName' \
  --data 'variant=variantName' \
  --data ...remaining common params

Storage

AB Testing is not stored persistently. Variants and experiment informations are stored in memory in a key-value pair structure like Map.

Variants are stored in a string and string array structured map where key is remote config key name and value is variants name that is specified for that remote config key.

// Key-value structure
key1: [variant1, variant2]
key2: [variant3, variant4]

Experiment informations are stored in a string and ExperimentInfo structured map where key is experiment id and value is ExperimentInfo structure:

ExperimentInfo:
  experimentID: id of the experiment, string
  experimentName: name of the experiment, string
  experimentDescription: description of the experiment, string
  currentVariant: active variant for that experiment, string
  variants: variants for that experiment which is a string and string array key-value
    structure like key is remote config key name and value is variants name that is 
    specified for that remote config key

Consent

The feature depends on "remote-config" consent.

If consent is revoked, in memory values are not cleared.

User Feedback

The User Feedback feature provides three feedback widget types, NPS (Net Promoter Score), Survey, and Rating. Widgets are created on the Countly Dashboard and presented to users via WebView or custom UI. Legacy star rating and rating widget APIs are documented under the Star Rating section in Legacy Features.

Exposed Methods

Instance Methods

CountlyInstance.getAvailableFeedbackWidgets(callback: RetrieveFeedbackWidgets)
CountlyInstance.presentFeedbackWidget(widgetInfo: CountlyFeedbackWidget, callback: FeedbackCallback)
CountlyInstance.getFeedbackWidgetData(widgetInfo: CountlyFeedbackWidget, callback: RetrieveFeedbackWidgetData)
CountlyInstance.reportFeedbackWidgetManually(widgetInfo: CountlyFeedbackWidget, widgetData: JSONObject, widgetResult: Map<String, Object>)
CountlyInstance.presentNPS()
CountlyInstance.presentNPS(nameIDorTag: String)
CountlyInstance.presentNPS(nameIDorTag: String, callback: FeedbackCallback)
CountlyInstance.presentSurvey()
CountlyInstance.presentSurvey(nameIDorTag: String)
CountlyInstance.presentSurvey(nameIDorTag: String, callback: FeedbackCallback)
CountlyInstance.presentRating()
CountlyInstance.presentRating(nameIDorTag: String)
CountlyInstance.presentRating(nameIDorTag: String, callback: FeedbackCallback)

Implementation Details

For instance method getAvailableFeedbackWidgets

CountlyInstance.getAvailableFeedbackWidgets(callback: RetrieveFeedbackWidgets)

// Consent
Requires "feedback" consent. If not given, returns empty list with error.

// Preconditions
Blocked in temporary device ID mode — returns empty list immediately.

// Logic
Sends an immediate (non-queued) request to /o/sdk?method=feedback with common params.
Parses the JSON response "result" array into a list of CountlyFeedbackWidget objects.
Each object maps: _id > widgetId, type > type, name > name, tg > tags[], wv > widgetVersion.
Returns the list and null error on success, or empty list and error string on failure.

For instance method presentFeedbackWidget

CountlyInstance.presentFeedbackWidget(widgetInfo: CountlyFeedbackWidget, callback: FeedbackCallback)

// Consent
Requires "feedback" consent.

// Preconditions
widgetInfo must not be null. Blocked in temporary device ID mode.

// Logic
Constructs a WebView URL using the widget type, ID, and SDK params (see Constructing WebView URL).
For widgets WITHOUT wv (widget version):
  - Display in a legacy dialog with a native close button provided by the SDK.
  - custom JSON param: {"tc":1}
For widgets WITH wv:
  - Display in a fullscreen/transparent overlay where the webview handles its own close button.
  - custom JSON param: {"tc":1, "xb":1, "rw":1}
The WebView starts invisible; once the page loads, it becomes visible and interactive.
On close: dismiss the overlay and call onClosed().
On error: call onFinished(error).

For instance method getFeedbackWidgetData

CountlyInstance.getFeedbackWidgetData(widgetInfo: CountlyFeedbackWidget, callback: RetrieveFeedbackWidgetData)

// Consent
Requires "feedback" consent.

// Preconditions
Blocked in temporary device ID mode.

// Logic
Sends an immediate (non-queued) GET request to:
  /o/surveys/{type}/widget?widget_id=[widgetID]&shown=1&sdk_version=V&sdk_name=N&app_version=A&platform=P
The "shown=1" parameter causes the server to count this as a widget impression.
Parses the response and returns it as a JSON object via the callback.
Returns null data and error string on failure.

For instance method reportFeedbackWidgetManually

CountlyInstance.reportFeedbackWidgetManually(widgetInfo: CountlyFeedbackWidget, widgetData: JSONObject, widgetResult: Map<String, Object>)

// Consent
Requires "feedback" consent.

// Logic
If widgetResult is null:
  - Record a "closed" event: add "closed":"1" to the segmentation.
If widgetResult is not null:
  - Merge the contents of widgetResult into the event segmentation.

Event key is determined by widget type:
  - NPS:    "[CLY]_nps"
  - Survey: "[CLY]_survey"
  - Rating: "[CLY]_star_rating"

Base segmentation always includes: platform, app_version, widget_id.

// Validation
NPS rating values must be in range 0-10.
Rating values must be in range 1-5.
String values are truncated to the maxValueSize limit.

// Post-record
After adding the event to the event queue, force-flush the queue immediately
(combine into a request even if the event count is under the threshold).
This ensures the widget result reaches the server promptly.

For more information about manual feedback reporting, you can wander documentation below:

For instance method presentNPS / presentSurvey / presentRating

CountlyInstance.presentNPS()
CountlyInstance.presentNPS(nameIDorTag: String)
CountlyInstance.presentNPS(nameIDorTag: String, callback: FeedbackCallback)
// presentSurvey and presentRating follow the same overload pattern.

// Consent
Requires "feedback" consent.

// Logic
1. Calls getAvailableFeedbackWidgets to fetch the current widget list.
2. Filters the list by the target type (nps, survey, or rating).
3. If nameIDorTag is provided and non-empty:
   - Matches against widgetId, then name, then tags (first match wins).
4. If nameIDorTag is empty or not provided:
   - Selects the first widget of the matching type.
5. Calls presentFeedbackWidget with the matched widget and the provided callback.
6. If no matching widget is found, calls onFinished with an error message.

General Information

Three feedback widget types are supported:

  • NPS (Net Promoter Score): A 0-to-10 scale question with optional follow-up.
  • Survey: A multi-question form supporting various answer formats (multiple-choice, textbox, rating scales).
  • Rating: A 1-to-5 star rating with optional comment and email fields.

Each widget retrieved from the server is represented as a CountlyFeedbackWidget object containing: widgetId, type (nps/survey/rating), name, tags[], and widgetVersion (nullable, parsed from the "wv" field in the server response). When widgetVersion is present, the widget supports the newer fullscreen display mode; when absent, it uses the legacy dialog-based display.

Callback interfaces used throughout this feature:

  • RetrieveFeedbackWidgets — returns a list of CountlyFeedbackWidget objects and an error string.
  • RetrieveFeedbackWidgetData — returns a JSON object containing widget data and an error string.
  • FeedbackCallback — provides onFinished(String error) (called on error or successful completion; error is null if no issues) and onClosed() (called when the user dismisses the widget).

Feedback widgets can be used through three methods:

  • Automatic (server-rendered WebView): The SDK constructs a URL and displays the widget in a WebView. The server renders the widget UI.
  • Manual (custom UI + reportManually): The developer builds a custom UI using widget data and reports results via reportFeedbackWidgetManually.
  • Custom WebView URL: The SDK constructs the widget URL, and the developer displays it in their own WebView.

The convenience methods (presentNPS, presentSurvey, presentRating) fetch the widget list, filter by type, and optionally match by widget ID, name, or tag. If nameIDorTag is empty or not provided, the first matching widget of that type is presented.

All feedback widget operations are blocked when a temporary device ID is active — an empty list is returned and present/report calls are no-ops.

WebView Communication Protocol

When displaying feedback widgets via a WebView, the SDK and the widget page communicate through a URL interception mechanism. The widget page triggers navigation to special URLs that the SDK intercepts via the WebView client’s URL loading callback.

The base communication URL is:

https://countly_action_event

All communication URLs from the widget start with this base. The SDK should intercept any URL starting with this prefix, URL-decode it, parse the query parameters, and handle the appropriate action. There are two main command types:

1. Widget Command (close)

Used when the widget’s own close button is pressed (for versioned widgets with xb=1).

https://countly_action_event/?cly_widget_command=1&close=1

When this URL is intercepted, the SDK should:

  • Close/dismiss the WebView
  • Record a cancel event for the widget (event key [CLY]_nps, [CLY]_survey, or [CLY]_star_rating depending on widget type, with "closed":"1" in segmentation)
  • Notify the developer callback (onClosed)

2. Action Event Commands

Used for link navigation.

https://countly_action_event/?cly_x_action_event=1&action=[ACTION_TYPE]&...&close=1

The action parameter determines the action type. The close=1 parameter, when present, indicates the WebView should be dismissed after handling the action.

action=link - Open external URL

https://countly_action_event/?cly_x_action_event=1&action=link&link=[URL]

The SDK should open the provided URL in the system browser / default handler.

3. External Link Interception

In addition to the action=link command above, there is a separate mechanism for external links. Any URL that the WebView tries to navigate to that ends with the query parameter cly_x_int=1 should be intercepted and opened in the system browser application instead of loading within the WebView. This applies to any URL regardless of whether it starts with the communication base URL. For example:

https://example.com/some-page?cly_x_int=1

When the SDK detects a URL ending with cly_x_int=1 in the WebView’s URL loading callback, it should open the full URL in the device’s default browser and prevent the WebView from navigating to it.

4. Page Load Handling

The WebView should start as invisible and non-interactive (not touchable, not focusable). After the page is fully loaded (check document.readyState === ‘complete’ or listen for window load event), the SDK should:

  • Make the WebView visible and interactive
  • For widgets without wv (legacy): show in a dialog
  • For widgets with wv (versioned): enter immersive/fullscreen mode if applicable

If page loading takes 60 seconds or more, it should be treated as a failure and the WebView should be closed. Critical resource loading errors (js, css, png, jpg, jpeg, webp) or SSL errors should also trigger a failure close.

Constructing WebView URL

Constructing a WebView URL requires a CountlyFeedbackWidget object. Using the widget type and ID, the URL is constructed as follows:

// for NPS
/feedback/nps?widget_id=[widgetID]&device_id=[deviceID]&app_key=[appKey]&sdk_version=[sdkVersion]&sdk_name=[sdkName]&app_version=[appVersion]&platform=[platform]&custom=[customJSON]

// for Survey
/feedback/survey?widget_id=[widgetID]&device_id=[deviceID]&app_key=[appKey]&sdk_version=[sdkVersion]&sdk_name=[sdkName]&app_version=[appVersion]&platform=[platform]&custom=[customJSON]

// for Rating
/feedback/rating?widget_id=[widgetID]&device_id=[deviceID]&app_key=[appKey]&sdk_version=[sdkVersion]&sdk_name=[sdkName]&app_version=[appVersion]&platform=[platform]&custom=[customJSON]

The custom parameter is a JSON object that controls widget behavior. It always contains {"tc":1} to indicate support for terms and conditions. For widgets that have a wv (widget version) value, the following additional flags are added:

  • xb — set to 1. Indicates the webview handles its own close button, communicated back via the WebView Communication Protocol.
  • rw — set to 1. Indicates the webview will utilize fullscreen mode.

For a widget without a version: {"tc":1}
For a widget with a version: {"tc":1, "xb":1, "rw": 1}

Even if parameter tamper protection is enabled, this URL does not use the checksum param.

For widgets without a wv field, the URL can be shown in a native view with a native close button. For widgets with a wv field, it should be displayed in a fullscreen or transparent overlay where the webview handles its own close button via the WebView Communication Protocol.

Feedback widget operations require "feedback" consent.

Legacy star rating and rating widget operations require "star-rating" consent (see the Star Rating section under Legacy Features).

Networking and Params

1. Fetch widget list

Immediate request (not queued). Returns the list of available feedback widgets for the current device ID.

curl --request POST \
  --url ‘https://YOUR_SERVER/o/sdk’ \
  --header ‘Content-Type: application/x-www-form-urlencoded’ \
  --data ‘method=feedback’ \
  --data ...remaining common params

2. Fetch widget data

Immediate GET request (not queued). The shown=1 parameter causes the server to count this as a widget impression.

# type is one of: nps, survey, rating
curl --request GET \
  ‘https://YOUR_SERVER/o/surveys/{type}/widget?widget_id=WIDGET_ID&shown=1&sdk_version=SDK_VERSION&sdk_name=SDK_NAME&app_version=APP_VERSION&platform=PLATFORM’

3. Widget WebView URL

Not a network request per se — this URL is loaded in a WebView to display the widget.

https://YOUR_SERVER/feedback/{nps|survey|rating}?widget_id=WIDGET_ID&device_id=DEVICE_ID&app_key=APP_KEY&sdk_version=SDK_VERSION&sdk_name=SDK_NAME&app_version=APP_VERSION&platform=PLATFORM&custom={"tc":1}

4. Report widget result

Reported as an event via the standard /i endpoint. The event key depends on the widget type: [CLY]_nps, [CLY]_survey, or [CLY]_star_rating. The event is force-flushed from the queue immediately.

curl --request POST \
  --url ‘https://YOUR_SERVER/i’ \
  --header ‘Content-Type: application/x-www-form-urlencoded’ \
  --data-urlencode ‘events=[{"key":"[CLY]_nps","count":1,"segmentation":{"widget_id":"WID","platform":"PLATFORM","app_version":"VERSION","rating":8,"comment":"Great"}}]’ \
  --data ...remaining common params

Storage

Feedback widget lists and data are NOT cached. They are fetched fresh from the server every time getAvailableFeedbackWidgets or getFeedbackWidgetData is called. There is no persistent storage for feedback widget state.

Legacy star rating preferences are persisted separately (see the Star Rating section under Legacy Features).

User Profiles

Exposed Methods

Instance Methods

CountlyInstance.setProperty(key: String, value: Object)
CountlyInstance.setProperties(data: Map<String, Object>)
CountlyInstance.increment(key: String)
CountlyInstance.incrementBy(key: String, value: Number)
CountlyInstance.multiply(key: String, value: Number)
CountlyInstance.saveMax(key: String, value: Number)
CountlyInstance.saveMin(key: String, value: Number)
CountlyInstance.setOnce(key: String, value: String)
CountlyInstance.push(key: String, value: String)
CountlyInstance.pushUnique(key: String, value: String)
CountlyInstance.pull(key: String, value: String)
CountlyInstance.save()
CountlyInstance.clear()

General Information

User Profiles allows attaching identity and behavioral data to a device ID. Both predefined standard fields and arbitrary custom properties are supported. All changes are batched in memory and flushed to the server explicitly via save(), or automatically before session and event requests.

All fields are optional. If a null value is provided for any property, the SDK ignores it and logs a warning. Setting a property to an empty string signals deletion — the SDK sends a JSON null for that key so the server removes it.

Custom properties support MongoDB-style modifier operations (increment, multiply, array push/pull, etc.) that are applied server-side. Multiple modifier operations on the same key are accumulated locally and sent together on save().

Implementation Details

For instance method setProperty

CountlyInstance.setProperty(key: String, value: Object)

// Valid values
key is not nullable or empty
value: String, Number, or null (null is ignored with a warning; empty string signals deletion)

// Logic
Queues a single property change. Delegates to setProperties() internally.
Predefined keys are stored at the top level; all other keys are stored under "custom".

For instance method setProperties

CountlyInstance.setProperties(data: Map<String, Object>)

// Valid values
data is not nullable
Keys: predefined field names or custom property keys
Values: String, Number, boolean, or null/empty string (see logic)

// Predefined keys
"name"         - full name (String)
"username"     - username/handle (String)
"email"        - email address (String)
"organization" - company or org name (String)
"phone"        - phone number (String)
"picture"      - URL to profile picture (String, limit: 4096 chars)
"gender"       - gender (String, e.g. "M", "F")
"byear"        - birth year (Integer)

// Logic
Queues multiple property changes in a single call.
- Predefined string values are truncated to the maxValueSize limit (256 chars by default),
  except "picture" which allows up to 4096 chars
- Null values are ignored with a warning
- Empty string value sends JSON null for that key, deleting the property on the server
- Custom properties are stored under a nested "custom" JSON object
- Changes are NOT sent until save() is called (or triggered automatically)
- User property keys are validated against upb/upw filter lists before queuing

For instance method increment

CountlyInstance.increment(key: String)

// Logic
Increments the numeric value of a custom property by 1.
Wire modifier: $inc with value 1

For instance method incrementBy

CountlyInstance.incrementBy(key: String, value: Number)

// Valid values
value is numeric (integer or double)

// Logic
Increments the numeric value of a custom property by the given amount.
Wire modifier: $inc with the given value

For instance method multiply

CountlyInstance.multiply(key: String, value: Number)

// Valid values
value is numeric (integer or double)

// Logic
Multiplies the numeric value of a custom property by the given amount.
Wire modifier: $mul

For instance methods saveMax / saveMin

CountlyInstance.saveMax(key: String, value: Number)
CountlyInstance.saveMin(key: String, value: Number)

// Logic
Saves the value only if it is greater (saveMax) or less (saveMin) than the current
server-side value. Useful for tracking high scores or minimums.
Wire modifiers: $max / $min

For instance method setOnce

CountlyInstance.setOnce(key: String, value: String)

// Logic
Sets the value only if the property does not already exist on the server.
Has no effect if the property is already set.
Wire modifier: $setOnce

For instance method push

CountlyInstance.push(key: String, value: String)

// Logic
Appends a value to an array property, allowing duplicates.
Multiple push calls on the same key before save() accumulate into an array.
Wire modifier: $push

For instance method pushUnique

CountlyInstance.pushUnique(key: String, value: String)

// Logic
Appends a value to an array property only if it is not already present (set semantics).
Multiple pushUnique calls on the same key before save() accumulate into an array.
Wire modifier: $addToSet

For instance method pull

CountlyInstance.pull(key: String, value: String)

// Logic
Removes all occurrences of a value from an array property.
Multiple pull calls on the same key before save() accumulate into an array.
Wire modifier: $pull

For instance method save

CountlyInstance.save()

// Logic
Flushes all queued user property changes to the server.
- Serializes predefined and custom properties into a JSON object sent as "user_details"
- Custom modifiers ($inc, $mul, $max, $min, $setOnce, $push, $addToSet, $pull) are
  nested under the "custom" key within user_details
- After sending, the internal queue is cleared
- Requires "users" consent; omitted if consent is not given
- Also called automatically by the SDK before event recording and session begin/update

For instance method clear

CountlyInstance.clear()

// Logic
Discards all queued user property changes without sending them.
Does not affect properties already persisted on the server.

Internal limits:

Custom property key length:   truncated to maxKeyLength (default 128 chars)
Custom property value length: truncated to maxValueSize (default 256 chars)
  Exception: "picture" URL allows up to 4096 chars regardless of maxValueSize
User property cache limit:    max number of user property changes held in memory
  before a forced flush (default 100, configurable via server setting "upcl")

SDK Behavior Settings:

upb (user property blacklist) — String array, default: empty
  Property keys in this list are silently dropped before queuing.
  If both upb and upw are present, upb takes precedence.

upw (user property whitelist) — String array, default: empty
  Only property keys in this list are accepted; all others are silently dropped.
  Has no effect if upb is also set.

upcl (user property cache limit) — Integer, default: 100
  Maximum number of user property operations to accumulate in memory before
  triggering an automatic save().

The feature depends on "users" consent.

Orientation change tracking also requires "users" consent. If consent is revoked, orientation events stop being recorded.

Networking and Params

User profile data is sent as a single user_details parameter containing a JSON object. The request is added to the standard request queue and sent to the /i endpoint.

user_details: JSON object containing predefined fields and a nested "custom" object

Example (decoded):
{
  "name": "Jane Doe",
  "email": "jane@example.com",
  "byear": 1990,
  "custom": {
    "level": 5,
    "$inc": { "score": 10 },
    "$push": { "badges": "gold" },
    "$addToSet": { "tags": "beta" },
    "$pull": { "tags": "alpha" }
  }
}
curl --request POST \
  --url ‘https://YOUR_SERVER/i’ \
  --header ‘Content-Type: application/x-www-form-urlencoded’ \
  --data ‘user_details={"name":"Jane Doe","email":"jane@example.com","custom":{"level":5}}’ \
  --data ‘...remaining common params’

Modifier operations ($inc, $mul, $max, $min, $setOnce, $push, $addToSet, $pull) are always nested under the custom key as sub-objects, where each modifier key maps to an object of { propertyKey: value } pairs. Array modifiers ($push, $addToSet, $pull) send their accumulated values as arrays.

Application Performance Monitoring

APM is no longer receiving new updates. Existing functionality will continue to work, but no new enhancements or features will be added. The documentation below reflects the current stable implementation.

Exposed Methods

Config Methods

CountlyConfig.apm.enableAppStartTimeTracking()
CountlyConfig.apm.enableForegroundBackgroundTracking()
CountlyConfig.apm.enableManualAppLoadedTrigger()
CountlyConfig.apm.setAppStartTimestampOverride(timestamp: long)

Instance Methods

CountlyInstance.startTrace(traceKey: String)
CountlyInstance.endTrace(traceKey: String, customMetrics: Map<String, Integer>)
CountlyInstance.cancelTrace(traceKey: String)
CountlyInstance.cancelAllTraces()
CountlyInstance.recordNetworkTrace(networkTraceKey: String, responseCode: int, requestPayloadSize: int, responsePayloadSize: int, requestStartTimestampMs: long, requestEndTimestampMs: long)
CountlyInstance.startNetworkRequest(networkTraceKey: String, uniqueId: String)
CountlyInstance.endNetworkRequest(networkTraceKey: String, uniqueId: String, responseCode: int, requestPayloadSize: int, responsePayloadSize: int)
CountlyInstance.setAppIsLoaded()
CountlyInstance.triggerForeground()
CountlyInstance.triggerBackground()

Implementation Details

For instance method startTrace

CountlyInstance.startTrace(traceKey: String)

// Valid values
traceKey is not nullable and not empty

// Logic
Starts a custom trace. The trace key and start timestamp are stored.
- If a trace with the same key is already running, the call is ignored and warned.

For instance method endTrace

CountlyInstance.endTrace(traceKey: String, customMetrics: Map<String, Integer>)

// Valid values
traceKey is not nullable and not empty
customMetrics is nullable

// Logic
Ends a previously started custom trace and records it.
- Duration is calculated in ms from start to end timestamp
- Trace key is truncated by maxKeyLength, then validated:
  - Max 2048 chars (truncated if longer)
  - Keys starting with '$' are warned (removed server-side)
- Custom metric key validation:
  - Max 32 chars per key (entries with longer keys are removed)
  - Keys starting with '$' are warned
  - Keys containing '.' are warned (dots removed server-side)
  - Null/empty keys or null values are removed
  - Must match pattern /^[a-zA-Z][a-zA-Z0-9_]*$/ server-side
- Reserved metric keys (response_time, response_payload_size, response_code,
  request_payload_size, duration, slow_rendering_frames, frozen_frames)
  are removed from custom metrics if provided
- Custom metrics are subject to maxKeyLength and maxSegmentationValues limits

For instance method recordNetworkTrace

CountlyInstance.recordNetworkTrace(networkTraceKey: String, responseCode: int,
  requestPayloadSize: int, responsePayloadSize: int,
  requestStartTimestampMs: long, requestEndTimestampMs: long)

// Valid values
networkTraceKey is not nullable and not empty

// Logic and validation
- responseCode must be between 100 and 599 inclusive; invalid values are set to 0
- requestPayloadSize and responsePayloadSize must be = 0; negative values set to 0
- If startTimestamp  endTimestamp, the values are swapped
- Trace key is truncated by maxKeyLength, then validated (max 2048 chars)
- The trace is sent as an immediate APM request to the server

For instance method cancelTrace

CountlyInstance.cancelTrace(traceKey: String)

// Logic
Cancels a previously started custom trace without recording it.

For instance method cancelAllTraces

CountlyInstance.cancelAllTraces()

// Logic
Cancels all currently running custom traces without recording them.

For instance method startNetworkRequest / endNetworkRequest

CountlyInstance.startNetworkRequest(networkTraceKey: String, uniqueId: String)
CountlyInstance.endNetworkRequest(networkTraceKey: String, uniqueId: String,
  responseCode: int, requestPayloadSize: int, responsePayloadSize: int)

// Valid values
Both networkTraceKey and uniqueId are not nullable and not empty

// Logic
Alternative to recordNetworkTrace for tracking network requests in two steps.
- Internally the trace is stored with a composite key: "traceKey|uniqueId"
- This allows tracking multiple concurrent requests to the same endpoint
- startNetworkRequest stores the start timestamp
- endNetworkRequest retrieves the start timestamp, calculates duration,
  then calls the same recordNetworkTrace logic (with all its validation)
- Same validation rules as recordNetworkTrace apply to the end call

For instance method setAppIsLoaded

CountlyInstance.setAppIsLoaded()

// Logic
Manually triggers the moment when the app has finished loading.
Only functional when enableManualAppLoadedTrigger is set in config.
Records the app start time from SDK init to this call.

For instance methods triggerForeground / triggerBackground

CountlyInstance.triggerForeground()
CountlyInstance.triggerBackground()

// Logic
Manually reports that the app has entered the foreground or background.
Used for platforms where app lifecycle detection is not automatic.
Records foreground/background duration for performance monitoring.

General Information

Countly server supports multiple metrics for performance monitoring. They are divided into 3 groups:

  • Custom traces — used to measure the performance of some running task. At the basic level, this function is similar to timed events. The default metric that gets tracked is the "duration" of the event. There should be a "startTrace" and "endTrace" call, which start and end the tracking. When ending the tracking, the developer has the option of providing additional metrics. Those metrics are String and Integer/Numeric pairs.
  • Network request traces — used to track network request performance including response time, payload sizes, and response codes. Can be recorded in one shot via recordNetworkTrace or in two steps via startNetworkRequest / endNetworkRequest.
  • Device traces — used to track device-level performance metrics such as app start time and foreground/background duration.

In some of the exposed functionality, developers can provide the metric key for identifying the tracked thing. There are some requirements that the key must meet. Those requirements will be enforced on the API endpoint, and if the key will be deemed invalid, the trace will be dropped. Therefore it's important to catch invalid keys and warn about them on the SDK side.

Metric keys have a maximum length of 32 characters.

Trace keys have a maximum length of 2048 characters.

Metric names can't start with "$" and they can't have "." in them. Trace names can't start with "$". Those values should still be accepted. But the SDK should print a warning that the server will strip those characters.

The following SDK internal limits apply to APM data. These are defined globally in the SDK Internal Limits section and enforced within this feature:

maxKeyLength (default: 128)
  Applies to: custom trace key names, custom metric keys.
  Keys exceeding this limit are truncated.

maxSegmentationValues (default: 100)
  Applies to: custom APM metrics passed to endTrace.
  If the developer provides more than this many custom metric entries,
  excess entries are dropped.

In addition to the global limits, APM enforces its own feature-specific limits:

Trace key length — max 2048 characters
  Trace keys exceeding this length are truncated. Keys starting with "$"
  trigger a warning (the server strips the character).

Metric key length — max 32 characters
  Custom metric keys exceeding this length cause the entry to be removed.
  Keys starting with "$" or containing "." trigger a warning.

Reserved metric keys — automatically removed if provided by the developer:
  response_time, response_payload_size, response_code,
  request_payload_size, duration, slow_rendering_frames, frozen_frames

Networking and Params

APM requests are sent to the "/i" endpoint. There is no batching functionality for APM requests, every APM data point or trace is put in the request queue immediately after it is acquired. That means after a network request finishes, after a custom trace ends, and each time the device transitions between foreground and background.

APM data is combined into a single JSON object and set on the "apm" param. APM requests should be sent through the request queue.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'apm={"type":"device","name":"app_start","apm_metrics":{"duration":3200},"stz":1584698900000,"etz":1584699100000}' \
  --data ...remaining common params

Sample custom trace request:

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'apm={"type":"device","name":"forLoopProfiling_1","apm_metrics":{"duration":10,"memory":200},"stz":1584698900000,"etz":1584699900000}'
  --data ...remaining common params

Sample network trace request:

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'apm={"type":"network","name":"/count.ly/about","apm_metrics":{"response_time":1330,"response_payload_size":120,"response_code":300,"request_payload_size":70},"stz":1584698900000,"etz":1584699900000}'
  --data ...remaining common params

Sample device trace request:

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'apm={"type":"device","name":"app_start","apm_metrics":{"duration":15000},"stz":1584698900,"etz":1584699900}'
  --data ...remaining common params

Storage

APM data is not stored persistently beyond the standard request queue. Once a trace ends or a network request completes, the APM payload is formed immediately and added to the request queue as a regular queued request. No separate APM-specific persistence layer is used. In-progress custom traces (started but not yet ended) are held in memory only and are lost if the SDK is shut down before endTrace is called.

Consent

The feature depends on "apm" consent.

User Consent

Exposed Methods

Config Methods

CountlyConfig.setRequiresConsent(shouldRequireConsent: boolean)
CountlyConfig.setConsentEnabled(featureNames: String[])

Instance Methods

CountlyInstance.giveConsent(featureNames: String[])
CountlyInstance.removeConsent(featureNames: String[])
CountlyInstance.giveConsentAll()
CountlyInstance.removeConsentAll()
boolean CountlyInstance.getConsent(featureName: String)
CountlyInstance.setConsent(featureNames: String[], isConsentGiven: boolean)
CountlyInstance.createFeatureGroup(groupName: String, features: String[])
CountlyInstance.setConsentFeatureGroup(groupName: String, isConsentGiven: boolean)
CountlyInstance.checkAllConsent()

Implementation Details

The full list of valid consent feature names is:

sessions, events, views, location, crashes, attribution, users, push,
star-rating, remote-config, apm, feedback, clicks, scrolls, content, metrics, forms

// IMPORTANT: Wire-format names use HYPHENS, not camelCase:
// "star-rating" (not "starRating"), "remote-config" (not "remoteConfig")

For config method setRequiresConsent

CountlyConfig.setRequiresConsent(shouldRequireConsent: boolean)

// Logic
Enables consent enforcement for the SDK.
- When true: all features are initialized to consent=false and will not function
  until explicit consent is given
- When false (default): all getConsent() calls return true silently and the SDK
  operates without consent checks

For config method setConsentEnabled

CountlyConfig.setConsentEnabled(featureNames: String[])

// Valid values
featureNames must contain valid feature name strings (see list above)

// Logic
Provides initial consent during SDK initialization.
- Equivalent to calling giveConsent() immediately after init
- Features not listed start with consent=false when requiresConsent is true

For instance method giveConsent

CountlyInstance.giveConsent(featureNames: String[])

// Valid values
featureNames must contain valid feature name strings; groups are also accepted

// Logic
Gives consent for one or more features.
- Accepts a single feature name, an array, or variable arguments
- If consent is already given for a feature, the call is ignored for that feature
- The SDK immediately begins collecting data for the consented features
- A consent update request is queued to notify the server

For instance method removeConsent

CountlyInstance.removeConsent(featureNames: String[])

// Valid values
featureNames must contain valid feature name strings; groups are also accepted

// Logic
Removes consent for one or more features.
- Accepts the same parameter options as giveConsent
- If consent was not given for a feature, the call is ignored for that feature
- The SDK immediately stops collecting data for the affected features
- A consent update request is queued to notify the server
- Depending on SDK structure, existing queued requests may be kept or discarded

For instance methods giveConsentAll / removeConsentAll

CountlyInstance.giveConsentAll()
CountlyInstance.removeConsentAll()

// Logic
Convenience methods that give or remove consent for all known features at once.

For instance method getConsent

boolean CountlyInstance.getConsent(featureName: String)

// Logic
Returns the current consent status for a single feature.
- Returns true if consent is given, false if not
- Returns true unconditionally if requiresConsent is false
- For a feature group name, returns true only if ALL underlying features have consent

For instance method setConsent

CountlyInstance.setConsent(featureNames: String[], isConsentGiven: boolean)

// Logic
Convenience method combining giveConsent and removeConsent.
- Calls giveConsent if isConsentGiven is true, removeConsent if false

For instance methods createFeatureGroup / setConsentFeatureGroup

CountlyInstance.createFeatureGroup(groupName: String, features: String[])
CountlyInstance.setConsentFeatureGroup(groupName: String, isConsentGiven: boolean)

// Logic
createFeatureGroup stores a named mapping from groupName to a list of feature names.
setConsentFeatureGroup calls giveConsent or removeConsent for all features in the group.

For instance method checkAllConsent

CountlyInstance.checkAllConsent()

// Logic
Returns true only if consent is given for every known feature.
Returns true unconditionally if requiresConsent is false.

Two-phase consent notification:

- consentWillChange() is called BEFORE consent values are updated
- onConsentChanged() is called AFTER consent values are updated
This ordering is critical for modules that must stop activity before consent is removed
(e.g. Views must stop tracking before consent is set to false).

Module reactions on consent change:

- "location" revoked: location erasure request is sent to the server
- "content" revoked: content zone is exited and any displayed content is removed
- "sessions" given after init: a session begins (if automatic session triggers are enabled)
- "remote-config" given after init: RC values are downloaded (if automatic triggers are enabled)

SDK Behavior Settings:

cr (consent required) — Boolean, default: false
  If the server sends cr=true, the SDK must enforce consent requirements as if
  setRequiresConsent(true) had been configured locally. The developer-set value
  takes precedence if it is already true; the server can only enable consent
  enforcement, not disable it.

Device ID change behavior:

When device ID changes without merge:
- All consents are reset to false
- Modules are notified via ConsentChangeSource.DeviceIDChangedNotMerged

General Information

GDPR compatibility is about dividing the SDK functionality into different features and allowing SDK integrators to ask for consent when using these features. Once consent has been given, the SDK may only send newly collected (after consent is given) data to the server.

The user may change their mind during the app run and opt-out of some features. The SDK must be able to enable or disable features at runtime accordingly.

Consent management in the SDK is done in 2 steps:

  1. consent has to first be required in the app — otherwise the SDK works as if all consent is given
  2. if consent is required, it has to explicitly be given for each targeted feature

The config object accepts an initial set of consents at startup. After init, consent can be given or removed at any time using the instance methods. The SDK does not persistently store consent state — the host application is responsible for persisting user choices and re-providing them on each SDK initialization.

Feature grouping allows existing features to be collected into named groups so that consent can be given or removed for the entire group at once:

Countly.createFeatureGroup("activity", ["sessions", "events", "views"]);
Countly.setConsentFeatureGroup("activity", true); // gives consent for all three

Common integration flow when requiresConsent = true:

  1. Set requiresConsent = true in config — the SDK starts but collects and sends nothing.
  2. Show your consent UI; persist the user's choices in your app storage.
  3. Call giveConsent(featureNames) for the approved features — the SDK activates those features and notifies the server.
  4. On every subsequent app launch, re-provide consent from your stored choices.
  5. If the user later opts out, call removeConsent(featureNames) — the SDK stops those features and notifies the server.

Exposing Available Features for Consent

The SDK should expose all the features it supports for consent in the form of a method, static properties, or constant strings. The developer may check which features are available during development or when creating a consent form UI.

Developers shouldn't have to write the consent feature strings themselves. They should be provided either as constants or even as enums thereby eliminating the need for strings.

The following are the currently available features:

  • sessions - tracking when, how often, and how long users use your app/website
  • events - allow events to be sent to the server (doesn't apply to other features using event mechanisms)
  • location - allows location information to be sent. If consent has not been given, the SDK should force send an empty location upon begin_session to prevent the server from determining location via IP addresses
  • views - allow tracking of which views/pages a user visits
  • scrolls - allow user scrolls for scroll heatmap to be tracked
  • clicks - allow user clicks for heatmaps as well as link clicks to be tracked
  • forms - allow user's form submissions to be tracked
  • crashes - allow crashes, exceptions, and errors to be tracked
  • attribution - allows the campaign from which a user came to be tracked
  • users - allow collecting/providing user information, including custom properties
  • push - allows push notifications
  • star-rating - allows their rating and feedback to be sent
  • apm - allows usage of application performance monitoring features
  • remote-config - allows downloading of remote configs from the server
  • feedback - allows showing things like surveys and NPS
  • content - allows server-driven content (Content Zone) to be fetched and displayed
  • metrics - allows device and environment metrics to be collected and reported

Note that the available features may change depending on the platform.

Networking and Params

Consent change requests are sent to the standard request endpoint using the common request parameters. They are added to the request queue and processed asynchronously — they are not sent directly like content fetch requests.

The consent state is sent as a single consent parameter containing a JSON object with the full current state of all features — not just the changed ones. Every update is a complete snapshot. Both giving and removing consent can be expressed in a single request.

consent: JSON object — full current state of all features
  Example (decoded): {"sessions":true,"events":true,"views":false,"crashes":true,...}
  All known feature names are included as keys with boolean values.

Consent changes are sent to the standard /i endpoint.

# Consent given for sessions and events, removed for views
curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'consent={"sessions":true,"events":true,"views":false,...}' \
  --data '...remaining common params'

Multiple rapid consent changes may be coalesced into a single request before being queued (implementation-dependent — some platforms debounce by ~1 second).

Storage

Consent state is not persisted by the SDK across sessions. The host application is responsible for storing the user's consent choices and calling the appropriate consent methods on every SDK initialization.

The SDK only holds consent state in memory during the current session. State is reset when:

  • The app restarts and consent is not re-provided
  • Device ID changes without merge, all consents are cleared

Exception: Push notification consent may be persisted by the platform's push module (where supported) because the OS-level notification permission state must survive app restarts. This is platform-specific and should not be relied upon for other features.

Security and Privacy

Parameter Tampering

This is one of the preventive measures of Countly. If someone in the middle intercepts the request, it would be possible to change the data in the request and make another request with other data to the server or simply make random requests to the server through the retrieved app_key.

To prevent this from happening, the SDK should provide the option to send a checksum alongside the request data. To do so, it should be possible for the developer to provide some random string as SALT to the SDK as parameters or configuration options.

If this SALT is provided, right before making the request, the SDK should take all the payload it is about to send (all the data after the ‘?’ symbol in GET requests, including the app_key and device_id or query string encoded body of POST requests) and make a sha256 hash of this data. You should also provide SALT, and append it as checksum256={hash}.

if(salt){
  data += "&checksum256=" + sha256Hash(data + salt);
}

If SALT is not provided, the SDK should make ordinary requests without any checksums.

Other Features

Attribution

Attribution allows attributing installs from specific campaigns.

There are 2 forms of attribution: directAttribution and indirectAttribution.

There should be a way to provide them during init and there should be a way to provide them after init.

These values should be sent when they are provided in a separate request.

This requires attribution consent.

Direct Attribution

With this the dev is able to provide 2 String values: "Campaign type" and "Campaign data". The "type" determines for what purpose the attribution data is provided. Depending on the type, the expected data will differ, but usually that will be a string representation of a json object.

Currently there is only one type "countly". That type expected the data to look like following: '{cid:"[PROVIDED_CAMPAIGN_ID]", cuid:"[PROVIDED_CAMPAIGN_USER_ID]"}'. The inserted values would be retrieved from install attribution.

This feature is currently setup in a way to give more flexibility in the future. For now it will be only possible to record install attribution by handling twp special cases. In the future this feature will be generalised and a new param will be added.

If the provided type is "countly" then the first special case will be executed. The data string is an stringified json that has 2 values "cid" or Campaign ID and "cuid" or Campaign user ID.

Non valid or empty string should produce an error log.

The "Campaign ID" value is mandatory. If this has no valid value, an error log should be printed and execution should be aborted.

The "Campaign user ID" value is optional and if it is missing or invalid, only the "Campaign ID" value should be sent.

The call to record this value should be named something similar to "recordDirectAttribution".

The param for the campaign ID should be added as:

"&campaign_id=[PROVIDED_CAMPAIGN_ID]"

The param for the campaign user ID should be added as:

"&campaign_user=[PROVIDED_CAMPAIGN_USER_ID]"

If the provided type is "_special_test" then the second special case will be executed. If the provided data is not null or empty then it will be processed.

A request will be created. The provided value should be HTTP encoded and then set to the parameter "attribution_data" and then sent.

"&attribution_data=[ENCODED_CAMPAIGN_DATA]"

Indirect Attribution

With this the dev is able to provide a map/dictionary of String to String values. This allows multiple values to be provided.

Common values that would be provided here would be IDFA (for iOS) and AdvertisingId (for Android).

Each usable value will have a predefined key that has to be used. IDFA will need to be provided with the "idfa" key and Advertising ID will need to be provided with the "adid" key. These keys have to the be provided by the SDK as "constant" variables or some other convenient way where the developer is not setting the final key manually.

The pseudo code for recording indirect attribution would look something like this:

Map<String, String> attributionValues = new HashMap<>();
attributionValues.put(AttributionIndirectKey.AdvertisingID, getAdvertisingID());
Countly.recordIndirectAttribution(attributionValues);

The map/dictionary with valid key-value pairs will then be transformed into a json object which will set to the "aid" param and then immedietelly sent to the server.

Each key-value pair should be validated. If the key or value is either null, undefined or empty string, that key-value pair should be removed from the map/dictionary and an error message should be printed.

It should not be validated if the provided keys are part of our officially supported ones ("idfa" and "adid" at the time of writing). Just that the keys and their values are legitamate values.

If after the validation no valid value is left another error log should be printed and the execution of this call should not continue.

The call to record this value should be named something similar to "recordIndirectAttribution".

The param in the request would look something like like:

&aid={"adid":[PROVIDED_ATTRIBUTION_ID], "idfa":[PROVIDED_IDFA_VALUE]}

Or:

&aid={"adid":[PROVIDED_ATTRIBUTION_ID]}

Or:

&aid={"idfa":[PROVIDED_IDFA_VALUE]}

Or:

&aid={"rndid":[SOME_OTHER_ID_VALUE]}

SDK Internal Limits

The SDK should have the following limits:

  • "maxKeyLength" - 128 chars

Limits the maximum size of all string keys.
"Keys" include:
- event names
- view names
- custom trace key name (APM)
- custom metric key (APM)
- segmentation key (for all features)
- custom user property
- custom user property keys that are used for property modifiers (mul, push, pull, set, increment, etc)

  • "maxValueSize" - 256 chars

Limits the size of all values in our key-value pairs.
"Value" fields include:
- segmentation value in case of strings (for all features)
- custom user property string value
- user profile named key (username, email, etc) string values. Except the "picture" field, which has a limit of 4096 chars
- custom user property modifier string values. For example, for modifiers like "push," "pull," "setOnce", etc.
- breadcrumb text
- manual feedback widget reporting fields (reported as an event)
- rating widget response (reported as an event)
 

  • "maxSegmentationValues" - 100 developer-supplied entries

Max amount of custom (dev-provided) segmentation in:

- Event segmentation

- View segmentation

- Global view segmentation

- Custom (Global) crash segmentation

- Crash segmentation

- Custom APM Metrics

  • "maxBreadcrumbCount" - 100 entries

Maximum amount of breadcrumbs that can be recorded before the oldest one is deleted

  • "maxStackTraceLinesPerThread" - default value of 30

limits how many stack trace lines would be recorded per thread

  • "maxStackTraceLineLength" - default value of 200

limits how many characters are allowed per stack trace line. This also limits the crash message length.

Besides those 2 exposed tweakable crash-related values, there would also be an internal one for "maxStackTraceThreadCount." Which would limit the maximum number of recorded threads by a default of 50. This would be mostly just a sanity check, as that has to be capped somehow. In cases where stack traces can be provided as a string, the maximum line count would be 50*30 = 1500. That string would have to be split into lines and then checked accordingly.

Crash information like PLC crashes for iOS, and native Android crashes do not have any limits applied to them.

Backoff Mechanism

The SDK uses a backoff mechanism to pause requests when the server is slow or unresponsive.

Exposed Methods

Config Methods

CountlyConfig.disableBackoffMechanism()

Implementation Details

The backoff mechanism should be applied where the SDK handles network traffic.

It uses a 3-stage check to determine whether to initiate backoff:

  1. Response Time Check: If the response time of the most recent request is greater than or equal to the accepted timeout (default: 10 seconds).
  2. Queue Load Check: If the ratio of stored requests to the maximum request queue size is less than or equal to the accepted threshold (default: 50%).
  3. Request Age Check: If the age of the request is less than or equal to the accepted request age (default: 24 hours).

If all conditions are met, the SDK enters backoff mode and pauses new outgoing requests for a defined duration (default: 60 seconds). After that duration, SDK triggers sending requests.

The current request is still processed and removed from the queue since a response was received.

Note: Immediate requests are not subject to the Backoff Mechanism.

These parameters are configurable via the SDK Behavior settings for the Backoff Mechanism:

  • bom: Enable/disable the backoff mechanism (default: true)
  • bom_at: Accepted timeout in seconds (default: 10)
  • bom_rqp: Accepted request queue percentage (between 0 and 1) (default: 0.5)
  • bom_ra: Accepted request age (default: 24)
  • bom_d: Backoff duration in seconds (default: 60)

To observe SDK health and how backoff mechanism is doing, two health check parameters are logged for the backoff mechanism:

  • bom: Total number of requests that were backed off
  • cbom: Maximum number of consecutive requests that were backed off

For more information about SDK Health Checks see here.

For config method disableBackoffMechanism

CountlyConfig.disableBackoffMechanism()

// Logic
Disables the backoff mechanism
- After disabling, mechanism must not work.

SDK Behavior Settings

SDK Behavior Settings provide a way for server to provide a configuration. This configuration can effect which internal limits are used and which features are allowed to work in the SDKs.

Exposed Methods

Config Methods

CountlyConfig.setSDKBehaviorSettings(sdkBehaviorSettings: String)
CountlyConfig.disableSDKBehaviorSettingsUpdates()

Implementation Details

During init and every X hours (4 hours by default) SDK tries to acquire the behavior settings and stores it persistently locally. Afterwards, tries to reconfigure itself.

When initializing the SDK, if there is a persistent behavior settings stored, it will be used. The SDK will still try to get a up to date version of the behavior settings at the end of initialization. Once the up to date version has been acquired, it is stored persistently and the SDK reconfigures itself to reflect the new settings.

SDK's Default < Dev Set Settings < Dev Set Behavior Settings < Stored Behavior Settings

SBS = SDK Behavior Settings

Init Time Status Initial Behavior
Stored SBS Provided SBS Temp ID  
    Uses Stored SBS
  Uses Stored SBS
  Uses Stored SBS
Uses Stored SBS
    Uses Provided SBS
  Uses Provided SBS
    Uses User Config
      Uses User Config

After this initial behavior SDK should get the SDK Behavior Settings again to be up-to-date and save it. If that request fails or the settings are invalid it should keep using what it has.

After fetching the settings, the module must notify dependent components to allow them to reconfigure themselves with the updated settings. The notification mechanism can be implemented as a single function or a callback, allowing features to handle updates themselves. The exact approach depends on SDK design and platform constraints.

If user changes a setting in server, response would include that option with new value. If no value is sent for a configuration (meaning c is empty), then that means that the SDK has to use its own default or the value provided by the developer.

If temporary id is enabled, SDK Behavior Settings fetches must be omitted.

For config method setSDKBehaviorSettings:

CountlyConfig.setSDKBehaviorSettings(sdkBehaviorSettings: String)

// Valid values
Provided value should be not empty and correcty structured JSON object like below.
  
// Logic
In some cases, the behavior settings may not be applied on the app’s first run, 
or the app might temporarily lack internet connectivity. Therefore, providing the 
behavior settings through a dedicated configuration function becomes necessary.
- After the SDK behavior settings are supplied, they are among the first components 
processed during SDK initialization. Relevant parts of the system are notified of 
any changes before the rest of the initialization continues.

For config method disableSDKBehaviorSettingsUpdates:

CountlyConfig.disableSDKBehaviorSettingsUpdates()

// Logic
By default, SDKs fetch the behavior settings from the server. However, in some 
cases, network traffic may increase due to unintended factors. This configuration 
method disables behavior settings requests to help mitigate such issues.
- After SDK behavior settings updates disabled, it will only disable server config
fetch requests. Feature still continues to read the stored behavior settings and provided
behavior settings.

The feature must expose an internal interface for other components to access the required settings. Here is an example interface for function signatures. Some functions might not be exposed if they are used only inside of the feature. It is up to SDK design and platform constraints.

interface ConfigurationProvider {

  Boolean getNetworkingEnabled() //networking

  Boolean getTrackingEnabled() //tracking

  Boolean getSessionTrackingEnabled() //st

  Boolean getViewTrackingEnabled() //vt

  Boolean getCustomEventTrackingEnabled() //cet

  Boolean getEnterContentZone() //ecz

  Boolean getCrashReportingEnabled() //crt

  Boolean getLocationTrackingEnabled() //lt

  Boolean getRefreshContentZoneEnabled() //rcz

  Integer getRequestQueueSize() // rqs

  Integer getEventQueueSize() //eqs

  Integer getSessionUpdateInterval() //sui

  Integer getLimitKeyLength() //lkl

  Integer getLimitValueSize() //lvs

  Integer getLimitSegmentationValues() //lsv

  Integer getLimitBreadcrumbCount() //lbc

  Integer getLimitStackTraceLineCount() //ltlpt

  Integer getLimitStackTraceLineLength() //ltl

  Integer getContentZoneInterval() //czi

  Boolean getConsentRequired() //cr

  Integer getServerConfigUpdateInterval() //scui

  Integer getDropOldRequestTime() //dort

  Boolean getBOMEnabled() //bom

  Integer getBOMAcceptedTimeoutSeconds() //bom_at

  Double getBOMRQPercentage() //bom_rqp

  Integer getBOMRequestAge() //bom_ra

  Integer getBOMDuration() //bom_d

  Integer getUserPropertyCacheLimit() //upcl

  Boolean getLoggingEnabled() //log

  FilterList<Set<String>> getEventFilterList() //eb or ew

  FilterList<Set<String>> getUserPropertyFilterList() //upb or upw

  FilterList<Set<String>> getSegmentationFilterList() //sb or sw

  FilterList<Map<String, Set<String>>> getEventSegmentationFilterList() //esb or esw
  
  Set<String> getJourneyTriggerEvents() //jte
}

Listing Filters are key-value pairs that use either a blacklist or whitelist approach. Blacklist means the listed values are blocked, whitelist means only the listed values are allowed. If both a blacklist and whitelist key are present for the same category, the blacklist takes precedence.

The FilterList structure contains:

FilterList {
  filterList: the set/map of filter values
  isWhitelist: boolean - false for blacklist, true for whitelist
}

Event Segmentation Filters (esb/esw) are structured as a JSON object where each key is an event name and each value is a JSON array of segmentation keys to filter for that specific event.

Journey Trigger Events (jte) is a JSON array of event keys that trigger a content zone refresh when recorded. When one of these events is recorded, the SDK should refresh the content zone after making sure SDK delivers event to the server. If the initial attempt fails, retries refreshing content zone up to 3 times with 1-second delays Only custom events are affected by this.

Filter Application Logic:

Event filter (eb/ew):
- Applied before recording any event. If the event name is filtered out, the event is dropped entirely.
- Whitelist: event is allowed only if its name is in the list
- Blacklist: event is blocked if its name is in the list
- Empty filter list = no filtering (everything allowed)

User property filter (upb/upw):
- Applied before recording any user property. If the property key is filtered out, it is not sent.
- Same whitelist/blacklist logic as event filter

Segmentation filter (sb/sw):
- Applied to all event segmentation before recording. Filtered keys are removed from segmentation.
- Whitelist: only keys in the list are kept
- Blacklist: keys in the list are removed
- Empty filter list = no filtering

Event segmentation filter (esb/esw):
- Applied per-event. Each event name maps to a set of segmentation keys to filter.
- If no rule exists for a specific event name, all segmentation is allowed for that event.
- Same whitelist/blacklist logic as segmentation filter

Networking and Params

Behavior settings fetch request might consist of only 1 parameter

method: this is always "sc"

method parameter is always sent with behavior settings fetch request

Behavior settings fetch request is sent to the "/o/sdk" endpoint.

Behavior settings fetch requests are directly sent to the server, they will not be added to the request queue.

# Common way to send
curl --request POST \
  --url 'https://YOUR_SERVER/o/sdk' \
  --header 'Accept: application/json' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'method=sc' \
  --data ...remaining common params

Response would look like this:

{
  "v": 1,
  "t": 1742459739383,
  "c": {
    "tracking": false,
    "networking": false,
    // ...
  }
}

Which are:

v - current schema version
t - timestamp (at the time of creation)
c - config object (key value pairs)

Storage

The newly fetched server configuration should be merged with the stored configuration. It must not override all existing values only update the keys present in the new configuration.

Values are preserved in their original format as received from the response. Here is a full list of server config key value pairs and defaults:

Feature Name Key Default Val
Allow all tracking tracking TRUE
Allow sending requests networking TRUE
Request queue size rqs 1000 requests
Event queue size (or batch) eqs 100 events
Session update interval sui 60 seconds
Allow session tracking st TRUE
Allow crash tracking crt TRUE
Allow location tracking lt TRUE
Allow view tracking vt TRUE
Key length limit lkl 128 chars
Value size limit lvs 256 chars
Segmentation values count limit lsv 100 key/values
Breadcrumb count limit lbc 100 breadcrumbs
Stack trace line count limit ltlpt 30 lines
Stack trace line length limit ltl 200 chars
Allow custom event tracking cet TRUE
Enter Content Zone after init ecz FALSE
Content Zone request interval czi 30 seconds
Consent required cr FALSE
Server config update interval scui 4 hours
Allow Refresh Content Zone rcz TRUE
Drop old request time dort 0 (disabled)
Backoff mechanism enabled bom TRUE
Backoff mechanism accepted timeout bom_at 10 seconds
Backoff mechanism request queue % bom_rqp 0.5 (between 0 and 1)
Backoff mechanism request age bom_ra 24 hours
Backoff mechanism duration bom_d 60 seconds
User property cache limit upcl 100
Enable/disable logging log FALSE
Event blacklist (JSON array) eb [] (empty)
Event whitelist (JSON array) ew [] (empty)
User property blacklist (JSON array) upb [] (empty)
User property whitelist (JSON array) upw [] (empty)
Segmentation blacklist (JSON array) sb [] (empty)
Segmentation whitelist (JSON array) sw [] (empty)
Event segmentation blacklist (JSON object) esb {} (empty)
Event segmentation whitelist (JSON object) esw {} (empty)
Journey trigger events (JSON array) jte [] (empty)

Consent

This feature does not need any consent.

Health Checks

The SDK Health Monitoring feature automatically tracks internal SDK health indicators and reports them to the server once per SDK initialization. This gives server-side visibility into whether the SDK is operating correctly: whether requests are succeeding, whether the backoff mechanism is being triggered excessively, and whether any internal warnings or errors have occurred since the last successful report.

The health check tracking, serialization, deserialization, and request logic should be contained within an independent, self-contained module that can be easily tested in isolation.

Exposed Methods

Config Methods

CountlyConfig.disableHealthCheck()

There are no instance methods for this feature. Health monitoring is fully automatic and does not expose any runtime API to the developer.

Implementation Details

For config method disableHealthCheck

CountlyConfig.disableHealthCheck()

// Logic
Disables the SDK health check feature entirely.
- No health check request will be sent during SDK initialization.
- The internal health counter object is still created and metrics still accumulate,
  but no report is sent to the server.
- This is a permanent setting for the lifetime of the SDK instance.

For internal behavior sendHealthCheck

// Triggered automatically after SDK init completes (initFinished lifecycle hook)

// Logic
Sends a single immediate (out-of-queue) health check request to the /i endpoint.
- Aborted if health check is disabled via config.
- Aborted if temporary device ID mode is active.
- Aborted if a health check has already been sent in this SDK lifecycle.
- Marks the health check as sent (one-shot flag).
- Prepares device metrics containing only the app version (with metric override support).
- Prepares the base request data (common params + metrics).
- Appends the serialized health counters as the &hc param.
- Creates a connection processor and reads the current networking-enabled state.
- Sends the request via the immediate request mechanism (not the request queue).
- On successful server response (response JSON contains "result" key):
  - Counters are cleared in memory and persisted state is wiped (clearAndSave).
- On network failure, no response, or missing "result" key:
  - Counters are retained in memory and storage.
  - They will be re-sent on the next SDK initialization.

For internal behavior counter accumulation

The health check module should expose the following methods for tracking. These are called by other SDK modules during normal operation:

logWarning()
- Increments the warning log counter ("wl") by 1.
- Called whenever the SDK emits an internal warning-level log entry.

logError()
- Increments the error log counter ("el") by 1.
- Called whenever the SDK emits an internal error-level log entry.

logFailedNetworkRequest(statusCode: integer, errorResponse: string)
- Records the HTTP status code of a failed request ("sc").
  - Status code must be > 0 and < 1000.
- Records the error response body ("em"), truncated to 1000 characters if longer.
- Only the most recent failed request is stored; previous values are overwritten.

logBackoffRequest()
- Increments the total backoff request counter ("bom") by 1.
- Increments a temporary consecutive backoff run counter by 1.
- Called when a request is backed off, excluding immediate requests.

logConsecutiveBackoffRequest()
- Compares the temporary consecutive backoff run counter against the stored
  maximum ("cbom") and keeps the higher value.
- Resets the temporary consecutive backoff run counter to 0.
- Called when a request completes without backing off, ending a consecutive run.
  Excludes immediate requests.

For internal behavior saveState / clearAndSave

saveState()
- Calls logConsecutiveBackoffRequest() first to finalize the consecutive backoff
  maximum before persisting.
- Serializes all current counter values to a JSON object.
- Persists the JSON string to the platform key-value store.
- Called on platform-specific app lifecycle events (e.g. activity stop, session
  update timer, session end) to survive process death.

clearAndSave()
- Resets all in-memory counter values to their defaults:
  - countLogWarning = 0
  - countLogError = 0
  - statusCode = -1
  - errorMessage = ""
  - countBackoffRequest = 0
  - consecutiveBackoffRequest = 0
  - consecutiveBackoffRequestCounter = 0
- Clears the persisted storage entry (sets it to an empty string).
- Called after a successful health check server acknowledgement.

For internal behavior initialization and state recovery

// On construction, the health counter reads the persisted state from storage.

// Logic
- Reads the stored JSON string from the platform key-value store.
- If the string is null or empty, counters start at zero defaults.
- If the string is valid JSON, each counter is restored from the stored values
  using safe fallback defaults (0 for numbers, -1 for status code, "" for error message).
- If the stored string is malformed (JSON parse failure), counters are reset to
  zero defaults and the stored state is cleared via clearAndSave().

General Information

Health monitoring is enabled by default and requires no explicit opt-in. The developer may disable it during configuration via disableHealthCheck(). Even when disabled, the internal counter object is still created and metrics continue to accumulate (since other SDK modules may still call the tracking methods), but no request is ever sent.

The health check request is sent once, immediately after SDK initialization completes (in the initFinished lifecycle hook), as an immediate (out-of-queue) request. It is a one-shot per SDK lifecycle: only one health check request is sent per SDK initialization.

The request is skipped if any of the following conditions are true at init time:

  • Health check is disabled via config
  • Temporary device ID mode is active
  • A health check has already been sent during this SDK lifecycle

The feature tracks the following indicators since the last successful health check submission:

  • Number of SDK-internal warning log entries
  • Number of SDK-internal error log entries
  • HTTP status code of the last failed network request
  • Error message body of the last failed network request (capped at 1000 characters)
  • Total count of backoff requests (requests delayed due to server-side backoff signals)
  • Maximum observed consecutive backoff request count

Counters accumulate across SDK sessions and are persisted to storage. Health metrics should not be saved after every change to a metric. They should be saved after the following triggers:

  • Session update timer (for periodic persistence)
  • Session ended (as a proxy that the app or page is about to be closed)
  • Any other platform-specific mechanism that indicates the app is about to be closed or killed (e.g. activity stop on Android)

When the health check request is successfully acknowledged by the server (the response JSON contains a result key), all counters are cleared in memory and the persisted state is wiped. If the request fails (e.g. no network connectivity, or the response does not contain a result key), the counters are retained and will be included in the next health check attempt on a subsequent SDK initialization.

The SDK Health Monitoring feature does not require any consent. It is an internal SDK diagnostic mechanism and does not track user behavior or personally identifiable information. It operates independently of the consent framework and is not gated by any consent group.

Networking and Params

The health check is sent as an immediate request to the /i endpoint. It bypasses the standard request queue and is fired directly after initialization completes. It respects the SDK's networking-enabled state: if networking is disabled, the immediate request mechanism will not send the request.

The request consists of the base common params (app_key, device_id, timestamp, hour, dow, sdk_version, sdk_name), the &metrics param containing only the app version, and the &hc param containing the serialized health counters.

curl --request POST \
  --url 'https://YOUR_SERVER/i' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'metrics={"_app_version":"1.0.0"}' \
  --data 'hc={"el":5,"wl":12,"sc":503,"em":"Service Unavailable","bom":3,"cbom":2}' \
  --data ...remaining common params

The &metrics param is a URL-encoded JSON object containing device metrics. For health check requests, only the app version is included:

&metrics={"_app_version": "1.0.0"}

If metric overrides are configured and include _app_version, the overridden value is used instead of the platform-detected app version.

The &hc param is a URL-encoded JSON object containing the health counters:

&hc={
  "el":   <number>,   // error log count since last successful health check
  "wl":   <number>,   // warning log count since last successful health check
  "sc":   <number>,   // HTTP status code of last failed network request (-1 if none)
  "em":   <string>,   // error message of last failed network request ("" if none)
  "bom":  <number>,   // total backoff request count since last successful health check
  "cbom": <number>    // maximum consecutive backoff request count observed
}

The server is expected to respond with a JSON object containing a result field. The SDK checks only for the presence of this key to determine success. Any response without result is treated as a failure and counters are not cleared.

Storage

Health check counters are persisted to the platform's key-value store under the key HEALTH_CHECK. The stored value is a JSON string containing the current counter snapshot.

The persisted JSON schema is:

{
  "LErr":   <number>,   // error log count
  "LWar":   <number>,   // warning log count
  "RStatC": <number>,   // HTTP status code of last failed request (-1 default)
  "REMsg":  <string>,   // error message of last failed request ("" default)
  "BReq":   <number>,   // total backoff request count
  "CBReq":  <number>    // maximum consecutive backoff count
}

State is written on platform-specific app lifecycle events (session update timer, session end, activity stop, or equivalent) and cleared on successful health check submission. If the stored state is missing or empty on SDK startup, counters start at zero defaults. If the stored state is malformed (JSON parse failure), counters are reset to zero defaults and the stored state is cleared.

Changing the Server URL

This feature adds the ability to change the server URL after the SDK is initialised.

This should in memory overwrite the current URL. Basic URL validation should be performed on the provided URL.

After the URL is changed, all previously saved events and requests should be sent to the new URL.

Queue Operations

The SDK should provide queue management methods for advanced use cases:

CountlyInstance.flushQueues()

// Logic
Clears both the request queue and event queue. All pending data is discarded.
Started timed events are not affected.
CountlyInstance.replaceAllAppKeysInQueueWithCurrentAppKey()

// Logic
Replaces the app key in all queued requests with the current app key.
Useful when the app key changes and old requests should be associated with the new app.
CountlyInstance.removeDifferentAppKeysFromQueue()

// Logic
Removes all queued requests whose app key differs from the current app key.
Useful for cleaning up after an app key change.
CountlyInstance.addDirectRequest(requestParameters: Map<String, String>)

// Logic
Adds a custom request directly to the queue using given key-value pairs.
If consent is required and no consents are given, the call is ignored.

Offline Mode

Some SDKs (particularly Web) support an offline mode where the SDK does not send any data to the server. All data is stored locally and sent when offline mode is disabled.

CountlyInstance.enableOfflineMode()

// Logic
Puts the SDK into offline mode. All requests are queued but not sent.
No network requests will be made while in offline mode.
CountlyInstance.disableOfflineMode(deviceId: String)

// Logic
Disables offline mode and optionally sets a new device ID.
All queued requests are sent to the server.
If a device ID is provided, it is applied before sending.

Markdown Linting

To ensure the formatting of the code is uniform across all platforms, certain linting tools can be used. Currently for markdown files "markdownlint" would be used.

Installation

You can add markdownlint to your project with the following line of code using the npm:

npm install markdownlint --save-dev

Another way to add it to your project would be to download its extension in VSC.

Rules

Markdownlint, has multiple rules that can be modified/defined from a config file called ".markdownlint.json". This file must be created at the root of the project and should have a structure similar to this:

{
    "MD001": true, /*Heading levels should increase one at a time*/
    "MD002": false, /*First heading should be h1*/
    "MD003": true, /*Use only one heading style in a document*/
    "MD004": true, /*Only one unordered list style should be used*/
    "MD005": true, /*List items should have same indentation at same level*/
    "MD006": true, /*Top level list items should not be indented*/
    "MD007": { "indent": 2 }, /*Indent level as space*/
    "MD009": false, /*No white space at the end*/
    "MD010": false, /*No hard tab indentation*/
    "MD011": true, /*link syntax should not be reversed like (a)[a.com]*/
    "MD012": { "maximum": 1 }, /*No more than 1 blank line*/
    "MD013": { "line_length": 300 }, /*Max line length*/
    "MD014": false, /*Dollar sign should not be used consecutively for shell commands*/
    "MD018": true, /*There should be a space after heading hash*/
    "MD019": true, /**There should not be multiple spaces after heading hash*/
    "MD020": true, /*Closed atx style heading should have 1 space inside hashes*/
    "MD021": true, /*Closed atx style heading should note have multiple space inside hashes*/
    "MD022": false, /*Before and after a heading should be a blank line*/
    "MD023": true, /*Heading should not be indented*/
    "MD024": true, /*No duplicate sibling headings*/
    "MD025": true, /*Only one h1*/
    "MD026": true, /*No punctuation at the end of a heading except '?'*/
    "MD027": true, /*No more than 1 space after blockquote symbol*/
    "MD028": true, /*No separation of blockquotes with a blank line*/
    "MD029": true, /*Ordered list should be ordered and start with  or 1*/
    "MD030": true, /*Only one space between the list marker and text*/
    "MD031": true, /*Before and after a fenced code block should be a blank line*/
    "MD032": false, /*Before and after a list should be a blank line*/
    "MD033": false, /*No raw HTML*/
    "MD034": false, /*URL should be surrounded with brackets*/
    "MD035": true, /*No inconsistent horizontal rules; ---, *** */
    "MD036": true, /*No emphasis instead of heading*/
    "MD037": true, /*No space between emphasis market and the text*/
    "MD038": true, /*No space between backtick and text*/
    "MD039": true, /*No space inside link text*/
    "MD040": true, /*Fenced code blocks should have a language declared*/
    "MD041": false, /*First line in a file should be h1*/
    "MD042": true, /*No empty links*/
    "MD043": false, /*Declare a heading structure*/
    "MD044": true, /*Proper names should have the correct capitalization*/
    "MD045": true, /*Images should have alt text*/
    "MD046": true, /*Use indent or code fence alone*/
    "MD047": true, /*Files should end with a single newline character */
    "MD048": true, /*Code fence style should be uniform*/
    "MD049": true, /*Emphasis style should be consistent*/
    "MD050": true, /*Strong style should be consistent*/
    "MD051": true, /*Link fragments should correspond to a heading*/
    "MD052": true, /*Reference links and images should use a label that is defined*/
    "MD053": true /* Link and image reference definitions should be needed*/
}

To exclude certain files from being analyzed you can create a ".markdownlintignore" at the project root and add directories that you want to exclude from the analysis:

// to exclude node_modules folder
/node_modules/

Usage

Then you can add the following text to your npm scripts in your package.json file:

"lintMD": "markdownlint **/*.md --fix"

This way you can use the markdown linter with the following code at your project root:

npm run lintMD

Instead, if you have only installed the VSC extension you can simply right click on your markdown file and press 'Format Document' for ease of use. Or if you want to automatically format when saving or pasting into a Markdown document, configure Visual Studio Code's editor.formatOnSave or editor.formatOnPaste settings like so:

"[markdown]": { "editor.formatOnSave": true, "editor.formatOnPaste": true },

Backend Mode

Backend mode is a special SDK operating mode designed for server-to-server (backend) usage. It allows a single SDK instance to record data on behalf of multiple applications and multiple devices simultaneously. This is useful for scenarios such as data migrations, server-side event recording, and batch processing where no real device context exists.

When backend mode is enabled, the SDK disables most client-oriented features (automatic session tracking, location, consent management, device ID changes, view recording, crash breadcrumbs, etc.) and instead exposes a dedicated interface where every call requires an explicit device_id and optionally a separate app_key.

Backend mode and normal (client) mode are mutually exclusive. When backend mode is enabled, all standard SDK calls (events, sessions, views, crashes, location, consent, device ID changes) become no-ops and return immediately. Only the methods exposed through the Backend Mode interface are functional.

The backend mode interface can be extended with additional functions as needed. If a use case requires recording data that is not covered by the current set of methods (e.g. feedback, remote config, or custom endpoints), new methods can be added following the same pattern: accept an explicit device_id, optional app_key and timestamp, build the appropriate request, and add it to the request queue or event pool.

Exposed Methods

Config Methods

CountlyConfig.enableBackendMode()
CountlyConfig.setMaxRequestQueueSize(maxSize: int)
CountlyConfig.setEventQueueSizeToSend(threshold: int)
CountlyConfig.setBackendModeAppEQSizeToSend(appEQSize: int)
CountlyConfig.setBackendModeServerEQSizeToSend(serverEQSize: int)

Instance Methods (accessed via BackendMode interface)

BackendMode.RecordEvent(deviceId: String, eventKey: String, segmentations: Map, count: int, sum: double, duration: long, appKey: String, timestamp: long)
BackendMode.RecordUserProperties(deviceId: String, userProperties: Map<String, Object>, appKey: String, timestamp: long)
BackendMode.RecordException(deviceId: String, error: String, stackTrace: String, breadcrumbs: List<String>, customInfo: Map<String, Object>, metrics: Map<String, String>, unhandled: boolean, appKey: String, timestamp: long)
BackendMode.BeginSession(deviceId: String, appKey: String, metrics: Map<String, String>, location: Map<String, String>, timestamp: long)
BackendMode.UpdateSession(deviceId: String, duration: int, appKey: String, timestamp: long)
BackendMode.EndSession(deviceId: String, duration: int, appKey: String, timestamp: long)
BackendMode.StartView(deviceId: String, name: String, segmentations: Map, segment: String, appKey: String, firstView: boolean, timestamp: long)
BackendMode.StopView(deviceId: String, name: String, duration: long, segmentations: Map, segment: String, appKey: String, timestamp: long)
BackendMode.ChangeDeviceIdWithMerge(newDeviceId: String, oldDeviceId: String, appKey: String, timestamp: long)
BackendMode.RecordDirectRequest(deviceId: String, parameters: Map<String, String>, appKey: String, timestamp: long)

Implementation Details

For config method enableBackendMode

CountlyConfig.enableBackendMode()

// Logic
Enables backend mode for the SDK instance.
- When enabled, the SDK skips loading persisted events, sessions, exceptions,
  and user details from storage during initialization.
- A dedicated BackendMode module is created after init completes.
- The session timer is started (used to periodically flush the event pool).
- All standard client-side feature calls become no-ops.

For config method setMaxRequestQueueSize

CountlyConfig.setMaxRequestQueueSize(maxSize: int)

// Valid values
maxSize must be a positive integer. Default is 1000.

// Logic
Sets the maximum number of requests the request queue can hold.
When the queue is full, the oldest request is dropped (FIFO eviction)
to make room for the new one. This prevents unbounded memory growth
in long-running backend processes.

For config method setEventQueueSizeToSend

CountlyConfig.setEventQueueSizeToSend(threshold: int)

// Valid values
threshold must be a positive integer. Default is 10.

// Logic
Sets the per-device event queue threshold. When the number of events
for a specific device ID reaches this threshold, the events are
automatically flushed into a request and added to the request queue.

For config method setBackendModeAppEQSizeToSend

CountlyConfig.setBackendModeAppEQSizeToSend(appEQSize: int)

// Valid values
appEQSize must be a positive integer. Default is 1000.

// Logic
Sets the per-app-key event queue size limit. When the total number
of events across all device IDs for a single app key reaches this
limit, all events for that app key are flushed into requests.

For config method setBackendModeServerEQSizeToSend

CountlyConfig.setBackendModeServerEQSizeToSend(serverEQSize: int)

// Valid values
serverEQSize must be a positive integer. Default is 10000.

// Logic
Sets the global (server-wide) event queue size limit. When the total
number of events across all app keys and all device IDs reaches this
limit, all events across every app key are flushed into requests.

For instance method RecordEvent

BackendMode.RecordEvent(deviceId: String, eventKey: String, segmentations: Map, count: int, sum: double, duration: long, appKey: String, timestamp: long)

// Valid values
- deviceId: required, must not be null or empty
- eventKey: required, must not be null or empty
- count: defaults to 1, negative values are reset to 1
- sum: optional, defaults to null
- duration: optional, defaults to null
- segmentations: optional key-value pairs, values must be bool, int, long, string, double, or float
- appKey: optional, defaults to the app key provided during init
- timestamp: optional, defaults to current UTC time as Unix milliseconds

// Logic
Adds the event to the internal event pool, keyed by device ID and app key.
The event is not immediately sent; it is batched and flushed when:
- The per-device event threshold is reached
- The per-app event limit is reached
- The global event limit is reached
- The periodic timer fires (dumps all buffered events)

For instance method BeginSession

BackendMode.BeginSession(deviceId: String, appKey: String, metrics: Map<String, String>, location: Map<String, String>, timestamp: long)

// Valid values
- deviceId: required
- appKey: optional, defaults to init app key
- metrics: optional device metrics; if null, internal SDK metrics are used
- location: optional location parameters (country_code, city, location, ip_address)
- timestamp: optional, defaults to current time

// Logic
Creates a begin_session request with &begin_session=1 and the provided
or default metrics. If location data is provided, it is appended as
additional query parameters. The request is immediately added to the
request queue and an upload is triggered.

For instance method UpdateSession

BackendMode.UpdateSession(deviceId: String, duration: int, appKey: String, timestamp: long)

// Valid values
- deviceId: required
- duration: required, must be = 1 (in seconds); values less than 1 are rejected
- appKey: optional
- timestamp: optional

// Logic
Creates a session update request with &session_duration=[duration].
Used to signal that the session is still active. The caller is responsible
for sending periodic updates (e.g. every 60 seconds).

For instance method EndSession

BackendMode.EndSession(deviceId: String, duration: int, appKey: String, timestamp: long)

// Valid values
- deviceId: required
- duration: session duration in seconds
- appKey: optional
- timestamp: optional

// Logic
Creates an end_session request with &end_session=1 and
&session_duration=[duration].

For instance method RecordUserProperties

BackendMode.RecordUserProperties(deviceId: String, userProperties: Map<String, Object>, appKey: String, timestamp: long)

// Valid values
- deviceId: required, must not be null or empty
- userProperties: required, must not be null or empty
- appKey: optional
- timestamp: optional

// Logic
Separates properties into predefined keys (name, username, email,
organization, phone, gender, byear, picture) and custom keys.
Custom keys are placed under a "custom" sub-object.
Values must be valid data types (bool, int, long, string, double, float);
invalid types are removed with a warning.
String values that start with '{' are parsed as JSON objects to support
user property modifiers (e.g. $inc, $push).
The result is URL-encoded JSON set as the &user_details parameter.

For instance method RecordException

BackendMode.RecordException(deviceId: String, error: String, stackTrace: String, breadcrumbs: List<String>, customInfo: Map<String, Object>, metrics: Map<String, String>, unhandled: boolean, appKey: String, timestamp: long)

// Valid values
- deviceId: required
- error: required, must not be null or empty (used as _name in crash data)
- stackTrace: optional (used as _error in crash data)
- breadcrumbs: optional, joined with newlines (used as _logs)
- customInfo: optional key-value pairs (used as _custom), invalid data types are removed
- metrics: optional, only predefined crash metric keys are accepted:
  _os, _os_version, _ram_total, _ram_current, _disk_total, _disk_current,
  _online, _muted, _resolution, _app_version, _manufacture, _device,
  _orientation, _run
- unhandled: defaults to false; when true, _nonfatal is set to false
- appKey: optional
- timestamp: optional

// Logic
Builds a crash JSON object and sends it as the &crash parameter.
The request is immediately added to the queue and an upload is triggered.

For instance method StartView

BackendMode.StartView(deviceId: String, name: String, segmentations: Map, segment: String, appKey: String, firstView: boolean, timestamp: long)

// Valid values
- deviceId: required
- name: required, the view name
- segment: platform or domain identifier; defaults to a platform-specific value if empty
- firstView: if true, adds a "start":"1" segmentation entry
- segmentations: optional additional segmentation
- appKey: optional
- timestamp: optional

// Logic
Records a [CLY]_view event with "visit":"1" in the segmentation.
The "name" and "segment" values are added to segmentation automatically.
If firstView is true, "start":"1" is also added.
The event is batched through the event pool (same batching as RecordEvent).

For instance method StopView

BackendMode.StopView(deviceId: String, name: String, duration: long, segmentations: Map, segment: String, appKey: String, timestamp: long)

// Valid values
- deviceId: required
- name: required
- duration: required, must not be negative
- segment: platform or domain identifier
- segmentations: optional
- appKey: optional
- timestamp: optional

// Logic
Records a [CLY]_view event with the given duration.
Unlike StartView, "visit" is not added and "start" is not added.
The duration is passed as the event duration parameter.

For instance method ChangeDeviceIdWithMerge

BackendMode.ChangeDeviceIdWithMerge(newDeviceId: String, oldDeviceId: String, appKey: String, timestamp: long)

// Valid values
- newDeviceId: required, must not be null or empty
- oldDeviceId: required, must not be null or empty
- newDeviceId must not equal oldDeviceId

// Logic
Sends a request with &old_device_id=[oldDeviceId] using newDeviceId
as the device_id. The server will merge data from the old device ID
into the new one.

For instance method RecordDirectRequest

BackendMode.RecordDirectRequest(deviceId: String, parameters: Map<String, String>, appKey: String, timestamp: long)

// Valid values
- deviceId: required
- parameters: required, must not be null or empty
- appKey: optional
- timestamp: optional

// Logic
Sends a raw request with the provided key-value pairs appended as
query parameters, plus &dr=1 to mark it as a direct request.
This is a catch-all method for sending arbitrary data to the server
that is not covered by other backend mode methods.

General Information

Backend mode is intended for server-side integrations where the SDK runs without a real device context. A typical use case is a backend service that collects analytics data on behalf of many users/devices and forwards it to the Countly server. Another common scenario is data migration, where historical data from a different system needs to be replayed into Countly.

Key behavioral differences from normal mode:

  • No automatic device ID generation. Every call must provide an explicit device_id.
  • No automatic session management. Sessions must be manually started, updated, and ended via the backend mode interface.
  • Multi-app support. Each call can optionally specify an app_key that differs from the one provided during initialization. If omitted, the init-time app key is used.
  • No persistent storage. Events, sessions, exceptions, and user details are not written to disk. Request queue overflow is handled by dropping the oldest request (FIFO eviction).
  • Consent is ignored. Since backend mode is server-side, feature consent checks are bypassed.
  • Standard client-side calls (RecordEvent, RecordView, SessionBegin, SetLocation, ChangeDeviceId, SetConsent, AddCrashBreadCrumb, etc.) become no-ops and log a warning.

Networking and Params

All backend mode requests are sent to the /i endpoint. Every request includes a base set of parameters regardless of the specific method:

curl --request POST \
  'https://your.server.ly/i' \
  --data 'app_key=YOUR_APP_KEY\
  &device_id=DEVICE_ID\
  &sdk_version=SDK_VERSION\
  &sdk_name=SDK_NAME\
  &hour=HOUR\
  &dow=DAY_OF_WEEK\
  &tz=TIMEZONE_OFFSET\
  &timestamp=UNIX_TIMESTAMP_MS\
  &t=0\
  &av=APP_VERSION'

The &t=0 parameter is always included in backend mode requests. This signals to the server that the request originates from a non-device (backend) context, which may affect how the server processes certain data (e.g. skipping device-specific aggregations).

Feature-specific parameters are appended to this base. For example, an event request adds:

curl --request POST \
  'https://your.server.ly/i' \
  --data 'app_key=YOUR_APP_KEY\
  &device_id=DEVICE_ID\
  &sdk_version=SDK_VERSION\
  &sdk_name=SDK_NAME\
  &hour=HOUR\
  &dow=DAY_OF_WEEK\
  &tz=TIMEZONE_OFFSET\
  &timestamp=UNIX_TIMESTAMP_MS\
  &t=0\
  &av=APP_VERSION\
  &events=URL_ENCODED_JSON_ARRAY'

Session requests append session-specific params:

// Begin session
&begin_session=1&metrics=URL_ENCODED_JSON

// Update session
&session_duration=SECONDS

// End session
&end_session=1&session_duration=SECONDS

User properties are sent as:

&user_details=URL_ENCODED_JSON

Crash data is sent as:

&crash=URL_ENCODED_JSON

Device ID merge requests include:

&old_device_id=URL_ENCODED_OLD_ID

Direct requests include:

&dr=1&custom_param1=value1&custom_param2=value2

Event Batching

In backend mode, events are not sent individually. They are accumulated in a multi-level event pool organized by app key and device ID. Three thresholds control when events are flushed into requests:

  • Per-device threshold (default 10): When the event count for a specific device ID within an app key reaches this limit, those events are flushed.
  • Per-app threshold (default 1000): When the total event count across all device IDs for a single app key reaches this limit, all events for that app key are flushed.
  • Global threshold (default 10000): When the total event count across all app keys and device IDs reaches this limit, all events are flushed.

Additionally, the SDK's periodic timer (same interval as the session update timer) triggers a full dump of all buffered events regardless of thresholds.

Storage

Backend mode disables persistent storage entirely. Events, sessions, exceptions, user details, and the request queue are held only in memory:

  • Events are not saved to disk. They exist in the in-memory event pool until flushed into requests.
  • The request queue is not persisted. If the process terminates before requests are sent, queued data is lost.
  • User details changes are not written to storage.
  • Session state is not persisted. Session files are not loaded during init and not saved during operation.

This design is intentional: backend mode is expected to run in server environments where disk I/O for analytics state is unnecessary and where the process lifecycle is managed externally.

Experimental

This section describes features that are under development and in a prototype phase

Visibility Tracking

CountlyConfig.experimental.enableVisibilityTracking()

// Logic
Enables visibility tracking for events and views.
When enabled, the SDK tracks whether the app is in the foreground or background
at the time an event or view is created, and records this as part of the data.

Previous Name Recording

CountlyConfig.experimental.enablePreviousNameRecording()

// Logic
Enables recording of the previous view name for view events
and the current view name for custom events. This adds contextual
navigation information to the recorded data.

Legacy Features

Star Rating (Legacy)

The legacy star rating and rating widget APIs predate the unified feedback widget system. They are retained for backward compatibility but new integrations should prefer the feedback widget API in the User Feedback section.

Exposed Methods

Config Methods

CountlyConfig.setStarRatingSessionLimit(limit: int)
CountlyConfig.setStarRatingCallback(callback: StarRatingCallback)
CountlyConfig.setStarRatingTextTitle(title: String)
CountlyConfig.setStarRatingTextMessage(message: String)
CountlyConfig.setStarRatingTextDismiss(text: String)
CountlyConfig.setIfStarRatingDialogIsCancellable(isCancellable: boolean)
CountlyConfig.setIfStarRatingShownAutomatically(isShown: boolean)
CountlyConfig.setStarRatingDisableAskingForEachAppVersion(isDisabled: boolean)

Instance Methods

CountlyInstance.recordRatingWidgetWithID(widgetId: String, rating: int, email: String, comment: String, userCanBeContacted: boolean)
CountlyInstance.presentRatingWidgetWithID(widgetId: String, closeButtonText: String, callback: FeedbackRatingCallback)
CountlyInstance.showStarRating(callback: StarRatingCallback)

General Information

The legacy star rating is a native 1-to-5 dialog that can be shown manually via showStarRating or automatically when the session count reaches a configured threshold. The threshold is tracked per app version by default (configurable to once per app lifetime via setStarRatingDisableAskingForEachAppVersion).

The legacy rating widget methods (recordRatingWidgetWithID, presentRatingWidgetWithID) interact with server-created rating widgets using the older /o/feedback/widget API. The presentRatingWidgetWithID method checks device type targeting (phone/tablet/desktop) before display.

Implementation Details

For instance method recordRatingWidgetWithID

CountlyInstance.recordRatingWidgetWithID(widgetId: String, rating: int, email: String, comment: String, userCanBeContacted: boolean)

// Consent
Requires "star-rating" consent.

// Validation
widgetId must be a non-empty string (reject otherwise).
rating is clamped to the range [1, 5].

// Logic
Records an event with key "[CLY]_star_rating" and the following segmentation:
  - widget_id: provided widget ID
  - rating: clamped rating value
  - contactMe: userCanBeContacted value
  - email: provided email
  - comment: provided comment
  - platform: current SDK platform
  - app_version: current app version

For instance method presentRatingWidgetWithID

CountlyInstance.presentRatingWidgetWithID(widgetId: String, closeButtonText: String, callback: FeedbackRatingCallback)

// Consent
Requires "star-rating" consent.

// Logic
1. Fetches the widget configuration from /o/feedback/widget?widget_id=[widgetId] with common params.
2. Checks the widget's device type targeting (phone, tablet, desktop) against the current device.
   If the current device type is not in the target list, the widget is not shown.
3. If targeting passes, displays the widget in a WebView with the provided closeButtonText
   for the native close button.
4. Calls the callback on completion or error.

For instance method showStarRating

CountlyInstance.showStarRating(callback: StarRatingCallback)

// Consent
Requires "star-rating" consent.

// Logic
Displays a native 1-to-5 star rating dialog.
The dialog title, message, and dismiss button text are configurable via CountlyConfig
(defaults: title="App Rating", message="How would you rate the app?", dismiss="Dismiss").
If the user selects a rating:
  - Records an event with key "[CLY]_star_rating" and segmentation:
    platform, app_version, rating (1-5).
  - Calls the callback with the selected rating.
If the user dismisses without rating, no event is recorded.

All legacy star rating and rating widget operations require "star-rating" consent:

  • recordRatingWidgetWithID
  • presentRatingWidgetWithID
  • showStarRating
  • Automatic star rating display on session count threshold

Networking and Params

Legacy rating widget config

Fetches the rating widget configuration to check device type targeting before display.

curl --request POST \
  --url 'https://YOUR_SERVER/o/feedback/widget' \
  --header 'Content-Type: application/x-www-form-urlencoded' \
  --data 'widget_id=WIDGET_ID' \
  --data 'app_key=YOUR_APP_KEY' \
  --data 'device_id=DEVICE_ID' \
  --data 'sdk_version=SDK_VERSION' \
  --data 'sdk_name=SDK_NAME'

Storage

Star rating preferences are persisted locally using platform-specific storage (e.g., SharedPreferences on Android, localStorage on Web). The following values are stored:

  • Session count for automatic display threshold tracking
  • Whether the star rating has been shown for the current app version
  • The app version for which the star rating was last shown
  • Custom dialog text (title, message, dismiss button text)

Remote Config (Legacy)

First off, interaction with the Countly Server for the Remote Config feature should be done after you have checked the available API information. The Remote Config API documentation for legacy remote config API includes information about an earlier implementation. This legacy API uses 'method=fetch_remote_config' inside the request URL while fetching the remote config object and enrolling the user to A/B testing automatically.

The latest API, on the other hand, fetches the remote config object while giving us the ability to enroll the user in the A/B testing or not. This can be done by utilizing 'method=rc' and 'oi=1'(opting in/enrolling the user) or 'oi=0'(opting out/not enrolling the user) in the request URL.

There is also another API that is used for enrolling the user to A/B testing for the selected remote config keys without fetching the remote config object. This can be done by using the 'method=ab' in your request URL.

Their usage can be seen below:

// legacy API
o/sdk?method=fetch_remote_config&metrics=...&app_key=app_key&device_id=device_id...(optional params: keys, omit_keys)

// latest API for remote config
o/sdk?method=rc&metrics=...&app_key=app_key&device_id=device_id...(optional params: keys, omit_keys, oi)

// enrolling users
o/sdk?method=ab&keys=...&app_key=app_key&device_id=device_id

There are currently 2 init time config flag associated with these APIs: rcAutoOptinAb (true by default) and useExplicitRcApi (false by default). These flags enable users to change between APIs and control the A/B testing enrolling process. useExplicitRcApi lets the user use the latest API if it is set to true. rcAutoOptinAb decides if auto enrolling (oi=1) must be on or not (oi=1) when using the latest API. Setting it to false should automatically trigger the usage of the latest API and should log a warning to the user.

Automatic Fetch

The Remote Config feature allows app developers to change the behavior and appearance of their applications at any time by creating or updating custom key-value pairs on the Countly Server.

There should be a flag upon initial config to enable the automatic fetching of the remote config upon SDK start. If this flag is set, the SDK will automatically fetch the remote config from the server and store it locally. A locally stored remote config should reflect the server response as is, overwriting any existing remote config. No merging or partial updating. Automatic fetching will be performed only upon SDK start, not with every begin session. There should also be a callback on the initial config to inform the developer about the results of automatic fetching the remote config.

e.g. config.enableRemoteConfig = true;

Manual Fetch

There should be a method/function to fetch the remote config manually anytime the developer would like. Just like with automatic fetch, this method will fetch the remote config from the server and store it locally. A locally stored remote config should reflect the server response as is, overwriting any existing remote config. No merging or partial updating. This method should take a callback argument to inform the developer about the results of manually fetching the remote config. Callback on initial config should not be affected by manual fetchings, as it is for automatic fetchings only.

e.g. updateRemoteConfig(callback(){ })

Getting Values

There should be a method to get remote config values for a given key. It will return the value for a given key. If the key does not exist, or the remote config has yet to be fetched, this method should return nil or null or however the platform handles the absence of values. If the server is not reachable, this method should return the last fetched and locally stored value if available.

e.g. remoteConfigValueForKey(key)

Keys and Omit Keys

There should be 2 additional methods for manual fetching: one for specifying which keys will be updated and one for specifying which keys will be ignored.

These methods should take an array of keys as an argument, in addition to callbacks, and send requests with keys= or omit_keys= query strings. For the result of these requests, only the keys in the response should be updated in local storage, not a complete overwrite as with an automatic or standard manual fetch.

e.g. updateRemoteConfigForKeysOnly(keys, callback(){ }) e.g. updateRemoteConfigExceptKeys(keys, callback(){ })

Example case: Local storage reflecting server as is (after an automatic or manual fetch):

{
  "a": "x",
  "b": "y",
  "c": "z",
}

Calling update for specified keys only:

updateRemoteConfigForKeysOnly(["a"], callback(){ });

Response:

{
  "a": "xx",
}

Local storage:

{
  "a": "xx",
  "b": "y",
  "c": "z",
}

A/B Testing

There should be a call to enroll users in the A/B testing without triggering any other action. This call should be named something similar to enrollUserToAb and should have one parameter named keys. This parameter should be an array of string key values. If an empty array or no value at all is provided the function should be terminated and should give an error log to the developer. Else the keys should be stringified and added to the request as 'keys=stringified_values_here'. The response can be parsed and put out as a log. If all is good the response's result field should return something like "Successfully enrolled the user to given keys".

Consents

In case where the consentRequiredflag is set upon initial config, remote config actions should only be executed if the "remote-config" consent is given. Additionally, if sessions consent is given, the remote config requests will have metrics info, similar to begin session requests.

Device ID Change

After a device ID change, the locally stored remote config should be cleaned, and an automatic fetch should be performed if enabled upon initial config.

Salt

Remote config requests need to include the checksum if enabled upon initial config. As with all other requests, only the query string part will be used to calculate hash.

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

Looking for more Help?