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.
Conventions
These rules apply to every method and feature in this guide. The feature sections state only what differs from them.
- Method descriptions - each method shows its signature, then its valid values when they need more than the rules below, then its logic. A config method is an option of the config object (see Init Configuration); an instance method is a call on an initialized SDK. Function and argument names are examples (see General SDK Structure Overview).
- Required values - the main value of a call must be valid: a key, name, ID or URL is a non-null, non-empty string, and the exception, callback, list or map that a method records, sets or registers is not null. A call with an invalid required value is ignored with a warning; a method that returns a value then returns null, or the platform's empty value. No public method throws. The server URL and the app key are the exception: a missing or invalid one is an error, because the SDK can't work without it (see Log Levels).
- Optional values - other parameters, such as segmentation maps and callbacks passed to a call, may be null, which means "not given". In a list of keys or names, an invalid item is ignored with a warning, and the other items are used.
- Segmentation values - segmentation and similar maps accept strings, numbers, booleans and arrays of those. Other value types are removed with a warning. The internal limits apply (see SDK Internal Limits).
- Consent - while consent is required, a feature's calls need that feature's consent, named in its Consent part. Without it, a call is ignored with a log, and a callback it was given gets an error.
- Offline mode - data recorded in offline mode is queued under a placeholder device ID and sent when offline mode ends. Calls that need an answer from the server, such as downloads, content and feedback widgets, don't run, and a callback they were given gets an error. Methods that differ say so (see Device ID Management).
- Before init and after halt - calls follow the rule for calls before init (see Initialization in General SDK Structure Overview).
- Bridge SDKs - an SDK that wraps a native SDK offers the same calls and passes them on (see Bridge SDKs in SDK Specifics).
-
Request examples - every request is a POST with its parameters in the body (see Using GET or POST). The curl examples show only the request's own parameters: each request also carries the base request parameters (see Making Requests) and, when a salt is set, the checksum. curl sends
--dataas an "application/x-www-form-urlencoded" POST by itself.
Integration
Initialization
To start making requests to the server, the SDK needs 3 things: the URL of the server where it will make requests, the app key of the app it reports to, and a device ID that identifies this device or user. The server URL and the app key must be given by the developer. The device ID may be given by the developer; if it is not, the SDK generates one.
A problem in this setup must never stop the app. When a required value is missing or invalid, the SDK logs an error and does not start: it does not throw. Every later call to the SDK is then ignored with a warning log, and the app keeps working without analytics.
Server URL - The SDK needs to let the developer specify the URL of the server where their Countly instance is installed. This URL is used for all requests. The SDK must validate the server URL during init:
- The URL must be non-null, non-empty, and a valid absolute URL with an "http" or "https" scheme. Otherwise the SDK logs an error and does not start.
- Trailing "/" characters must be removed automatically, with a warning log, so that endpoint paths such as "/i" and "/o/sdk" can be appended safely.
- If certificate or public key pinning is enabled, the URL must use "https". A URL with "http" is then treated as invalid: the SDK logs an error that explains why and does not start.
App Key - The app key must be provided by the developer. It identifies the dashboard application that the data belongs to. The app is created on the Countly server, and after creation the server shows its app key. The same app key is used for the same app on different platforms. The app key must be non-null and non-empty, and it must not be a placeholder from the integration snippet or code generator output, such as "YOUR_APP_KEY", because data sent with it is lost. Otherwise the SDK logs an error and does not start.
Device ID - A device ID is required to uniquely identify a device or user. The developer may provide one in the config, for example their own user ID. If the developer does not provide one, the SDK must generate a random identifier (a UUID is recommended) and store it persistently, so that the same device keeps the same ID across app starts. Do not use advertising identifiers or hardware identifiers as the default device ID: they need user permission or are restricted by app store and privacy rules on most platforms, and they can be reset or shared across apps. A device ID stored by an earlier run takes precedence over the one given in the config, unless the developer asks to clear the stored ID. The SDK can also start without a real ID in offline mode. All these cases are described in Device ID Management, and the exact init behavior is in its init rule.
Platform-specific fields - Some platforms need more than these 3 values, for example the application context on Android. If a required platform field is missing, the SDK logs an error and does not start.
Init Configuration
The SDK takes a config (configuration) object at init. Every initial setting, including the tweakables such as queue sizes and thresholds, is set through it, rather than with calls made before init. Feature options can be grouped in sub-config objects, for example CountlyConfig.crashes, CountlyConfig.views, CountlyConfig.apm, CountlyConfig.content and CountlyConfig.sdkInternalLimits.
The SDK reads the config object once, during init. Changing the config object after init must have no effect; values the SDK keeps (for example custom headers) should be copied at init. If a value must be changeable while the SDK runs, provide a separate runtime method for it.
Values in the config object are not always final. The server can override several of them through SDK Behavior Settings, with this order of precedence: SDK default < value set by the developer in the config < behavior settings given by the developer in the config < behavior settings received from the server and stored. The options below name the behavior setting key that can override them, where there is one.
Invalid values - a config value of the wrong type, or outside an option's valid range, is ignored with a warning, and the option keeps the value it had: the default, unless it was set before. The SDK never raises, lowers or cuts a value to make it fit. Values from SDK Behavior Settings are checked the same way, but an invalid server value counts as missing: the developer's value, or the default, applies, not the previous server value (see SDK Behavior Settings). The SDK internal limits follow the same rules (see SDK Internal Limits).
For more information about the values that can be sent to the server, see the API reference below:
Networking Configuration
The following config options control how the SDK communicates with the server. All requests are sent as POST (see "Using GET or POST" in SDK Storage and Requests), so there is no option to choose the HTTP method. The request drop age, the failed-request cool-down and the salt are in the table below.
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 for routing through proxies. The SDK copies the map during init, so changing the map afterwards has no effect. To change headers while the SDK runs (for example to rotate a token), expose a runtime method such as requestQueue().addCustomNetworkRequestHeaders(headers).
CountlyConfig.enablePublicKeyPinning(certificates: String[])Logic: Enables SSL public key pinning. The SDK will only accept a server connection if the public key of the server's SSL certificate matches the public key of one of the provided certificates. Each certificate is the base64 text between "-----BEGIN CERTIFICATE-----" and "-----END CERTIFICATE-----", without line breaks. The server URL must use "https" (see Initialization). On platforms where certificates are usually bundled with the app as files, the SDK may take the file names instead (for example DER files ending in .der, .cer or .crt).
CountlyConfig.enableCertificatePinning(certificates: String[])Logic: Enables SSL certificate pinning. The SDK will only accept a server connection if the server's full certificate matches one of the provided certificates. The certificate format and the "https" rule are the same as for public key pinning. Public key pinning is usually preferred, because it keeps working when the certificate is renewed with the same key.
CountlyConfig.setRequestTimeoutDuration(timeout: int)Logic: Sets the network request timeout in seconds, at least 1. Default is 30 seconds. Where the platform's HTTP client has separate connect and read timeouts, apply the value to both.
SDK Tweakables
The following config options control the SDK's timer and storage writes. The queue sizes and other thresholds are in the table below.
CountlyConfig.setUpdateSessionTimerDelay(delay: int)Logic: Sets the interval in seconds of the SDK's update timer. On each tick the SDK
- sends a session update (session_duration) when automatic session tracking is on,
- saves pending user profile changes,
- moves the queued events into a request,
- tries to send the stored requests (unless a failed-request cool-down or a backoff pause is running),
- persists the health check counters.
At least 1. Default is 60 seconds; on watchOS, where apps run only briefly, 20 seconds. Server override: SDK Behavior Settings key "sui".
CountlyConfig.enableExplicitStorageMode()Logic: By default the SDK writes the request queue and the event queue to persistent storage on every change. This option stops those automatic writes: the queues are kept in memory and are written only when the developer gives the explicit write signal through a runtime method such as requestQueue().esWriteCachesToPersistence(). If the signal is not given at the right time, data can be lost (app killed before the write) or sent twice. Offer it only on platforms where storage writes are expensive enough to need it.
Web View UI
Some features show server-made pages in a web view: content, feedback widgets and the legacy rating widget. These options control those web views.
CountlyConfig.disableWebView()Logic: Turns off every web view the SDK would create or show. Web views are allowed by default.
- Content: the SDK doesn't enter the content zone. enterContentZone, refreshContentZone and previewContent are ignored with a warning, and the "ecz" behavior setting has no effect
- Feedback widgets: presentFeedbackWidget, presentNPS, presentSurvey and presentRating show nothing, and onFinished gets an error. Getting the widget list, getting widget data and reporting a widget manually keep working, so the app can still show widgets in its own UI
- Legacy rating widgets (presentRatingWidgetWithID) are not shown. The native star rating dialog is not a web view and keeps working
- Meant for apps that don't use these features and want no web view code to run, for example to pass a security review
- A build that leaves out the web view code (the "-nw" builds, see SDK Metadata) behaves as if this option were set
CountlyConfig.setWebviewDisplayOption(option: WebViewDisplayOption)Logic: Chooses how content and feedback widgets are placed on the screen: IMMERSIVE (the default, full screen) or SAFE_AREA (inside the device's safe area). See "Display Behavior" in Content Zone.
Options Described With Their Features
These config options are described in the sections of their features. The table lists them so that the config object covers them all.
| Option | Purpose | Default | Server key | Described in |
|---|---|---|---|---|
setRequestDropAgeHours(hours: int) |
Drops stored requests older than this many hours | 0 (off) | dort | Request Drop by Age |
setFailedRequestCooldown(seconds: int) |
Wait after a failed request before it is tried again | 60 seconds | - | Failed Requests |
setParameterTamperingProtectionSalt(salt: String) |
Adds the "checksum256" to every request | no salt | - | Parameter Tampering |
setEventQueueSizeToSend(threshold: int) |
Number of events that makes a request | 100 | eqs | Event Queue |
setMaxRequestQueueSize(size: int) |
Most requests the queue keeps | 1000 | rqs | Queue Size Limit |
disableGradualRequestCleaner() |
Trims an overfull queue in one step | gradual trimming | - | Queue Size Limit |
enableManualSessionControl() |
The app begins, updates and ends sessions | automatic sessions | ast | Sessions |
enableManualSessionControlHybridMode() |
Manual begin and end, automatic updates | off | - | Sessions |
disableBackoffMechanism() |
No pause when the server answers slowly | backoff on | bom | Backoff Mechanism |
|
Features work only after their consent is given | false | cr | User Consent |
setConsentEnabled(featureNames: String[]), giveAllConsents()
|
Consent given at init | none | - | User Consent |
|
Location values used from init on | none | lt | Recording Location |
disableLocation() |
Starts with location tracking off | location on | lt | Recording Location |
|
Replaces device metrics the SDK collects | none | - | Device Metrics |
|
User properties saved at the end of init | none | - | User Profiles |
setDeviceId, enableOfflineMode, enableClearStoredDeviceId
|
Device ID at init | generated ID | - | Device ID Management |
setLoggingEnabled, setLogListener, disableSDKLoggingInProduction
|
Console logs, log listener, logs in production builds | off | - | Logging / Debug Mode |
sdkInternalLimits: setMaxKeyLength, setMaxValueSize, setMaxValueSizePicture, setMaxSegmentationValues, setMaxBreadcrumbCount, setMaxStackTraceLinesPerThread, setMaxStackTraceLineLength
|
Size limits of recorded data | see section | lkl, lvs, lsv, lbc, ltlpt, ltl | SDK Internal Limits |
setSDKBehaviorSettings, disableSDKBehaviorSettingsUpdates
|
Settings used until the server answers; no fetches | - | - | SDK Behavior Settings |
disableHealthCheck() |
No health check request | health check on | - | Health Checks |
setDirectAttribution, setIndirectAttribution
|
Attribution recorded at the end of init | none | - | Attribution |
setShouldIgnoreAppCrawlers, setAppCrawlerNames (app SDKs) |
Creates no data on known app crawlers | off | - | Bot/Crawler Detection |
| Sub-configs: crashes, views, content, push, heatmaps, remote config, star rating, experimental, APM | Feature options | - | - | Exposed Methods of each feature |
Logging / Debug Mode
The SDK should have a logging flag in the init config (for example setLoggingEnabled(true)). It is off by default. If it is enabled, the SDK prints its logs to the console. If it is not enabled, no logs are printed to the console.
This flag functions independently of the log listener and of log gathering (both described below). The table shows where a log line goes:
| 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 |
Other rules for the flag:
-
Production builds - console logs of a published app can be read by other apps or by anyone with the device, and they slow the app down. Where the platform can tell a debug build from a production (release) build, the SDK offers a switch in the init config, for example
disableSDKLoggingInProduction(), that keeps console logs off in production builds even when the flag is on. The switch is off by default, so without it the flag alone decides. Browser and server SDKs, which have no such build types, don't offer it. The switch doesn't affect the log listener or log gathering. - Health check counts - every error and warning log must be counted for the health check, whether or not the line is printed anywhere. Counts made before the health check module is ready must be kept and added to it, not lost.
- No wasted work - when nothing wants a log line (no console, no listener, no gathering for that level), the SDK should not build the message string at all.
Log Messages
No two places should have the same log message. All messages should be unique: knowing the log message and the SDK version, it should be unambiguous which line printed that message.
To help with that, the log messages could include the internal "module" or "section" name from where they were called. If the function called is part of the public API of the SDK, the log should include the function name.
[ModuleName] functionName, Some messageIf there is a message tag/group mechanism on the platform, the tag name "Countly" should be used. If there is no tag/group mechanism available, the name "Countly" should be added to each message.
[Countly] [ModuleName] functionName, Some messageAll calls that can be called by developers should produce a log message to indicate what is being called.
The goal is to have good enough log coverage so that it's easy to understand what was happening with the SDK and how it was used when an SDK integrator provides their logs.
Logs must not contain secrets that the integrator would not want to share: do not print the parameter tampering salt or the values of custom request headers (they often carry tokens). When the SDK runs several instances, each line should show which instance printed it, for example by adding the instance name or the app key to the line.
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 developer was doing with the SDK and in what order the calls were made. "Debug" and above should give a good enough look into the SDK's internal state and a good overview of its configuration.
The SDK may offer a minimum log level in the init config, so that lower levels are not printed or passed to the listener. If it does, the default should be Debug, so that only the noisy Verbose lines are left out and the call order and configuration stay visible.
Platforms that don't have explicit log levels should print the level manually at the start of the log, in square brackets. Any of these variants is fine, as long as it's consistent within the SDK:
- [ERROR], [WARNING], [INFO], [DEBUG], [VERBOSE]
- [Error], [Warning], [Info], [Debug], [Verbose]
- [error], [warning], [info], [debug], [verbose]
If there are the same number of log levels available but with different names, the closest ones available for the platform should be picked.
If there are fewer than 5 log levels available, multiple levels should be printed in the same platform level, and the appropriate tag in square brackets should be printed at the start.
For example, if there are only 3 levels "error", "info" and "debug", then "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 wayDepending on the function call, the function parameters should also be printed, enclosed in square brackets. For example, event keys or 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 whether a callback was provided.
Log Listener
Sometimes an integrated SDK does not behave as expected in a release build that is used by clients. In those cases it would be valuable to see the inner workings of the SDK, but the SDK logs can't be accessed. It is also not recommended to print those logs in a published app, as they might leak private info to other apps on the same device.
This is why the log listener feature exists. During init, the developer can set up a log listener, for example a callback (setLogListener). If such a listener is set, logs are passed to it, even when the logging flag is off. For every log, the listener receives 2 values: first the message string, and then the log level of that message, so that it can be used as an indication of the message's importance. Because the level is passed separately, the message should not need a level prefix.
The listener runs inside the SDK's own code paths:
- A listener that throws must not break the SDK. Wrap each call and report the failure to the console only.
- The listener may be called from any thread, sometimes while the SDK holds an internal lock. Document that it should return quickly and must not call back into the SDK.
Log Gathering
Log gathering lets a Countly server operator collect an SDK's logs from a specific user's device, for support and debugging, without asking the integrator for a new build. It is off until the server asks for it, and it works independently of the logging flag and the listener.
-
Turning it on - the server sends the top-level key "lg" in the SDK Behavior Settings response, next to "c", "v" and "t":
{"e": enabled, "i": gather ID, "l": levels, "b": batch size}. "l" is a string of level letters from "ewidv" (error, warning, info, debug, verbose). "b" is the number of lines per upload: default 100, minimum 10, maximum 500. When "e" is true but there is no gather ID, the SDK must not gather, because the server rejects batches without one. - Only a live answer decides - "lg" is read from the answer of a fetch and never stored, so stored or provided behavior settings never turn gathering on. The server sends "lg" only while a gather is running for that user, always with "e" true; it doesn't send "e" false. A live answer without a usable "lg" therefore means "no gather": the SDK stops gathering, as it does for "e" false.
- Stopping - when a gather stops, the SDK sends the lines it still holds once, under that gather's ID; the server still accepts this last batch for a short while after the operator stopped the gather. When "lg" brings a different gather ID, the lines held for the previous gather are dropped, because the server would reject them. A failed fetch leaves a running gather as it is.
- Before the first answer - until the first live answer arrives, nothing is decided. The SDK may keep the lines of this period provisionally, at every level, so that a gather started for this user also shows how the SDK started. If the answer turns gathering on, the SDK keeps the provisional lines of the requested levels. If the answer has no "lg", if the fetch fails, or if no fetch will be made in this run (behavior settings updates disabled, offline mode), it discards them.
- Collecting - the SDK keeps a copy of each log line of a requested level in memory, without the level prefix. Lines longer than 4,096 characters are cut. The buffer holds at most 500 lines, the largest batch the server may ask for. It also needs a character limit, for example 128 KB, because a single line can be 4,096 characters long. When the characters held come close to that limit, the SDK sends what it holds as a smaller batch. Lines that don't fit are dropped and counted.
-
Uploading - when a batch is full, the SDK queues a request with the parameter
sdk_logs={"i": gather ID, "d": dropped line count, "l": [{"t": timestamp in ms, "l": level letter, "m": message}, ...]}. The server accepts the batch only if the gather ID matches the gather that is running for that user. - No loops - log lines that the SDK writes while building or sending a log upload must not be gathered, otherwise every upload would create the next one.
- Consent and privacy - while consent is required, a gathered line about a feature must not be uploaded until consent for that feature is given; until then it is held separately. A line that belongs to no feature needs both the "events" and the "users" consent. When the device ID changes without merge, the SDK first sends the lines it holds under the old device ID and then stops gathering; the answer of a fetch that was already on its way when the ID changed belongs to the previous user, and its "lg" is ignored. A change with merge keeps the gather, because the server moves it to the merged user. When the SDK is halted, gathering pauses until the next start. These rules keep one user's logs out of another user's profile.
SDK Storage and Requests
Making Requests
The Countly server is an HTTP-based REST API server. The SDK sends all recorded data to the /i endpoint, with two required parameters: app_key and device_id. Requests that read something from the server (remote config, A/B testing, feedback widgets, SDK Behavior Settings, content) go to other endpoints, mainly /o/sdk; see Immediate Requests.
Other optional parameters need to be provided based on what the request should do. All the parameters that the /i endpoint accepts are listed in the API reference:
Some parameters should be added to all requests, even though the server does not require them. Together with the required parameters, they form the base request, and every request sent to the server is built from it. The parameters of the base request are:
- "app_key" - the application key of the Countly app (shown on the dashboard)
- "device_id" - the current device ID
- "timestamp", "hour", "dow" and "tz" - when the data was recorded: the time in milliseconds, the local hour, the local day of the week and the offset from UTC (see Recording Time of Data)
- "sdk_version" - the SDK's version (see SDK Metadata)
- "sdk_name" - the SDK's name
- "av" - the application's version string (the overridden value if the developer set one with setMetricOverride)
- "t" - the device ID type: 0 - provided by the developer, 1 - generated by the SDK, 2 - offline mode (temporary ID), 3 - taken from the page URL (browser SDKs). The value 0 makes the server mark the user as known (identified), which is what the dashboard counts as identified users.
- "rr" - the number of requests waiting in the request queue, not counting this one. It is added when the request is sent, not when it is created: a queued request carries the number of requests behind it, an immediate request the number of requests in the queue at that moment. The server keeps the latest value per user, so that SDK Manager can show how many requests are waiting on devices.
Two parameters are added only at send time: "rr" and, when a salt is set, "checksum256". Because "rr" changes while the request waits, the checksum must be computed after "rr" is added (see Parameter Tampering (checksum256) Algorithm).
Encoding URI Components
SDKs must percent-encode (UTF-8) every parameter value before adding it to the request. JSON values such as "events", "metrics", "segmentation" and "user_details" are first serialized to a string and then encoded. Without encoding, an "&", "=", "+", "#" or "?" inside a value would split or cut the request.
Immediate Requests
Not every request goes through the request queue. Requests whose answer the app is waiting for are sent immediately and are not stored: fetching remote config values, A/B testing variants and experiment information, enrolling into a variant for testing (testingEnrollIntoVariant), getting the feedback widget list and widget data, fetching SDK Behavior Settings, fetching content, and the health check. For these requests:
- They are built from the full base request described above, like queued requests, and follow the same checksum rule.
- An answer counts only with a 2xx status. What else makes an answer valid is described with each feature.
- They are not stored or retried. If one fails, the SDK reports the failure to the feature (and to the developer's callback, if there is one) and drops it.
- They are not affected by the backoff mechanism or by the failed-request cool-down of the queue.
- They must not be sent while offline mode is on, because the server would create a user for the placeholder ID.
- They need the consent of their feature. The SDK Behavior Settings fetch and the health check need none (see User Consent).
- They are not sent while the server's SDK Behavior Settings turn networking off. The behavior settings fetch is the one exception: it still goes out, because otherwise the server could never turn networking back on.
A crash report must never be lost. The SDK stores it in the request queue; because the app may terminate right after a crash, an SDK may first try to send the report immediately and queue it only if that attempt fails.
Using GET or POST
Every request is sent as an HTTP POST, with the parameters in the request body, encoded as "application/x-www-form-urlencoded". File uploads (the user profile picture) are sent as "multipart/form-data".
The SDK never puts the request data into the URL; only the endpoint path is there (for example https://example.count.ly/i). This avoids the URL length limits of servers and proxies, and keeps the data, which can include personal data such as email addresses, out of their access logs.
This applies to every request the SDK sends to the Countly server, queued or immediate, with one exception: the page and file probes of a connection test use GET, because they check what a web view loads (see Connection Test). Pages and files that a web view loads, such as feedback widget and content pages and their images, are not SDK requests: the web view loads them the way a browser does.
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], lowercase words separated by hyphens. Example: &sdk_name=objc-native-ios
The origin is "native" for an SDK written for its platform, or the name of the bridge, for example java-native-android, dart-flutterb-android (Flutter bridge) or js-rnb-ios (React Native bridge). The name must be unique among Countly SDKs.
A bridge SDK sets the name and version of the native SDK it wraps to its own, so that the server sees the bridge and the bridge's version.
- SDK version:
Query String Key: sdk_version Query String Value: SDK version as string Example: &sdk_version=26.8.0
The SDK version decodes to [year].[month].[patch]. The year and month are those of the SDK major release the version belongs to, independent of the server's version: for example 26.8 for the major release planned for August 2026. The first release of a major is [year].[month].0, and every later release before the next major increases the patch number: 26.8.1, 26.8.2. The year and month stay the same even if the release ships a few months after the planned month.
No zero is added before the month when it is lower than 10: 26.8.0, not 26.08.0.
A build that leaves out a feature is published with a suffix on its package version, for example "-np" (no push notifications) or "-nw" (no WebView UI). It still sends the plain version number in sdk_version, and appends the suffix to sdk_name instead, for example java-native-android-nw or js-rnb-android-np.
Storage
Some things in the SDK are stored persistently, so that they survive app restarts and updates: the request queue, the event queue, the device ID and its type, and feature data (see Other Storage). They are stored in the platform's private app storage.
Persistent values are segmented by SDK instance: each named instance (see Multi-Instance Support) keeps its own storage, keyed by its name, and the shared instance uses the SDK's base storage. Two instances in one process must never read each other's data, even when they use the same app key.
Stored requests keep the app key they were created with. If the developer changes the app key, requests already in the queue still go to the old app, unless the developer rewrites or removes them with the queue operations.
Request Queue
In some cases, users might be offline and unable to make requests to the server. In other cases, the server may be down or in maintenance and unable to accept requests. In both cases, the SDK should queue and persistently store requests made to the Countly server, and it should wait for a successful response from the server before removing a request from the queue.
Requests must be sent in the order they were created, so the order of the queue must be preserved.
The flow of a request is:
- Initiating request - for example a session call, a batch of events, or user details.
- Creating a payload - take the base request parameters (with the current time) and the parameters of the feature, and build the payload of the HTTP request.
- The payload is inserted at the end of the queue (First In, First Out).
- All updates to the queue are stored persistently. Depending on the environment, the storage can be used directly as the queue.
- On another thread, a request processor takes the first request in the queue:
- It stops, without sending anything, while offline mode is on or while the server's SDK Behavior Settings turn networking off.
- It removes the request without sending it if the request is older than the drop age (see Request Drop by Age), and moves on to the next request.
- It removes internal tags (see Custom Endpoint Override), adds "rr", applies the checksum if a salt is set, and sends the request as POST.
- If the request is successful (defined below), it is removed from the queue, the backoff check runs, and the next request is processed.
- If the request failed, it stays first in the queue, and the processor waits for the cool-down before trying it again (see Failed Requests).

A request might fail for several reasons. A request counts as successfully delivered only if all of the following are true:
- The HTTP response code was successful (any 2xx code, 200 <= x < 300).
- The response body is a JSON object.
- That JSON object contains the field "result" (there can be other fields).
When a request is sent to a Countly server, it responds with a JSON object that has a property named "result". Usually that value is "Success". There may be scenarios where a different "result" value is returned or where additional fields are added.
If all of these are true, the request was delivered to the server and can be removed from the queue.
Failed Requests
A failed request stays first in the queue, and nothing behind it is sent, so the order is kept. All of these count as failures: network errors and timeouts, any status code outside 2xx (4xx included), and a body that is not a JSON object with "result". In every case the request is kept; the SDK never deletes a request because of the server's answer.
After a failure, the SDK waits a fixed cool-down (setFailedRequestCooldown, at least 1 second, 60 seconds by default) and then tries the same request again. Requests queued during the cool-down and update timer ticks do not trigger an earlier attempt. When the server answers, but slowly, the backoff mechanism applies instead.
Because rejected requests are kept, a request that the server will never accept blocks every request behind it, until the queue size limit or the drop age removes it. The most common cause is a wrong app key or a wrong salt: in both cases the server answers 400 "App does not exist". Because the checksum is computed when a request is sent, fixing the salt in an app update lets the waiting requests through. The SDK reports the failed status code and error in its health check, where they can be seen in SDK Manager.
Queue Size Limit
The queue size must be limited, so that it doesn't overflow, and so that syncing up won't take too long if the server has been down for a long time. The limit is a number of stored requests, and the developer must be able to change it in the SDK config (setMaxRequestQueueSize, at least 1). The server can override it with the SDK Behavior Settings key "rqs".
When a new request arrives and the queue is full, the SDK first removes the requests that are older than the drop age (if one is set). If the queue is still full, it removes the oldest requests: by default at most 100 of the overflow, plus one to make room for the new request. A queue that is far over its limit, for example after the server lowered "rqs", therefore shrinks step by step with each new request, instead of losing a large backlog in one go while it is still being sent. With disableGradualRequestCleaner(), the SDK removes the whole overflow at once. The default limit is 1,000 requests.
Request Drop by Age
SDKs should support dropping requests that are older than a configurable age in hours (setRequestDropAgeHours, 0 or more; 0, the default, turns it off). The request's "timestamp" is compared with the current time when the request is about to be sent and when the queue is full. A request without a timestamp is kept. The server can set the age with the SDK Behavior Settings key "dort".
Request Queue Storage Format
The storage format is platform-specific but must meet these requirements:
- Each stored request contains everything needed to send it later, including the values captured when it was created (app key, device ID, time parameters). It can be stored as a serialized query string or as an object of parameters.
- The storage must survive app restarts and app updates, and a write must not leave a half-written queue behind (write a new copy and replace the old one, or use a database).
- Use the platform's private app storage, for example SharedPreferences or a database on Android, a file in the app's private directory on iOS, and localStorage in a browser.
- A browser SDK keeps the requests of a tab in that tab's sessionStorage while the page is open and sending works, and moves them to localStorage when they cannot be sent for now (for example in offline mode) and when the page is being left, so that another tab or the next page load can send them.
- A browser SDK lets the developer choose where it stores its data, or not to store it at all (see Storage Modes).
- Storage is segmented by SDK instance (see Storage).
Parameter Tampering (checksum256) Algorithm
// Algorithm: SHA-256(request_body + salt)
// The salt is a shared secret, set both in the SDK config and in the app's settings on the server
1. Build the full request body exactly as it will be sent, including the
send-time parameter "rr" (e.g., "app_key=xxx&device_id=yyy&...&rr=0")
2. Append the salt directly: requestBody + salt
3. Compute the SHA-256 hash of the combined string
4. Convert it to a hex string
5. Append "&checksum256={hex_hash}" to the body as the last parameter
// File uploads (multipart/form-data):
- Build the request data string from the form fields, URL-decode it, hash it
together with the salt, and send the result as the form field "checksum256"
// How the server checks it:
- Only when a salt is set for the app on the server.
- It removes "&checksum256={value}" from the received body, hashes what is
left together with the salt, and compares the result without regard to
letter case. Any parameter order and either hex case are accepted, as long
as the hash covers exactly the body that was sent.
- A wrong or missing checksum is answered with 400 "App does not exist",
the same answer as for an unknown app key (see Failed Requests).
// If no salt is configured in the SDK, no checksum is appended.
// New SDKs use checksum256 only. The server still accepts the older SHA-1
// "checksum" parameter for old SDKs.Bot/Crawler Detection
Analytics data from bots and automated test devices should never reach the server. The SDK detects them before it records anything and then creates no data at all, instead of recording the data and dropping it later.
-
Browser SDKs - must check the user agent (and the user agent client hints, where available) against a maintained list of known bots and crawlers, for example Googlebot, bingbot, Baiduspider, HeadlessChrome and Lighthouse. The check is on by default, and the developer can turn it off (
ignore_bots). A detected bot is treated as an ignored visitor: no requests are created (see Ignoring Visitors). Page loads that the browser only prerenders are ignored as well, because the user may never see the page (ignore_prefetch, on by default). -
App SDKs - may offer an opt-in check of the device name against a list of known app crawlers (
setShouldIgnoreAppCrawlers,setAppCrawlerNames), for example "Calypso AppCrawler". It is off by default.
Custom Endpoint Override
Most requests to endpoints other than /i are immediate requests and never enter the queue. When a queued request must go to another endpoint, the SDK embeds the internal tag &new_end_point= with the path in the stored request data. The request processor removes this tag before sending and uses its value as the endpoint.
Other internal tags, for example &callback_id= that links a queued request to a callback, are handled the same way. All internal tags must be removed before the checksum is computed and the request is sent.
Event Queue
Similarly to the request queue, each SDK should also have an event queue. It combines multiple events into one request (the "events" parameter, a JSON array) to decrease the total number of requests.
This queue is also FIFO, and it is written to persistent storage on every change, so that recorded events survive the app being closed or killed before they are sent. Only enableExplicitStorageMode turns these writes off.
Events are moved from the event queue into a request:
- when the queue reaches the threshold set with
setEventQueueSizeToSend(at least 1, 100 by default; server key "eqs"), - on every tick of the update timer (60 seconds by default), and when the app goes to the background,
- before a request that must come after them is queued: ending a session, sending user details, and changing the device ID. Otherwise the server would process the events after, for example, the session they belong to has ended, or under the wrong user.
A browser SDK, where a page can be closed at any moment, moves pending events into a request on a short internal heartbeat (about every 500 milliseconds), putting at most setEventQueueSizeToSend events into one request.
The event queue holds at most 1,000 events: when it reaches 1,000, the events are moved into a request, also when setEventQueueSizeToSend is higher. No event is dropped.
Other Storage
Some other features also need to store things persistently, each in its own format. For example:
- the device ID and its type,
- the stored SDK Behavior Settings,
- remote config values,
- health check counters,
- star rating state.
They follow the same segmentation rule as the queues.
Recording Time of Data
To properly report and process data, especially queued data, the SDK must provide the time when the data was recorded. It provides 4 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 local day of the week (0 - Sunday, 1 - Monday, ... 6 - Saturday)
- tz: Current user time zone offset in minutes, including daylight saving time (120 for UTC+02, -300 for UTC-05)
As multiple events may be combined in a single request, each event object also gets its own "timestamp", "hour" and "dow", taken when the event was recorded. Events do not carry "tz"; the request's value is used.
These values are set when the data is recorded and must not change while the request waits in the queue or when it is retried.
The millisecond timestamp should be unique: if events are reported in the same millisecond, the SDK should increase the timestamp in the order in which the events were reported. The pseudo-code for the unique millisecond timestamp could look 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;
}The timestamp is always in milliseconds: the request drop age and the backoff age check compare it with the current time in milliseconds.
General SDK Structure Overview
Depending on the SDK's environment and language, a different set of features can be supported. Some features work on any platform, whereas others are platform-specific. For example, a desktop app may not provide telecom operator information.
Note that function and argument names in this guide are only examples. Follow your platform's, environment's and language's best practices when creating and naming functions and variables.
Core features are the minimal set of features that every SDK must support. These features are platform-independent:
- Device ID management, including offline mode
- Sessions (automatic and manual)
- Events, including timed events
- Views
- Crash reporting
- User profiles
- User consent
- Location
- Remote config and A/B testing
- SDK Behavior Settings
- Health checks
- Backoff mechanism
- Parameter tampering protection
- SDK internal limits
- Logging
The following features are required where the platform allows them:
- Push notifications
- Feedback widgets (NPS, surveys, rating)
- Content zone
- Heatmaps and scroll tracking (web)
- Attribution
- Backend mode (server SDKs)
Application Performance Monitoring (APM) is dropped: it is not required, and new SDKs don't implement it.
Initialization
The SDK provides a shared (default) instance, for example Countly.sharedInstance(), which is enough for most apps. Before the SDK can work, the developer initializes it once with a config object that holds at least the server URL and the app key, and optionally a device ID; if no device ID is given, the SDK generates one. See Initialization and Init Configuration under Integration.
CountlyConfig config = new CountlyConfig("https://your.server.ly", "YOUR_APP_KEY");
config.setDeviceId("user-123"); // optional
Countly.sharedInstance().init(config);
- Init is called once. A second call is ignored with a warning log and does not change the settings.
-
Calls made before init. In a browser, the SDK script usually loads asynchronously, so page code may call the SDK before it is initialized. A browser SDK therefore keeps the calls made before init in a queue (for example a
Countly.qarray) and runs them in order after init. Other SDKs ignore a call made before init, with a warning log. -
No crash because of a missing init. A public method must never throw because the SDK is not initialized. A method that returns a feature interface (for example
events()) never returns null: it returns an object whose calls are ignored with a warning, so that chained calls such asCountly.sharedInstance().events().recordEvent("purchase")do not crash the app.
Multi-Instance Support
An SDK may support running several independent instances at the same time, for example when an app reports to its own Countly app and to a partner's Countly app. This is optional. If an SDK supports it, these rules apply:
- Each additional instance has a developer-given name, and that name is its only identity. The config plays no part in it, so two instances may use the same app key or the same server. The shared instance has no name and is always available.
- Each instance has its own config, device ID, session state, consent state, request and event queues, and persistent storage, separated by the instance name. Instances must not share state or interfere with each other.
- Creating an instance with a name that is already in use returns the existing instance, with a warning log; it does not replace it.
- Lifecycle callbacks (foreground and background) are forwarded to all active instances.
- Each instance can be initialized, halted and destroyed independently. Halting or destroying one instance does not affect the others or the shared instance.
A typical multi-instance pattern:
CountlyConfig partnerConfig = new CountlyConfig(partnerServerUrl, partnerAppKey);
Countly partner = Countly.instance("partner");
partner.init(partnerConfig);
// Each instance tracks independently
Countly.sharedInstance().events().recordEvent("event_a");
partner.events().recordEvent("event_b");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, with hooks for: init finished, halt, app going to the foreground and the background, consent change, device ID change (with and without merge), and SDK Behavior Settings change
- Keep its state per SDK instance, with no static or global state, so that multiple instances work
- Be independently testable
- Only activate when its required consent is granted
- React to cross-cutting events (device ID change, consent change, SDK Behavior Settings 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.).
- Public methods return quickly. Network operations and disk I/O are performed off the main/UI thread.
- Callbacks to the developer are delivered on the main/UI thread where the platform convention expects it, and the documentation states which thread a callback runs on.
- The SDK does not hold its internal locks while it calls developer code (callbacks and listeners), so that a callback that calls back into the SDK cannot cause a deadlock.
- An unexpected error inside the SDK must not crash the app: public methods catch it and log it.
Testing Requirements
Code coverage is measured in CI on every pull request and reported with it. A pull request that lowers the coverage fails. Every new feature and every bug fix comes with tests that cover it; a bug fix comes with a test that fails without the fix.
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 are automated and run in CI on every pull request. Coverage reports are kept, so that coverage can be tracked over time.
Halt (Testing Only)
Tests often need a clean SDK: one test ends, and the next one initializes the SDK again with another config. For this, the SDK offers a halt call that returns an instance to the state it had before init. It exists for tests only. It throws away data that was not sent yet, so a released app must never call it.
CountlyInstance.halt()Logic: Returns the instance to the state before init.
- Logs a warning that the call is meant for testing
- Sends nothing: no end_session, no view end events, no pending events or user profile changes. Whatever was not sent is lost
- Stops every timer and running task of the SDK: the update timer, the behavior settings timer, the content zone timer, a backoff pause, a running connection test (its report is dropped) and log gathering (paused until the next start, see Log Gathering)
- A request that is already on its way finishes, but its answer is ignored
- Deletes everything the instance has stored: the request and event queues, the device ID and its type, the stored behavior settings, remote config values, health check counters, push and star rating state, and anything else the features keep
- Clears the in-memory state: consent, timed events, running views, location values, global segmentations, registered callbacks
- Calls made after halt are handled like calls before init (see Initialization)
- In an SDK with several instances, it halts only the instance it is called on, and deletes only that instance's storage
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.clearCrashBreadcrumbs()
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 capture of unhandled crashes. It is off by default. When enabled, the SDK installs the platform's handler for unhandled errors, once per process:
- It keeps a reference to the handler that was installed before it. After recording the crash, it always calls that handler, or the platform's default behavior, so that crash tools installed earlier keep working and the app ends the way it would without Countly.
- It writes the crash report to storage synchronously before handing over, because the process ends right after.
- Every error the handler catches is recorded as unhandled ("_nonfatal": false), also in bridge SDKs that catch errors of their own layer (for example JavaScript or Dart errors).
- If several SDK instances have crash reporting enabled, the one handler records the crash in each of them.
Server override: SDK Behavior Settings key "acr" turns automatic capture off and on again. Key "crt" turns off all crash recording, handled and unhandled.
For config method setCustomCrashSegmentation
CountlyConfig.crashes.setCustomCrashSegmentation(crashSegment: Map<String, Object>)Logic: Sets global crash segmentation, which is added to every recorded crash (handled and unhandled) under "_custom". Internal limits (maxSegmentationValues, maxKeyLength, maxValueSize) are applied. When a record call also provides segmentation, both are merged, and the call's values win for keys that exist in both.
For config method setGlobalCrashFilterCallback
CountlyConfig.crashes.setGlobalCrashFilterCallback(callback: GlobalCrashFilterCallback)Logic: Registers a callback that is called for each crash before it is sent to the server. The callback receives a CrashData object, can change it in place, and returns true to drop the crash. See Crash Filtering.
The callback's signature is GlobalCrashFilterCallback:
GlobalCrashFilterCallback {
boolean filterCrash(CrashData crash)
}
// Parameters
- crash, CrashData object containing all crash-related data, changed in place
// Return value
- true: the crash should NOT be sent to the server (drop it)
- false: the crash should be sent to the serverFor config method enableRecordAllThreadsWithCrash
CountlyConfig.crashes.enableRecordAllThreadsWithCrash()Logic: Enables recording of all threads with crash reports, where the platform can read the stack traces of other threads. When enabled, the SDK appends the 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 (default: 200) caps individual line length
For instance method addCrashBreadcrumb
CountlyInstance.addCrashBreadcrumb(record: String)Logic: Adds a breadcrumb, a short log line that is sent together with the next crash report.
- Breadcrumbs are kept in memory, in the order they were added. When "maxBreadcrumbCount" (default: 100) is reached, the oldest one is removed.
- Each breadcrumb is cut to "maxValueSize" (default: 256) characters.
- When a crash is processed (sent, or dropped by the filter), its breadcrumbs are joined with new line symbols into "_logs", and the list is cleared, so the next crash report starts with a fresh trail.
- Removing the "crashes" consent clears the list.
- For crashes that end the process before a report can be queued (native crashes), see Native Crashes.
For instance method clearCrashBreadcrumbs
CountlyInstance.clearCrashBreadcrumbs()Logic: Removes all breadcrumbs recorded so far, so that the next crash report starts with an empty trail.
- Useful when the app moves on to an unrelated part, for example after a logout, where the earlier trail would only mislead
- Also clears the copy a native crash reporter keeps (see Native Crashes)
- Does nothing when there are no breadcrumbs
- Counted in the health check's method usage as "crashes" / "clear"
For instance method recordHandledException
CountlyInstance.recordHandledException(exception: Error/Exception)
CountlyInstance.recordHandledException(exception: Error/Exception, customSegmentation: Map<String, Object>)Logic: Records a handled (non-fatal) exception/error. The "_nonfatal" property is set to true.
- The stack trace is taken from the error and limited: at most maxStackTraceLinesPerThread lines per thread, each line at most maxStackTraceLineLength characters, and at most 20,000 characters in total.
- If recordAllThreadsWithCrash is enabled, the other threads' stack traces are appended, with the same limits.
- Custom segmentation, if provided, is merged with global crash segmentation (custom overrides global), and internal limits are applied to the result.
- A CrashData object is created with: stack trace, combined segmentation, breadcrumbs, crash metrics, fatal flag, and, where the platform's error provides them, name and description.
- The crash is passed to the crash filter callback, if one is registered. If it returns true, the crash is dropped.
- The "_ob" metric is calculated, internal limits are applied again, and unsupported data types are removed (see Crash Filtering).
- The crash report is serialized to JSON and added to the request queue, and the breadcrumb list is cleared.
Nothing is recorded while the SDK Behavior Settings key "crt" turns crash recording off.
For instance method recordUnhandledException
CountlyInstance.recordUnhandledException(exception: Error/Exception)
CountlyInstance.recordUnhandledException(exception: Error/Exception, customSegmentation: Map<String, Object>)Logic: Records an unhandled (fatal) exception/error. The "_nonfatal" property is set to false.
- The same crash preparation flow as recordHandledException applies.
- The automatic handler (enableCrashReporting) uses this flow. Developers use this method for errors they catch at the top level themselves, for example in a framework's global error hook.
- The crash report is written to storage synchronously, because the process usually ends right after.
Nothing is recorded while the SDK Behavior Settings key "crt" turns crash recording off.
General Information
Crash reports are shown on the dashboard under the Crashes section. A crash that is not sent is not displayed there. The parameters a crash report can carry are described here:
The server accepts a crash report only if it contains "_error", "_app_version" and "_os". All other fields are optional, but the SDK should collect as much information about the device as possible, so that each crash is easier to identify. The server keeps only the fields listed below; any other key in the crash object is ignored.
A browser SDK sends the browser's user agent with the crash request (metrics={"_ua": ...}). The server then fills in "_os", "_os_version", "_device" and "_browser" from it, so the browser SDK does not need to send them.
Crash Fields - The following fields should be collected and sent in the crash report where available:
// Core crash fields
_error - stack trace string (required)
_nonfatal - boolean (true = handled, false = unhandled/fatal)
_logs - breadcrumbs joined with new lines
_custom - crash segmentation (key/value pairs)
_run - app run duration in seconds
_background - whether the app was in the background when the crash occurred
_name - crash title, for example the error's message; if it is not
sent, the server uses the first line of _error
_type - the error's type or class
_ob - which fields the crash filter changed (see Crash Filtering)
// Device metrics (collected automatically)
_os - operating system name (required)
_os_version - OS version
_app_version - application version (required)
_device - device model name
_manufacture - device manufacturer (spelled without the final r, unlike
the device metric _manufacturer)
_resolution - screen resolution
_cpu - CPU info
_opengl - OpenGL/WebGL version
_root - whether the device is rooted/jailbroken
_ram_total - total RAM in MB
_ram_current - used RAM in MB at crash time
_disk_total - total disk space in MB
_disk_current - used disk space in MB at crash time
_bat - battery level (0 - 100)
_orientation - device orientation at crash time
_online - network connectivity status
_muted - whether the device is muted
_signal - whether the device has cell signal (not in airplane mode)
_view - current view or page name at crash time
_cvid - current view ID at crash time, the same value as "cvid" on
events, so that the crash can be linked to its view (see View
Tracking). Servers that don't store it yet ignore it
// Platform-specific fields
_native_cpp - boolean, true when _error holds a native crash dump (minidump)
_binary_images - binary image addresses/UUIDs for symbolication (Apple platforms)
_build_uuid - app build UUID (Apple platforms)
_executable_name - executable binary name (Apple platforms)
_architecture - CPU architecture (Apple platforms)
_app_build - app build number, separate from _app_version
_plcrash - true for reports created by PLCrashReporter (Apple platforms)
_javascript - true for JavaScript SDKs (browser, Node.js)For automatically captured errors, the _nonfatal property is false; for errors the developer reports as handled, it is true. Custom key/values can be reported with crash reports as segments, both globally (setCustomCrashSegmentation) and per recorded error.
Native Crashes
Where the app can contain native (C/C++) code, the SDK should also capture native crashes (signals such as SIGSEGV), usually through a native crash reporter, for example Breakpad on Android or PLCrashReporter on Apple platforms. A native crash ends the process immediately, so the report is saved at crash time and sent at the next start:
- At crash time - the native handler saves the crash report (for example a minidump) in the app's private storage. The breadcrumbs that exist at that moment are saved in the same crash report (for example in the native crash reporter's custom data), not in a separate file.
- At the next start - the SDK reads every saved report. If the "crashes" consent is given, it builds a crash report from it ("_nonfatal": false, the breadcrumbs from the report in "_logs"; for a minidump, the base64-encoded dump in "_error" and "_native_cpp": true), passes it through the crash filter, queues it, and deletes the file. Without the consent, the file is deleted without sending.
- No limits on dumps - internal limits and stack trace limits are never applied to a native crash dump or to a native crash reporter's report, also not after the filter. A cut dump cannot be read, so the server cannot symbolicate the crash.
- No values from the new session - fields that describe the moment of the crash ("_ram_current", "_disk_current", "_bat", "_run", "_orientation", "_online", "_muted", "_background", "_view", "_cvid") are left out when the report is built at the next start, because the SDK would otherwise report the new session's values.
Networking and Params
The crash report is a JSON object with the fields above ("_custom" is a nested object). It is sent in the "crash" parameter, together with the base request parameters, to the "/i" endpoint, through the request queue.
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'crash={"_os":"Android","_os_version":"14","_manufacture":"Samsung","_device":"Galaxy S24","_resolution":"1080x2340","_app_version":"2.1","_cpu":"arm64-v8a","_opengl":"3.2","_ram_current":3072,"_ram_total":8192,"_disk_current":64000,"_disk_total":131072,"_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,"_ob":0,"_custom":{"facebook_sdk":"3.5","admob":"6.5"}}'Storage
There is no separate crash storage. A crash report becomes a request in the request queue, which is stored persistently like any other request. A fatal crash is written to storage synchronously, before the SDK hands over to the previous handler.
Native crash reports are saved at crash time and queued at the next start (see Native Crashes).
Consent
The feature depends on the "crashes" consent. Without it, the SDK records no crashes and keeps no breadcrumbs or global crash segmentation, and a saved native crash report found at the next start is deleted without being sent. When the consent is removed, the breadcrumb list is cleared.
Crash Filtering
There must be a way to filter and edit the crash report before it is sent to the server: the callback registered at init with setGlobalCrashFilterCallback (see its description above). It receives a "CrashData" object containing all crash-related data, changes it in place, and returns true if the crash must be dropped.
If the callback throws an error, the SDK logs it and drops the crash, because the filter may exist to remove personal data from the report.
The crash data object must contain:
- Stack trace string, with lines joined with new line symbols
- List of breadcrumbs
- Crash metrics
- Crash segmentation
- Whether or not the crash is unhandled (fatal)
- Where the platform's error provides them: the crash name, which is the error's type and is sent as "_type", and the crash description, which is the error's message and is sent as "_name"
Mind the two meanings of "name". The CrashData field name holds the error's type and goes to the server as "_type". The server's "_name" field, the crash title, comes from the CrashData field description. The "_ob" bits below follow the CrashData field names: a filter that changes the crash title changes the description, bit 64.
While preparing "CrashData", SDK internal limits are 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 must not accept invalid values like null or undefined.
The SDK must also report to the server which fields the callback changed. To do that, it computes a checksum (for example SHA-256) of each field before and after the callback, and sets one bit per changed field in the integer "_ob" metric:
- 01000000 for description -> "_ob" metric will be 64
- 00100000 for name -> "_ob" metric will be 32
- 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 are changed, the "_ob" metric must be 25. An SDK whose crash data has no name and description uses only the five lower bits.
After the crash filtering is completed, SDK internal limits must be applied again to the stack trace, crash segmentation, and breadcrumbs (never to a native crash dump, see Native Crashes). Unsupported data types must also be removed from crash segmentation and crash metrics.
The "_ob" metric calculation must not be affected by these limit changes. It must only indicate whether the developer modified a field.
The "_ob" metric is added 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.recordPastEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double, dur: double, timestamp: long)
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
- count is an integer, defaults to 1; values below 1 are ignored with a warning, and the default 1 is used
- sum defaults to 0 and may be negative (for example a refund)
- dur is in seconds, defaults to 0; negative values are ignored with a warning, and the default 0 is used
Logic: Records an event with the given parameters.
- A unique event ID ("id") is generated for each event (see General Information).
- Before recording, any pending user profile changes are saved, so that the profile request is queued before the event.
- For custom (developer-provided) events:
- The SDK Behavior Settings can turn custom event tracking off ("cet") and filter events and segmentation with blacklists and whitelists ("eb", "ew", "sb", "sw", "esb", "esw"). A filtered event is not recorded, and filtered segmentation keys are removed.
- The event key is truncated by the "maxKeyLength" internal limit.
- Segmentation keys are truncated by "maxKeyLength", string values by "maxValueSize", and the number of entries is limited by "maxSegmentationValues" (the first entries are kept).
- "peid" is set to the ID of the previous custom event (an empty string for the first one), and this event becomes the previous custom event. Internal events neither get a "peid" nor move this chain.
- Needs the "events" consent.
- Internal events ("[CLY]_" keys) need the consent of their own feature, not the "events" consent (see Internal Events). The behavior settings filters and "cet" are not applied to them. The internal limits don't cut the SDK's own keys and values in them, but the values the developer gives do follow the limits, for example a view name, view segmentation or a feedback answer (see SDK Internal Limits). A "[CLY]_" key that is not a known internal event is treated as a custom event.
- "cvid" (current view ID) is added to every event except the view event; the view event gets "pvid" (previous view ID) instead.
- Experimental: if visibility tracking is enabled, "cly_v" is added to the segmentation (1 = foreground, 0 = background). If previous name recording is enabled, "cly_pen" (previous event name) and "cly_cvn" (current view name) are added to custom events, and "cly_pvn" (previous view name) to view events. See Experimental.
- The event is added to the event queue with: key, count, sum, dur, segmentation, timestamp, hour, dow, id, and cvid, pvid or peid. See Event Queue in SDK Storage and Requests.
- The internal events listed under Immediate Flush Events (see General Information) are sent right away: after recording one, the event queue is moved into a request.
For instance method recordPastEvent
CountlyInstance.recordPastEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double, dur: double, timestamp: long)Valid values
- timestamp is the moment the event happened, in milliseconds since the epoch. A timestamp of 0 or less is ignored with a warning, and the event is recorded at the current time
- The other values are the same as for recordEvent
Logic: Records an event that happened earlier. It is for apps that keep their own record of what happened, for example while they were offline or before the SDK was started, and hand it over later.
- The event gets the given timestamp, used as given, and the hour and day of week of that moment in the device's time zone. The request that carries it still has its own time parameters (see Recording Time of Data)
- Otherwise it is recorded like recordEvent: validation, limits, filters, consent, "peid" and "cvid". These IDs link the event to the state at the time of the call, not to the time it happened
- It goes to the end of the event queue like any other event; the queue is not sorted by time
- Optional: not every SDK needs it. Offer it where apps may collect activity themselves and report it later. An SDK whose apps record everything as it happens can leave it out
For instance method startEvent
CountlyInstance.startEvent(key: String)Logic: Starts a timed event. The event key and the start time are stored.
- If a timed 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.
- Timed events are kept in memory only; running timed events are lost when the app is closed.
- All running timed events are cleared when the device ID changes without merge, when the "events" consent is removed, and when the SDK is halted. They are kept when the device ID changes with merge.
For instance method endEvent
CountlyInstance.endEvent(key: String, segmentation: Map<String, Object>, count: int, sum: double)Valid values
- count defaults to 1
- sum defaults to 0
Logic: Ends a previously started timed event and records it.
- If no timed event with the given key is running, the call is ignored and a warning is logged.
- The duration ("dur") is the time in seconds between the start and this call.
- The event is recorded at the moment endEvent is called: its timestamp, hour and day of week are those of the end, like any event recorded at that moment. It is then in the right order after the events recorded while it was running.
- Otherwise it is recorded like recordEvent (validation, limits, peid).
For instance method cancelEvent
CountlyInstance.cancelEvent(key: String)Logic: Cancels a previously started timed event without recording it.
- If no timed event with the given key is running, 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 (custom events)
Internal Events have a [CLY]_ prefix in their key and are used for providing a specific kind of information (a feature) to the server. For example, the Views feature is reported as an internal Event and is displayed in its own section on the server (Analytics > Page Views).
Developer provided Events can be about any kind of information (non specific). All developer provided Events are displayed in an aggregate section (Events > All Events).
An Event has the following properties:
- key - Mandatory, non empty string
- count - Mandatory, integer, minimum 1, defaults to 1
- sum - Optional, number, can be negative
- dur - Optional, number of seconds, minimum 0
- segmentation - Optional, object with key-value pairs
- timestamp, hour, dow - Mandatory, the time when the event was recorded (see Recording Time of Data)
- id - Mandatory, unique event ID, generated internally
- pvid - Mandatory for the view internal event only, previous view ID, tracked internally
- cvid - Mandatory for all events except the view internal event, current view ID, tracked internally
- peid - Mandatory for custom events only, the ID of the previous custom event (empty for the first one), tracked internally
- cly_v - Mandatory if enableVisibilityTracking is called, added to segmentation, indicates whether the app is in the foreground or background; not added to the view internal end events; tracked internally
- cly_pen - Mandatory if enablePreviousNameRecording is called, added to segmentation, previous event name, only added to custom events, tracked internally
- cly_pvn - Mandatory if enablePreviousNameRecording is called, added to segmentation, previous view name, only added to view internal events, tracked internally
- cly_cvn - Mandatory if enablePreviousNameRecording is called, added to segmentation, current view name, only added to custom events, tracked internally
Event ID Generation: Each event gets a unique ID, for example 6 bytes from a secure random generator, base64-encoded, followed by the millisecond timestamp. The server stores it with the event and uses "id", "cvid", "pvid" and "peid" to link events to each other and to views.
More on Event formatting and information about the feature can be found in the documents below:
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' } }Your SDK should provide methods to cover these combinations, either by default values or by function parameter overloading, as the recordEvent forms under Exposed Methods show.
Event Queue Threshold: events are combined into one request when the queue reaches the threshold (setEventQueueSizeToSend, 100 by default, server key eqs), or on a browser SDK's short heartbeat; see Event Queue.
Immediate Flush Events: Certain internal events bypass the queue threshold and trigger an immediate flush: NPS events ([CLY]_nps), Survey events ([CLY]_survey), Rating events ([CLY]_star_rating), and Push action events ([CLY]_push_action). They report that a feedback widget was answered or closed, or that a notification was tapped. Sending them at once lets the server stop offering a widget the user has answered, and keeps a tap that is followed by the app closing. Other internal events do NOT trigger an immediate flush.
Networking and Params
Event request structure is:
events : json array containing 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,
"cvid": CURRENT_VIEW_ID, // or "pvid" for the view event
"peid": PREVIOUS_EVENT_ID // custom events only
}Event requests are sent to the "/i" endpoint, through the request queue.
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'events=[{"key":"purchase","count":1,"sum":2.99,"dur":30,"segmentation":{"screen":"main"},"timestamp":1761638400000,"hour":10,"dow":2,"id":"T0QxZ2kq1761638400000","cvid":"view-123","peid":""}]'Storage
Events have their own queue, the event queue. It is written to persistent storage on every change, so that recorded events survive the app being closed before they are sent. When the threshold is reached, the events are moved into a request in the request queue.
Running timed events are kept in memory only (see Timed Events).
Consent
Custom events and timed events depend on the "events" consent. Internal events depend on the consent of their own feature instead (see Internal Events).
Internal Events
The call for recording events should support recording Countly internal events. If consent is required, the ability to record them is governed only by their respective feature consents; it is in no way influenced by the "events" consent. If consent for a feature is given and "recordEvent" is used to record that feature's internal event, it is recorded even if no "events" consent is given. If consent for a feature is not given and "recordEvent" is used to record its internal event, it is not recorded even if "events" consent is given. If no consent is required, they are 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" or "feedback" consent
- [CLY]_view - "views" consent
- [CLY]_orientation - "users" consent
- [CLY]_push_action - "push" consent
- [CLY]_action - "clicks" or "scrolls" consent
- [CLY]_content_shown and [CLY]_content_interacted - "content" consent; a content page records them (see Content Zone)
A "[CLY]_" key that is not in this list is treated as a custom event: it needs the "events" consent.
Browser SDKs also record linkClick events themselves (see Link Tracking). Their key has no "[CLY]_" prefix, so the server shows them with the custom events, but they need the "clicks" consent instead of "events".
Example 1:
- "events" consent is given, but "views" consent is not given
- dev calls "recordEvent('[CLY]_view')"
- event is not recorded
Example 2:
- "events" consent is given, and "views" consent is also given
- dev calls "recordEvent('[CLY]_view')"
- event is recorded
Example 3:
- "events" consent is not given, but "views" consent is given
- dev calls "recordEvent('[CLY]_view')"
- event is recorded
Timed Events
Time can be reported with the dur property of an event. It is good practice to let the developer measure periods with the SDK API: startEvent, endEvent and cancelEvent, described under Implementation Details.
If possible, the SDK may provide a way to start multiple timed events with the same key, such as returning an event instance from the start method and calling the end method on that instance. Otherwise a key has at most one running timed event, as those methods describe.
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
- A null or empty map is ignored with a warning.
- Null or empty keys and values are skipped with a warning.
Logic: Every time the SDK builds metrics, it first collects the device's own values and then applies these overrides: an override replaces the collected value with the same key.
- An override of "_app_version" also changes the "av" parameter of every request.
- The server keeps only the metric keys listed in General Information, so an override with any other key is not stored.
- The overrides are kept in memory for the life of the SDK instance.
For instance method recordMetrics
CountlyInstance.recordMetrics(metricOverride: Map<String, String>)Valid values: metricOverride is nullable; null or empty keys and values are skipped with a warning
Logic: Sends the device metrics without starting a session, so that the user's profile and the metric reports are updated.
- The SDK collects the device metrics.
- It applies the overrides from the config, then the overrides given to this call.
- It queues a request with the base request parameters and a "metrics" parameter holding the result.
General Information
Metrics describe the device and the app: the operating system, the device model, the app version, the screen, the language, and so on. The server shows them in its technology reports (platforms, devices, app versions, resolutions, densities, carriers, languages, sources, browsers) and keeps the latest values in the user's profile.
Metrics are sent:
- with
begin_session=1, on every session start; - with remote config fetch requests, so that the server can choose values by device;
- with the health check, which carries only
_app_version; - with
recordMetrics.
Metrics added to a request need the "metrics" consent, in addition to the consent of the feature that sends the request (see Consent below).
A browser SDK also adds metrics with only _ua to every request. The server derives the operating system, the browser and the device from it.
The SDK collects metrics fresh every time it builds them; it does not keep the values from init, so that changes such as a new locale are picked up. It should collect as many metrics as the platform allows, and the developer can override them (setMetricOverride). Possible metrics are listed in the documents below:
Complete Device Metrics List: The server keeps only these keys; any other key in "metrics" is ignored.
_os - operating system name, one of the names listed below
_os_version - OS version only, without the OS name (e.g., "14", "17.4")
_device - device model name (e.g., "Pixel 8", "iPhone15,2")
_device_type - device form factor: "mobile", "tablet", "desktop", "smarttv", "wearable"
_manufacturer - device manufacturer (e.g., "Samsung"), where available
_resolution - screen resolution in pixels, "WIDTHxHEIGHT" (e.g., "1080x2340")
_density - screen density in the platform's own format (e.g., "XHDPI" on
Android, "@2x" on Apple platforms, the devicePixelRatio such as
1.25 in browsers)
_locale - device language and region (e.g., "en_US"; the server also accepts "en-US")
_app_version - application version (the same value as the "av" parameter)
_carrier - mobile carrier name; left out when the platform cannot provide it
(for example "--" on iOS 16.4 and later)
_store - the store or source the app was installed from, where available
_has_hinge - whether the device is foldable, where available
_browser - browser name (browser SDKs)
_browser_version - browser version (browser SDKs)
_ua - user agent string (browser SDKs)All SDKs must identify the same platform with the same string, so the _os metric must be one of these names. The server matches them without regard to case and uses them to keep each platform's OS versions apart; an unknown name mixes the OS versions of different platforms in the reports. The version goes into _os_version, not into the name ("Windows" and "10", not "Windows 10").
- Android - for Android
- iOS - for iOS and iPadOS
- macOS - for macOS
- tvOS - for Apple TV
- watchOS - for Apple Watch
- visionOS - for Apple Vision Pro
- HarmonyOS - for HarmonyOS
- Windows - for Windows
- Windows Phone - for Windows Phone
- Linux - for Linux
- Unix - for Unix
- webOS - for webOS
- Tizen - for Tizen
- Roku - for Roku
- Amazon Fire TV - for Amazon Fire TV
- BlackBerry - for BlackBerry
- Symbian - for Symbian
- BeOS - for BeOS
- QNX - for QNX
- os/2 - for OS/2
- Open BSD - for Open BSD
- Sun OS - for Sun OS
- Unity Editor - for the Unity editor
- SearchBot - for SearchBots
- Unknown - if the operating system is unknown
Networking and Params
A manual metrics request is formed like:
metrics : metrics formed like json objectMetrics requests are sent to the "/i" endpoint, through the request queue.
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'metrics={"_os":"Android","_os_version":"14","_device":"Pixel 8","_device_type":"mobile","_manufacturer":"Google","_resolution":"1080x2400","_density":"XHDPI","_locale":"en_US","_app_version":"2.1","_carrier":"Vodafone","_has_hinge":"false"}'
# and if you are sending with begin session add following parameter
# --data 'begin_session=1'Storage
Metrics are not stored. They are collected from the device every time they are needed. Only the metric overrides provided in the config are kept in memory.
Consent
Metrics need the "metrics" consent wherever they are sent, in addition to the consent of the feature that sends them:
- A session start carries metrics only with both the "sessions" and the "metrics" consent. With "sessions" alone, the begin_session request is sent without metrics.
- A remote config fetch carries metrics only with the "metrics" consent; the fetch itself needs "remote-config".
- The health check carries
_app_versiononly with the "metrics" consent; the health check itself needs no consent. - Manual metrics (
recordMetrics) need the "metrics" consent.
The _ua that a browser SDK adds to every request needs no consent, because the browser sends the same user agent in the header of every request anyway.
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. Server override: the SDK Behavior Settings key "ast" can turn automatic session tracking on (and off). While it is on, the manual session methods are ignored with a warning.
For config method enableManualSessionControlHybridMode
CountlyConfig.enableManualSessionControlHybridMode()Logic: Enables hybrid manual session control mode. Optional: an SDK may leave it out. In this mode:
- The developer must manually call beginSession and endSession
- Session updates are sent automatically by the SDK, on the update timer
- This only works when manual session control is also enabled
- Calls to updateSession are ignored with a warning in hybrid mode
For instance methods beginSession / updateSession / endSession
CountlyInstance.beginSession()
CountlyInstance.updateSession()
CountlyInstance.endSession()Logic: These methods only work when manual session control is enabled. If automatic session tracking is active, the calls are ignored with a warning.
beginSession
- Requires the "sessions" consent, and session tracking allowed by the SDK Behavior Settings (key "st")
- If a session is already running, the call is ignored with a warning, and the invalid call is counted in the health check (log code w760)
- Saves any pending user profile changes first
- Sends a begin_session request that includes the metrics (with the "metrics" consent, see Device Metrics) and the location data (if set)
- If orientation tracking is enabled, records the current orientation
- Starts measuring the session duration
updateSession
- Requires the "sessions" consent, and session tracking allowed by "st"
- If no session is running, the call is ignored with a warning and counted in the health check (log code w760). The server would otherwise start a new session from it.
- Saves any pending user profile changes, then sends session_duration with the seconds elapsed since the last session request
- Ignored in hybrid mode (the SDK sends the updates itself)
endSession
- Requires the "sessions" consent. A session that is running when the server turns session tracking off ("st") still ends normally.
- If no session is running, the call is ignored with a warning and counted in the health check (log code w760). The server would otherwise start a new session from it.
- Moves pending events into a request and saves pending user profile changes first, so that they are recorded inside the session
- Sends an end_session request with the seconds elapsed since the last session request
- Resets the "first view" state of view tracking
General Information
For SDKs to track user 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 that 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 - session requests are sent according to the app lifecycle, together with automatic session update requests after periodic timeframes (60 seconds by default). This is the default in app SDKs and browser SDKs.
- manually by the developer - session requests are triggered by the developer, including the periodic update requests. SDKs without an app lifecycle (server-side and command line SDKs) offer only manual sessions.
The SDK measures the session duration with a monotonic clock where the platform has one, so that changes of the device's clock do not distort it. It reports whole seconds and carries the fraction of a second that was not reported over to the next session request.
Networking and Params
Here is the API documentation for sessions:
Starting a Session
The SDK sends the begin_session=1 param. The same request also contains the metrics parameter (with the "metrics" consent) and, if set, the location parameters.
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'begin_session=1' \
--data 'metrics={...}'Session Update
On every tick of the update timer (60 seconds by default), the session is 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' \
--data 'session_duration=60'Ending a Session
The SDK sends 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' \
--data 'end_session=1' \
--data 'session_duration=60'How the server treats these requests:
- It caps each session_duration value at its configured limit (24 hours by default) and turns a negative value into 30 seconds.
- A session_duration or end_session that arrives while the server has no running session for the user starts a new session. That is why the SDK must never send them without a running session.
- A begin_session that arrives while the previous session is still open closes that session first.
Here are a few example requests generated by different session lengths:
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 requestbegin_session=1&metrics={...} // first begin session request
end_session=1&session_duration=30 // end session requestStorage
Sessions are not stored directly. Their requests are added to the request queue like any other request. A session that was still running when the app was killed is not ended by the SDK; the server closes it when the next begin_session arrives.
Consent
The feature depends on the "sessions" consent. When the consent is removed, the SDK ends the running session right away. When the consent is given while the app is in the foreground and automatic session tracking is active, the SDK begins a session.
Automatic Session Tracking
Automatic session tracking is the default behavior in app SDKs and browser SDKs. 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 tracks sessions while 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 the session cooldown on the server).
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_sessionis sent only on the 0 > 1 transition, and anend_sessiononly 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_durationupdates every 60 seconds (configurable, minimum 1 second). Updates report the elapsed seconds since the last session request.
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 detects this and sends
begin_sessionimmediately. - Unbalanced lifecycle calls (counter going negative) are ignored with an error log.
- Consent withdrawal mid-session ends the session immediately. Consent granted while in the foreground begins one automatically.
- Changing the device ID without merge ends the running session under the old device ID and begins a new one under the new device ID, if the consent allows it: while consent is required, the change removes all consent, and the new session waits until the app gives the "sessions" consent again. With merge, the session continues.
- The SDK Behavior Settings key "ast" can turn automatic session tracking off and on, overriding the developer's choice. When the key "st" turns session tracking off, no new session begins; a session that is already running still ends normally.
Browser SDKs
A browser has no app lifecycle in the same sense, and a visit usually spans several page loads. A browser SDK therefore follows these rules:
- Automatic session tracking starts at init, like in apps. A developer who wants to control sessions (for example in a single-page app) enables manual session control.
- A session continues across page loads of the same site. The SDK keeps the session in storage (a session cookie) for a configurable time after the last activity, 30 minutes by default. Leaving a page sends only the elapsed duration, not end_session. If the next page loads within that time, the session continues; otherwise the next page load begins a new session, and the server closes the previous one when that begin_session arrives.
- Session time is counted only while the page is visible and focused, and while the user has been active within a configurable time (20 minutes by default). Mouse, keyboard, scroll or click input resumes the counting.
- Ending a session explicitly, for example on logout, sends a real end_session.
Manual Session Tracking
With enableManualSessionControl, the app makes the session calls itself (see Implementation Details). SDKs without an app lifecycle (server-side and command line SDKs) provide only this mode.
Enabling Manual Mode
In manual mode, the SDK sends no session request by itself and doesn't react to app lifecycle events (foreground and background) for sessions: lifecycle callbacks and app state observers trigger no session requests.
Hybrid Mode
Hybrid mode (enableManualSessionControlHybridMode) is optional. It suits apps that need explicit control over when sessions start and end, but don't want to send the periodic updates themselves.
Precautions
In pure manual mode (non-hybrid), the app sends the periodic updateSession() calls itself. The recommended interval is 60 seconds, matching the automatic mode default. If the app forgets, the session time between the last update and the end is reported only at the end. Out-of-order calls and the first view reset are described with beginSession, updateSession and endSession above.
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 the server has 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 is counted as the same session, and the session duration extends this session instead of being applied to a 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, pass the ignore_cooldown=true parameter with 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 is server-driven, HTML-based content (made with the dashboard's content builder) that the SDK fetches periodically and displays as an overlay on top of the application. The feature is for SDKs that can show a web view: mobile, desktop and browser SDKs.
Exposed Methods
Config Methods
CountlyConfig.content.setGlobalContentCallback(callback: ContentCallback)
CountlyConfig.content.setZoneTimerInterval(zoneTimerIntervalSeconds: int)
CountlyConfig.content.setContentUrlHandler(handler: ContentUrlHandler)
CountlyConfig.content.setAllowedLinkSchemes(schemes: String[])
CountlyConfig.content.enableReloadOnStall()
CountlyConfig.content.setReloadOnStallTimeout(milliseconds: int)
CountlyConfig.content.disableZoom()
CountlyConfig.content.disableRotation()Instance Methods
CountlyInstance.enterContentZone()
CountlyInstance.exitContentZone()
CountlyInstance.refreshContentZone()
CountlyInstance.previewContent(contentId: String)
CountlyInstance.setGlobalContentSegmentation(segmentation: Map<String, Object>)Implementation Details
For config method setGlobalContentCallback
CountlyConfig.content.setGlobalContentCallback(callback: ContentCallback)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 eventFor config method setZoneTimerInterval
CountlyConfig.content.setZoneTimerInterval(zoneTimerIntervalSeconds: int)Valid values: At least 15 seconds. Default is 30 seconds
Logic: Sets the interval for automatic content fetch requests while in a content zone. The SDK periodically checks the server for new content at this interval. Server override: SDK Behavior Settings key "czi".
For config method setContentUrlHandler
CountlyConfig.content.setContentUrlHandler(handler: ContentUrlHandler)Logic: Registers a handler that is called when content (or a feedback widget) opens a link. The handler receives the URL and returns true if the app handled it, for example by routing its own deep link to the right screen. If it returns false, or no handler is set, the SDK opens the link itself (see Links).
For config method setAllowedLinkSchemes
CountlyConfig.content.setAllowedLinkSchemes(schemes: String[])Logic: Sets the URI schemes that links from content and feedback widgets may open, matched without regard to case, for example ["https", "myapp"]. When the list is empty (the default), any scheme except the dangerous ones is allowed (see Links).
For config methods enableReloadOnStall and setReloadOnStallTimeout
CountlyConfig.content.enableReloadOnStall()
CountlyConfig.content.setReloadOnStallTimeout(milliseconds: int)Valid values: milliseconds: a whole number of at least 1, default 1000 (1 second)
Logic: Off by default. When on, a content page that has not appeared within the stall timeout is reloaded instead of being left to wait. A reload reuses the warm connection and the files that already arrived, which recovers loads that fail because a server or CDN drops a burst of parallel connections.
- A critical file (JavaScript, CSS or an image, see Display Behavior) that fails to load is retried at most 2 times before the content is closed
- The content must still appear within 60 seconds of the web view being created, or it is closed. Reloads don't restart this limit
- setReloadOnStallTimeout is used only together with enableReloadOnStall
For config method disableZoom
CountlyConfig.content.disableZoom()Logic: Stops the user from zooming the content web view (pinch and double tap). The page's own width and initial scale stay as the page sets them. Zoom is allowed by default.
For config method disableRotation
CountlyConfig.content.disableRotation()Logic: Places content with the portrait layout ("p" of the server's "geo") whatever the device orientation is, and doesn't place it again when the device rotates. By default content follows the device orientation. Feedback widgets are not affected.
For instance method enterContentZone
CountlyInstance.enterContentZone()Logic: Enables content fetching and updates for the user.
- If the app turned off the SDK's web views (disableWebView, see Init Configuration), the call is omitted with a warning
- If the SDK is already in the content zone, the call is omitted
- Starts a periodic timer that fetches content from the server at the configured interval
- On the first call right after the SDK is initialized, the first fetch waits 4 seconds, to let the app finish loading
- When content is received, it is displayed as an overlay on the current screen that is always on top
- After content is closed, the SDK waits 2 intervals 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
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
- Sends the stored requests first, so that the server knows the latest events, then re-enters the content zone
For instance method previewContent
CountlyInstance.previewContent(contentId: String)Valid values: contentId: the 24-character hexadecimal ID of the content
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 content is already being displayed, the call is omitted
- Needs server version 26.01.8 or later; older servers ignore the preview parameters and answer as for a normal fetch
For instance method setGlobalContentSegmentation
CountlyInstance.setGlobalContentSegmentation(segmentation: Map<String, Object>)Valid values: segmentation is nullable; null clears it
Logic: Sets segmentation that is added to what content and feedback widgets record:
- every event recorded by content
- feedback widgets: the SDK passes it to the widget page in the "sg" field of the widget URL's custom parameter, so that the page adds it to the answers it records, and adds it to the "closed" events and manual results it records itself (see User Feedback)
The event's own segmentation keys win, and internal limits apply, so the global keys are the first to be dropped when the limit is reached.
Server Settings
The server can control the content zone through the SDK Behavior Settings:
- "ecz" (enter content zone) - when on, the SDK enters the content zone by itself, at init or when the setting arrives, and again after leaving offline mode. When turned off, the SDK exits the content zone.
- "czi" - the zone timer interval in seconds.
- "rcz" - whether refreshContentZone works.
- "jte" and "jtv" (journey trigger events and views) - when one of the listed custom events or views is recorded, the SDK sends the event queue right away and refreshes the content zone, so that content triggered by a journey can appear without waiting for the timer.
Networking and Params
Content fetch requests consist of the base request parameters and the following parameters:
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)
la: device language code (e.g., "en", "de")
content_id: (optional) specific content ID for preview
preview: (optional) "true" when previewing specific contentThe server also accepts resolution in the long form ({"portrait": {"width", "height"}, "landscape": {...}}) and answers a malformed one with 400 "Invalid resolution format". It takes the platform from the user's profile and ignores any other parameter (for example method, category or dt).
Content fetch requests are sent to the "/o/sdk/content" endpoint.
Content fetch requests are directly sent to the server; they are not added to the request queue (see Immediate Requests).
# Common way to fetch content
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk/content' \
--data 'resolution={"p":{"w":360,"h":640},"l":{"w":640,"h":360}}' \
--data 'la=en'
# Preview specific content
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk/content' \
--data 'resolution={"p":{"w":360,"h":640},"l":{"w":640,"h":360}}' \
--data 'la=en' \
--data 'content_id=0123456789abcdef01234567' \
--data 'preview=true'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 URL of 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 the screen density. The response is valid only if both "geo" and "html" are present; other fields can be ignored.
When there is no content to show, the server answers with HTTP 200 and a "result" message instead, for example {"result": "No content block found!"}. The SDK logs it, shows nothing, and keeps the timer running.
Consent
The feature depends on the "content" consent.
If consent is revoked, the content zone is exited and any displayed content is removed.
Display Behavior
Content is displayed as a web view (or iframe) overlay on top of the current screen. If the app turned off the SDK's web views (disableWebView, see Web View UI), no content is shown. The overlay supports two display modes, set with CountlyConfig.setWebviewDisplayOption, which applies to feedback widgets as well:
- 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 keeping separate portrait and landscape configurations. It must always be on top of all views, and it stays on top when the app changes screens.
The overlay has a mechanism to wait until the web view or iframe is fully loaded, including its critical files (JavaScript, CSS and images: js, css, png, jpg, jpeg and webp). If one of them fails to load, an SSL error occurs, or 60 seconds pass while waiting for the content URL or its resources, the content is closed and content fetching continues. With enableReloadOnStall, the SDK reloads a load that stalls instead, which can recover loads that fail because the server dropped the first burst of connections (see the config method above). While waiting for content to load, the SDK must not block the UI: the underlying app keeps working.
Content is not shown while a feedback widget is displayed.
Links
When content asks to open a link:
- If the app registered a URL handler (
setContentUrlHandler), it gets the link first. If it returns true, the SDK does nothing else. - Otherwise the SDK opens the link in the device browser, or a new tab in browsers.
- Links with dangerous schemes are never opened: "file", "content", "javascript", "jar", "zip", "intent" and "data". If the app set allowed schemes (
setAllowedLinkSchemes), only those are opened.
Content Communication Protocol
Content displayed in the overlay (web view or iframe) communicates with the SDK in both directions. 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. Events in the "event" field are recorded with the global content segmentation (see setGlobalContentSegmentation): [CLY]_content_shown and [CLY]_content_interacted are internal events of the content feature and need the "content" consent (see Internal Events in Events); any other key is recorded as a custom event. The SDK processes all present fields. Coordinates in resize_me follow the same dp-based format as the server geo response. If "close" is received together with other actions, the other actions are done first, and close takes effect last.
How messages travel depends on the platform:
-
App SDKs - the content navigates to a URL whose query string contains
cly_x_action_event=1, the action (action=event,linkorresize_me) with its fields, andclose=1to close. The SDK intercepts this navigation in the web view it created and does not load the URL. -
Browser SDKs - the content sends the message object with
postMessage.
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
A browser SDK validates the origin of incoming messages against a configurable allow-list. The base SDK server URL is always included in the list. Additional origins can be added at initialization time. Messages from unlisted origins are silently ignored.
An app SDK accepts messages only from the content web view it created itself.
Platform-Specific Request Parameters
In addition to the common parameters listed under Networking and Params, browser SDKs include these fields in content fetch requests:
cly_ws: 1 // Identifies request as a browser SDK content request
cly_origin: window.location.origin // The origin of the page loading contentDevice ID Change
On a device ID change without merge, the content zone is exited and any displayed content is removed. With merge, the content zone continues. After leaving offline mode, the SDK enters the content zone again if the server's "ecz" setting is on.
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
- While automatic view tracking is active, manual view calls (start, stop, pause, resume) are ignored with a warning, so that the two do not record the same screen
Server override: SDK Behavior Settings key "avt" turns automatic view tracking on and off; key "vt" turns all view tracking off.
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: An invalid viewName makes the call return null
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
- Records a view start event (see View Structure), with the global view segmentation and the call's own segmentation merged in
- Requires view tracking allowed by the SDK Behavior Settings ("vt")
For instance method startView
String CountlyInstance.startView(viewName: String, viewSegmentation: Map<String, Object>)Valid values: An invalid viewName makes the call return null
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
- Records a view start event (see View Structure), with the global view segmentation and the call's own segmentation merged in
- Requires view tracking allowed by the SDK Behavior Settings ("vt")
For instance method stopViewWithName
CountlyInstance.stopViewWithName(viewName: String, viewSegmentation: Map<String, Object>)Logic: Stops a view by its name. If multiple views share the same name, the last one started is stopped.
- Otherwise works like stopViewWithID
- 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>)Logic: Stops a view by its view ID (returned from startView/startAutoStoppedView).
- The ID returned by the start call keeps working for the whole life of the view, also after the view was started again on returning to the foreground
- Duration is calculated in seconds since the view was started or last resumed
- If the view is paused, its time was already reported when it was paused, so the end event carries a duration of 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 view tracking allowed by the SDK Behavior Settings ("vt")
For instance method pauseViewWithID
CountlyInstance.pauseViewWithID(viewID: String)Logic: Pauses a running view.
- Reports the time so far with a view end event (duration, no "visit" key)
- Stops counting time; the view stays in the active views map
- If the view is already paused or does not exist, the call is ignored with a warning
For instance method resumeViewWithID
CountlyInstance.resumeViewWithID(viewID: String)Logic: Resumes a previously paused view.
- Starts counting time again; no new start event is recorded
- If the view is not paused or does not exist, the call is ignored with a warning
For instance method stopAllViews
CountlyInstance.stopAllViews(viewSegmentation: Map<String, Object>)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>)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
- If no view with that ID exists, the call is ignored with a warning
For instance method addSegmentationToViewWithName
CountlyInstance.addSegmentationToViewWithName(viewName: String, viewSegmentation: Map<String, Object>)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
- If no view with that name exists, the call is ignored with a warning
For instance method setGlobalViewSegmentation
CountlyInstance.setGlobalViewSegmentation(segmentation: Map<String, Object>)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>)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, with the view IDs described above, 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 (custom events and the other internal events) include a cvid (current view ID) field to track which view the event occurred in, and crash reports carry the same value as _cvid (see Crash Reporting).
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 session ends, so that the next session marks its own first view.
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.
The start and end events of a view (see View Structure for their fields and samples) go into the "events" parameter like any other event.
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.
Consent
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 the background, all running views are stopped with their accumulated duration and marked to start again (willStartAgain). When the app returns to the foreground, these views start again as new views with their original segmentation: a new start event with "visit": "1" is recorded, so each return to the app counts as a new visit of the view. The view IDs the developer holds keep working: the SDK maps them to the restarted views.
A config option disableViewRestartForManualRecording can be used to prevent the automatic restart of manually recorded views on foreground transitions.
Device ID Change
When the device ID changes without merge, for example when one user logs out and another logs in, the running views belong to the previous user:
- Before the ID changes, the SDK stops every running view, automatic and manual, and records their end events with the time so far. These end events go out under the old device ID, together with the other pending events.
- After the change, with automatic view tracking, the SDK starts a new view for the screen that is shown, under the new device ID, as long as the "views" consent allows it. While consent is required, the change removes all consent, so this view starts once the app gives the "views" consent again.
- Manual views are not started again: the app starts the views it needs for the new user. The view IDs the app held for the stopped views no longer work, and calls with them are ignored with a warning.
- Entering offline mode at runtime counts as a change without merge (see Device ID Management).
- With merge, the user stays the same, so the running views continue.
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 is on by default and 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 (also sent when a view is paused)
All view events use the event key [CLY]_view and are sent with a "count" of 1.
Start event segmentation keys:
-
"name"- view name (e.g., "HomeScreen"). Truncated by maxKeyLength. -
"segment"- platform name, the same value as the "_os" metric (e.g., "Android", "iOS"); browser SDKs may leave it out, because the server fills it in from the browser's user agent -
"view"and"domain"- browser SDKs only: the page URL path and the host name -
"visit"- always set to"1"on view start -
"start"- set to"1"only for the first view in a session (requires a running session; the flag resets when the session ends)
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).
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 outline shows the state a view implementation keeps and how the main calls use it. It follows the methods described above.
class ViewData {
String id; // returned to the developer, links start and end events
String name;
Map segmentation; // the view's own segmentation, re-applied on restart
long startSeconds; // 0 while the view is paused
boolean autoStopped; // true for views started with startAutoStoppedView
boolean willStartAgain; // set when the app goes to the background
}
Map<String, ViewData> views; // active views by ID
Map<String, String> restartedIds; // ID the developer holds -> current ID
String currentViewId = "", previousViewId = "";
boolean firstViewInSession = true; // reset when a session ends
String startView(String name, Map segmentation, boolean autoStopped) {
if (autoStopped) stopTheRunningAutoStoppedView(); // only one at a time
ViewData vd = new ViewData(newUniqueId(), name, segmentation, nowSeconds(), autoStopped);
views.put(vd.id, vd);
previousViewId = currentViewId;
currentViewId = vd.id;
// start event: name, segment, visit = "1", start = "1" only for the first view of a running session
recordViewStartEvent(vd, firstViewInSession && sessionIsRunning());
firstViewInSession = false;
return vd.id;
}
void stopViewWithID(String id, Map segmentation) {
ViewData vd = views.get(resolve(id)); // resolve follows restartedIds
if (vd == null) { warn("no view with this ID"); return; }
long dur = vd.startSeconds == 0 ? 0 : nowSeconds() - vd.startSeconds;
recordViewEndEvent(vd, segmentation, dur); // no visit, no start
views.remove(vd.id);
}
void pauseViewWithID(String id) {
ViewData vd = views.get(resolve(id));
if (vd == null || vd.startSeconds == 0) { warn("not running"); return; }
recordViewEndEvent(vd, null, nowSeconds() - vd.startSeconds);
vd.startSeconds = 0;
}
void resumeViewWithID(String id) {
ViewData vd = views.get(resolve(id));
if (vd == null || vd.startSeconds != 0) { warn("not paused"); return; }
vd.startSeconds = nowSeconds();
}
void onAppBackground() {
for (ViewData vd : views.values()) {
vd.willStartAgain = true;
recordViewEndEvent(vd, null, vd.startSeconds == 0 ? 0 : nowSeconds() - vd.startSeconds);
}
}
void onAppForeground() {
for (ViewData vd : viewsMarkedToStartAgain()) {
String newId = startView(vd.name, vd.segmentation, vd.autoStopped);
restartedIds.put(vd.id, newId); // the old ID keeps working
views.remove(vd.id);
}
}Additionally, if your platform supports actions on views, such as clicks and scrolls, you may report them as well, with the [CLY]_action internal event (see Heatmaps).
Device ID Management
Exposed Methods
Config Methods
CountlyConfig.setDeviceId(deviceId: String)
CountlyConfig.enableOfflineMode()
CountlyConfig.enableClearStoredDeviceId()Instance Methods
CountlyInstance.setID(deviceId: String)
String CountlyInstance.getID()
DeviceIdType CountlyInstance.getType()
CountlyInstance.enableOfflineMode()
CountlyInstance.disableOfflineMode(deviceId: String)Advanced Instance Methods
CountlyInstance.changeWithoutMerge(deviceId: String)
CountlyInstance.changeWithMerge(deviceId: String)Implementation Details
For config method setDeviceId
CountlyConfig.setDeviceId(deviceId: String)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 run), 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 enableOfflineMode
CountlyConfig.enableOfflineMode()Logic: Starts the SDK in offline mode (see Offline Mode below).
- If a device ID is also provided via setDeviceId, the provided 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.
- If a real device ID is stored, the SDK first moves the pending events into a request under that stored ID, so that the previous user's data stays with the previous user. (Requests that are already queued carry their own ID.) If a push token was sent for that ID, it also sends an empty token under it (see Push Notifications).
- It clears the stored device ID, its type, any cached generated ID (so that a new one is generated) and the session data.
- It resolves the device ID as on a first init: the ID given in the config, offline mode, or a newly generated ID (see Device ID State Management During Init).
- Data held in offline mode has no real device ID yet; it is sent under the newly resolved ID.
- Meant for testing and forced user resets. Leaving it enabled in a released app creates a new user on every app start.
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)Logic: Sets the device ID, automatically selecting the correct behavior:
- If the current device ID type is DEVELOPER_SUPPLIED or URL_PROVIDED: changes without merge (treats it as a user switch, ends the 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 current device ID type is TEMPORARY_ID (offline mode): leaves offline mode with the new ID
- If the provided ID is the same as the current ID, the call is ignored with a debug log, not a warning: apps often set the same ID at every start
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)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 ignored with a debug log
- In offline mode, the call only leaves offline mode with the new ID (see enableOfflineMode); nothing else is reset
- Otherwise, in this order:
- Stops all running views and records their end events (see View Tracking), moves pending events into a request and saves pending user profile changes, so that they are recorded under the old ID. If a push token was sent for the old ID, sends an empty token under it (see Push Notifications)
- Ends the running session (automatic session tracking)
- Removes all consent WITHOUT sending consent-removal requests to the server (the change source is "device ID changed without merge"), so that the new user starts with a clean consent state. The features run only the reactions that change state on the device; reactions that would send something are skipped, because what the old user needed went out in step 1 (see "Silent Consent Removal" in User Consent)
- Changes the device ID (type DEVELOPER_SUPPLIED) and stores it
- Clears remote config values (or marks them as cached if caching is enabled), the content zone state and all started timed events
- Begins a new session with the new device ID, if one was running and the consent allows it. While consent is required, step 3 removed the "sessions" consent, so the session begins once the app gives it again
- Fetches the SDK Behavior Settings, triggers the automatic remote config download (if enabled and the consent allows it), starts a view for the current screen with automatic view tracking (see View Tracking), and notifies all modules of the change (the push module then sends the token for the new user, once consent and permission allow it)
- Does NOT send an old_device_id parameter (no merge on the 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)Logic: Changes the current device ID to the provided one with server-side merge.
- If the provided ID is the same as the current ID, the call is ignored with a debug log
- In offline mode, the call only leaves offline mode with the new ID; no merge request is sent
- Otherwise:
- Changes the device ID (type DEVELOPER_SUPPLIED) and stores it
- Sends a request with "device_id=<new>" and "old_device_id=<old>", which tells the server to merge the old user profile into the new one
- Does NOT end/restart the session (the session continues with the new ID)
- Does NOT remove consent (the consent state is preserved)
- Does NOT stop running views or clear started timed events
- Deletes the stored remote config values, or marks them as cached with value caching (see Remote Config)
- Once the merge request has been sent, fetches the SDK Behavior Settings and triggers the automatic remote config download (if enabled). Neither may go out before the merge request: the server would create the new user first, and the merge would then join two users instead of moving one
- Notifies all modules of the change
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 (offline mode), URL_PROVIDED (browser SDKs)
For instance method enableOfflineMode
Enters offline mode at runtime, for example when the user logs out.
CountlyInstance.enableOfflineMode()Logic: Puts the SDK into offline mode (see Offline Mode below).
- It works like changeWithoutMerge to the placeholder ID: pending data is recorded under the current ID, the session ends and consent is removed
For instance method disableOfflineMode
Leaves offline mode. The device ID is optional.
CountlyInstance.disableOfflineMode(deviceId: String)Logic: Leaves offline mode and sends the queued requests.
- Ignored with a warning when the SDK is not in offline mode.
- With a device ID: works like setID in offline mode, and the ID gets the type DEVELOPER_SUPPLIED.
- Without a device ID (null or empty): the SDK returns to the device ID it had before it entered offline mode, with that ID's type. If offline mode started at init and there is no earlier ID, the SDK generates one (SDK_GENERATED).
- Either way, the placeholder and its type are replaced with that ID and its type in all queued requests, and they are sent. No merge request is sent.
- Consent that was removed when offline mode began at runtime is not given back. While consent is required, the app gives it again.
- The push token is sent for the ID now in use (see Push Notifications).
General Information
During the first SDK initialization, the SDK must acquire a device ID. It is a string value that identifies the user.
The device ID is either generated by the SDK or provided by the integrator as part of the 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. A browser SDK also accepts a device ID from the page URL (the cly_device_id query parameter), so that a user can be followed from one site or app to another; its type is URL_PROVIDED.
Which of them is used at init is decided by the rule in Device ID State Management During Init.
Whatever value is acquired, it must be stored persistently. If another value was already acquired before, that one is used, unless the enableClearStoredDeviceId config flag is set (see above).
When acquiring a device ID, the SDK takes note of its source (its type), and sends it with every request in the "t" parameter (see Making Requests).
Device ID State Management During Init
At init, the SDK decides the device ID with one rule:
- If a real device ID is stored from an earlier run, the SDK uses it, unless the "clear stored device ID" flag is set.
- Otherwise it takes the first of these that is available: the ID from the page URL (browser SDKs, type URL_PROVIDED), the ID the developer gave in the config, offline mode (enabled in the config, or still active from the previous run unless the clear flag is set), and finally a newly generated ID.
When the SDK takes an ID while offline mode was active from the previous run, it leaves offline mode, and the requests queued under the placeholder get that ID and its type. The rule covers every combination of a stored ID, an ID in the config, offline mode in the config, an ID in the page URL and the clear flag.
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. In offline mode, any change to a real ID only leaves offline mode.
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:
- Stop the running views, add currently recorded, but not queued, events to the request queue, and save pending user profile changes
- End the current session
- Remove all consent, without sending consent requests
- Change the device ID and store it persistently
- Clear remote config values, the content zone state and all started timed events
- Begin a new session with the new device ID, if the consent allows it
- Fetch the SDK Behavior Settings, download remote config when automatic triggers are on, and start a view for the current screen when automatic view tracking is on
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
- Clear remote config values (or mark them as cached when caching is enabled)
- After the merge request has been sent, fetch the SDK Behavior Settings and, when automatic triggers are on, download remote config
- No need to end/restart the current session, stop the running views 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., "getID()") and another that returns the current device ID type (e.g., "getType()").
There are 4 device ID types, sent in the "t" request parameter with the values in brackets:
- DEVELOPER_SUPPLIED (0) - provided by the developer, either during init or by changing the device ID after init
- SDK_GENERATED (1) - generated by the SDK
- TEMPORARY_ID (2) - the SDK is in offline mode
- URL_PROVIDED (3) - taken from the page URL (browser SDKs)
Offline Mode
In offline mode, the SDK records all data locally but sends nothing to the server until a real device ID is provided. It is useful when the app can't know the user yet, for example before login, and doesn't want an anonymous profile to be created for that time. The SDK enters it with the config option or the instance method enableOfflineMode.
- Requests created in offline mode use the placeholder device ID "CLYTemporaryDeviceID" and the device ID type TEMPORARY_ID. They are queued but not sent.
- Calls that need the server's answer don't run: feedback widgets, remote config downloads, and entering or refreshing the content zone. SDK Behavior Settings fetches and health checks are not sent.
- The SDK leaves offline mode when a real device ID is provided (setID, changeWithoutMerge or changeWithMerge) or when disableOfflineMode is called. It replaces the placeholder with the real ID in all queued requests (it finds
&device_id=<placeholder>and replaces it with&device_id=<realId>, and replaces the device ID type&t=2with the type of the real ID), and they are sent. No merge request is sent, because the server never saw the placeholder. - At every init, when the SDK is not in offline mode, it replaces the placeholder and its type (t=2) with the current device ID and its type in any queued request still carrying them, so that such requests can never block the queue.
Offline mode always records under the placeholder. It is not a way to pause sending while keeping the current device ID.
Storage
The device ID and its type must be stored persistently using the platform's preferred storage mechanism (e.g., SharedPreferences, UserDefaults, localStorage). In offline mode, the SDK also keeps the device ID and type it had before offline mode, for disableOfflineMode(). On each subsequent initialization, the stored device ID takes precedence over any config-provided value (unless the "clear stored device ID" flag is set).
Consent
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. On this silent removal the features run only their reactions that change state on the device, and skip the ones that would send something (see Silent Consent Removal). 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.disableAutomaticDisplay()
CountlyConfig.push.setNotificationActionHandler(handler: PushActionHandler)
CountlyConfig.push.setAllowedLinkSchemes(schemes: String[])
CountlyConfig.push.setAllowedIntentTargets(allowList: List<String>)Instance Methods
CountlyInstance.askForNotificationPermission()
CountlyInstance.askForNotificationPermission(options, completionHandler)
CountlyInstance.onTokenRefresh(token: String, provider: MessagingProvider)
CountlyInstance.recordPushNotificationToken()
CountlyInstance.clearPushNotificationToken()
CountlyInstance.recordActionForNotification(payload, buttonIndex: Int)A method that has no meaning on a platform is left out there: setMessagingProvider exists only where the platform has more than one push service (Android), setTestMode only where tokens come from Apple's push service, setAllowedIntentTargets only where a tap starts an app component through an intent (Android), and onTokenRefresh only where the app, not the SDK, receives the token.
Implementation Details
For config method setMessagingProvider
CountlyConfig.push.setMessagingProvider(provider: MessagingProvider)Valid values: provider: FCM (Firebase Cloud Messaging) or HMS (Huawei Push Kit)
Logic: Chooses the push service the token comes from, on platforms with more than one.
- When it is not set, the SDK uses the first service it finds in the app: FCM, then HMS
- When the chosen service is not in the app but the other one is, the SDK uses the other one. When neither is, push is not started and an error is logged
- The service decides how the token is sent ("token_provider", see Networking and Params) and the platform value of action events ("a" for FCM, "h" for HMS)
- The SDK stores the service, so that a message that arrives before init can be handled
For config method setTestMode
CountlyConfig.push.setTestMode(mode: PushTestMode)Valid values: mode: PRODUCTION (0, default), DEVELOPMENT (1) or TEST_DISTRIBUTION (2)
Logic: Tells the server which environment of Apple's push service (APNs) the token belongs to. Only SDKs that send APNs tokens (iOS, iPadOS, macOS, visionOS) offer it.
- PRODUCTION (0): App Store builds. The server sends through the production gateway
- DEVELOPMENT (1): builds signed with a development profile, such as debug builds run from Xcode. The server sends through the sandbox gateway
- TEST_DISTRIBUTION (2): TestFlight and ad hoc builds. The server sends through the production gateway, but keeps these tokens apart from App Store ones
- Sent as "test_mode" with every APNs token, including 0. The server ignores an APNs token that comes without it
- The other push services have a single environment. The SDK does not send "test_mode" for them, and the server would ignore it
For config method setSendPushTokenAlways
CountlyConfig.push.setSendPushTokenAlways(enabled: boolean)Logic: By default the SDK sends the token only while the user allows the app to show notifications (see Token Lifecycle). When enabled, the SDK sends the token without that permission, so that silent pushes (background data, nothing shown) can reach the app.
- Default is false
- A device that cannot show notifications still counts as "sent" in visible campaigns. Enable it only for apps that rely on silent pushes
- Browser SDKs don't offer it: a browser cannot subscribe without permission
For config method disableAutomaticDisplay
CountlyConfig.push.disableAutomaticDisplay()Logic: Stops the SDK from showing Countly messages that arrive while the app is in the foreground, so that the app can present them in its own way.
- Messages that arrive while the app is in the background are shown as usual
- Taps on system notifications are still recorded by the SDK
- The app records taps on its own presentation with recordActionForNotification; otherwise those clicks are missing from the campaign results
- Not needed where the app itself decides, for every message, whether to call the SDK's display helper (for example from its messaging service)
For config method setNotificationActionHandler
CountlyConfig.push.setNotificationActionHandler(handler: PushActionHandler)Logic: Lets the app handle the link of a tapped notification itself, for example to route its own deep link to the right screen.
- The handler gets the link of the tapped part, the body ("c.l") or the button ("c.b"), and the message
- If it returns true, the SDK opens nothing. If it returns false, throws, or no handler is set, the SDK opens the link (see Action and URL Handling)
- The action event is recorded before the handler is called, whatever the handler returns
- It is called for every tap the SDK handles: on system notifications and on the SDK's own in-app presentation alike
- Browser SDKs don't offer it: links are opened by the service worker, where page code does not run (see Browser SDKs)
For config method setAllowedLinkSchemes
CountlyConfig.push.setAllowedLinkSchemes(schemes: String[])Logic: Sets the URI schemes that notification links may open, matched without regard to case, for example ["https", "myapp"].
- When the list is empty (the default), any scheme except the dangerous ones is allowed: "file", "content", "javascript", "jar", "zip", "intent" and "data". Web links and the app's own deep links keep working
- A refused link is not opened and is logged as a warning. The action event is still recorded
- Browser SDKs open only http and https links and don't offer this setting
For config method setAllowedIntentTargets
CountlyConfig.push.setAllowedIntentTargets(allowList: List<String>)Logic: For platforms where a notification tap starts an app component through an intent (Android). Lists the components, by fully qualified class name, that a notification may start.
- Even without a list, the SDK starts only components of the app's own package
- With a list, the target's class must also be in it, even when it belongs to the app's own package
- A tap whose target is refused opens nothing and is logged as a warning
- Protects against a crafted payload that tries to open another app's screen, or a screen of this app that is not meant to be opened from outside
For instance method askForNotificationPermission
CountlyInstance.askForNotificationPermission()
CountlyInstance.askForNotificationPermission(options, completionHandler)Logic: Shows the platform's dialog that asks the user to allow notifications. Every SDK for a platform with such a runtime permission offers it.
- The first form asks for alerts, sounds and badges. The second chooses the types, where the platform supports this, and reports the answer (granted, error) to the completion handler
- When permission is granted, the SDK sends the token (see Token Lifecycle)
- The platform shows its dialog only once. When the user has already answered, the call reports that answer without a dialog
- Browser SDKs must call it from a user gesture, such as a click. Browsers refuse permission prompts that are not
For instance method onTokenRefresh
CountlyInstance.onTokenRefresh(token: String, provider: MessagingProvider)Logic: Hands a new or refreshed token to the SDK, on platforms where the app receives it: from the messaging service's new-token callback, and from the token request the app makes at every start.
- The SDK sends it as described in Token Lifecycle
- The same token and provider within 60 seconds are ignored, so that a token reported from two places at start is sent once
For instance method recordPushNotificationToken / clearPushNotificationToken
CountlyInstance.recordPushNotificationToken()
CountlyInstance.clearPushNotificationToken()Logic
recordPushNotificationToken
- Sends the current token again for the current device ID, after the usual checks (consent, permission)
- Normally not needed: the SDK sends the token itself at every start, after a device ID change, and after the server URL or the app key changes (see Changing the Server URL and Changing the App Key)
clearPushNotificationToken
- Sends an empty token for the current device ID, so that the server stops sending pushes to this device for this user (see Networking and Params)
- It is a one-off: the token is sent again at the next start while consent and permission allow it. To stop pushes for good, the app removes the "push" consent (see Consent)
- Browser SDKs: the matching call also unsubscribes the browser and remembers the opt-out (see Browser SDKs)
For instance method recordActionForNotification
CountlyInstance.recordActionForNotification(payload, buttonIndex: Int)Logic: Records the action event for a Countly message that the app presented itself.
- buttonIndex: 0 for the body, 1 for the first button, 2 for the second
- The message ID is read from the payload ("c.i"). A payload without it is ignored with a warning
- Records the same event that the SDK records for its own notifications (see Actioned Events)
General Information
Push notifications reach a device through the push service of its platform. The SDK gets the device's token from that service, registers it on the Countly server, shows the Countly messages that arrive (where the platform leaves this to the app) and records the user's taps. SDKs for platforms without a push service, such as desktop and server SDKs, don't offer this feature.
The push services, and how the server tells them apart:
| Push service | Platforms | Platform key | Token parameters |
|---|---|---|---|
| Firebase Cloud Messaging (FCM) | Android | a |
android_token, token_provider=FCM
|
| Huawei Push Kit (HMS) | Android, on devices without Google services | h |
android_token, token_provider=HMS
|
| Apple Push Notification service (APNs) | iOS, iPadOS, macOS, visionOS | i |
ios_token, test_mode
|
| Web Push, with VAPID keys | Browsers | w |
web_token, token_provider=WEB
|
The general flow is:
- Ask the user for permission to show notifications, where the platform requires it.
- Get a token from the push service.
- Send the token to the server, when consent and permission allow it (see Token Lifecycle).
- When a Countly message arrives, show it, unless it is a silent push. Leave other providers' messages alone (see Displaying Notifications).
- When the user taps the notification or one of its buttons, record a
[CLY]_push_actionevent and open the link.
Push Notification Payload Structure
Every Countly message has a Countly part, under the key "c". Its fields are:
"i" - message ID, a 24-character hexadecimal string. Always present; used for
action events
"l" - link to open when the body is tapped
"b" - buttons, at most 2, in display order. Each one has:
"t" - button title
"l" - button link
"m" - media URL (image) on FCM, HMS and Web Push
"a" - media URL on APNs (an image, GIF, audio or video attachment)
"e" - user properties that the campaign asked to include ("Send User Data")
"li" - large icon on FCM and HMS: an image URL, or the name of an image bundled
in the app
"ck" - collapse key on FCM and HMS (see Displaying Notifications)The layout differs per push service, because each service has its own message format:
- FCM and HMS - a data-only message: one flat map of strings. The Countly fields are top-level keys with a "c." prefix ("c.i", "c.l", "c.m"), and "c.b" holds the buttons array as a JSON string. User properties are "c.e.<name>" keys. "title", "message", "sound" and "badge" (a number written as a string) are top-level keys too. The system shows nothing by itself; the SDK displays the message.
-
APNs - the title and text are in
aps.alert(title,bodyandsubtitle), next toaps.soundandaps.badge. The Countly part is a nestedcobject.aps.mutable-contentis 1 when the message has media or buttons, so that the SDK's notification service extension can add them. -
Web Push - one JSON object, with
title,message,icon,tag(the collapse key) andrequireInteractionat the top, and the Countly part as a nestedcobject.
Custom data that the campaign adds ("Send JSON") arrives as more keys: at the top level on APNs and Web Push, and as flattened string keys on FCM and HMS. The SDK passes it to the app unchanged.
A Countly message without a title and a text is a silent push. On APNs it has aps.content-available set to 1 and no aps.alert. A message without c.i is not a Countly message.
FCM and HMS example:
{
"data": {
"c.i": "598e7e0f4d8c000001000001",
"title": "Your title",
"message": "Your message",
"sound": "default",
"badge": "1",
"c.l": "https://example.com",
"c.m": "https://example.com/image.jpg",
"c.b": "[{\"t\":\"Accept\",\"l\":\"https://example.com/accept\"},{\"t\":\"Decline\",\"l\":\"https://example.com/decline\"}]"
}
}APNs example:
{
"aps": {
"alert": { "title": "Your title", "body": "Your message" },
"sound": "default",
"badge": 1,
"mutable-content": 1
},
"c": {
"i": "598e7e0f4d8c000001000001",
"l": "https://example.com",
"a": "https://example.com/image.jpg",
"b": [
{ "t": "Accept", "l": "https://example.com/accept" },
{ "t": "Decline", "l": "https://example.com/decline" }
]
}
}Web Push example:
{
"title": "Your title",
"message": "Your message",
"icon": "https://example.com/icon.png",
"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" }
]
}
}Displaying Notifications
Who shows a notification depends on the push service. APNs messages are shown by the system; the SDK's notification service extension adds the media and buttons first. FCM and HMS messages are data-only, so the SDK builds and shows the notification, called from the app's messaging service. Web Push messages are shown by the SDK's service worker.
Rules for the SDK's part:
-
Other providers' messages - a message without
c.ibelongs to the app or to another push provider. The SDK leaves it alone: it does not show it, record it or change it. - Silent pushes - a Countly message without a title and a text is not shown. It is handed to the app, which may act on its data.
- Consent - without "push" consent, Countly messages are not shown. The SDK keeps the consent state in storage that it can read before init, so that it can decide even when the message itself started the app.
-
Content - the notification shows the title and the text; the media as a large picture (see Rich Push / Media Attachments); the buttons, at most 2, in payload order; the sound, where the platform lets the SDK choose it ("default" means the system's default sound, any other value names a sound bundled in the app); the badge number, where the platform shows one; and the large icon (
c.lion FCM and HMS,iconon Web Push), where the platform has one. On Web Push,requireInteractionasks the browser to keep the notification on screen until the user acts on it. -
Replacing - a newer message replaces the notification of an older one only when both have the same collapse key:
c.ckon FCM and HMS, which the SDK uses as the notification's tag, andtagon Web Push. APNs does this by itself, as the server sends the key in theapns-collapse-idheader. Without a collapse key, every delivery is shown on its own, even when a recurring campaign sends the same message ID again. A message whose display was delayed, for example by a media download, does not replace a newer message with the same key that is already shown. - Foreground - a message that arrives while the app is in the foreground is shown too: as a system notification, or as an in-app dialog with the same title, text, media and buttons, where the SDK chooses that. The app can turn this off (disableAutomaticDisplay) and present the message itself.
- Button sets - where buttons must be registered with the system before a notification can use them (APNs notification categories), the SDK registers a set for each message and adds it to the sets the app has registered. It never removes the app's own sets.
Actioned Events
When the user taps the body of a Countly notification, or one of its buttons, the SDK records an internal event with the key [CLY]_push_action and a count of 1. The server ignores the event when the count is anything else.
Event segmentation:
-
"i"- the message ID fromc.i: the 24-character hexadecimal string. The server ignores the event without it. -
"b"- the tapped part, as a number:0for the body,1for the first button,2for the second. -
"p"- the platform key of the push service that delivered the message:-
"a"- FCM -
"h"- HMS -
"i"- APNs, on every Apple platform, macOS and visionOS included -
"w"- Web Push
The server counts clicks per platform with this key. With any other value, the click is counted only in the campaign's total.
-
Rules for recording:
- Each tap is recorded once, before anything is opened. It is also recorded when the link is refused, or when the app's handler takes the link over.
- Receiving a notification and dismissing it are not actions.
- A tap that happens before the SDK is initialized (because the tap started the app) is stored, with its time, and recorded at init. Every such tap is kept, not only the last one.
- Recording requires "push" consent. A stored tap is recorded only if the consent is given at that time.
Action and URL Handling
A tap on the body opens c.l. A tap on a button opens the link of that button in c.b (button 1 is the first entry). For every tap, the SDK:
- Records the action event (see Actioned Events).
- Removes the tapped notification from the notification list, also after a button tap; many systems remove a notification by themselves only when its body is tapped.
- Passes the link to the app's handler, if one is set (setNotificationActionHandler). If the handler returns true, the SDK stops here.
- Refuses links with a dangerous scheme (setAllowedLinkSchemes) and, where taps start components through intents, targets that are not allowed (setAllowedIntentTargets).
- Opens the link with the platform's standard mechanism: web links in the browser, the app's own deep links in the app. Without a link, the SDK brings the app to the foreground (browsers: an open page of the site, or the site in a new window).
The SDK passes the message data along only to a screen of the app itself. Another app that the link opens never receives it.
Rich Push / Media Attachments
When a message has a media URL (c.m on FCM, HMS and Web Push, c.a on APNs), the notification shows it:
- FCM and HMS - the SDK downloads the image and shows it as the notification's large picture. The download uses a 15-second connect and read timeout and up to 3 attempts. A large image is scaled down before it is shown (for example to 1,024 pixels on the longest side), so that decoding it cannot run out of memory on older devices.
- APNs - the SDK's notification service extension downloads the file and attaches it. The system gives the extension about 30 seconds; the SDK delivers the notification before that time runs out, without the media if necessary.
- Web Push - the service worker passes the URL to the browser as the notification's image, and the browser downloads it.
If the media cannot be downloaded or decoded, the notification is still shown, without it. The large icon (c.li) follows the same rules.
Token Lifecycle
The SDK sends the token (see Networking and Params) when all of these hold:
- "push" consent is given;
- the user allows the app to show notifications, unless setSendPushTokenAlways is enabled;
- the SDK has a token.
It sends it:
- at every start of the app, and at every new session in browsers. The server ignores a token it already has for the user, and sending it again repairs a token the server has dropped;
- when the push service gives a new token;
- when the user grants the permission;
- when "push" consent is given;
- right after a device ID change without merge, for the new user;
- right after the server URL or the app key changes, because the new server or app doesn't have it.
It sends an empty token, which takes the token away from the user on the server:
- when "push" consent is removed (see Consent);
- when the user took the permission back in the system settings. The SDK notices this at the next start (browsers: the next page load) and clears the token once;
- before a device ID change without merge, under the leaving user's device ID;
- when the app calls clearPushNotificationToken.
Device ID changes:
- With merge - the SDK sends nothing. The server moves the token to the new device ID together with the rest of the profile.
- Without merge, and when the stored device ID is cleared at init - before switching, the SDK sends an empty token under the leaving user's device ID. After switching, it sends the token for the new user, once that user's consent and permission allow it. The consent removal that comes with the change does not clear the token a second time.
- Offline mode - entering it at runtime counts as a change without merge. When the SDK leaves offline mode, a queued token request gets the real device ID like every other queued request (see Device ID Management).
Networking and Params
The token is sent to the server in a token request to the "/i" endpoint, separate from the session requests. The token request goes into the request queue right away, like any other request, without a timer: the queue sends requests one at a time and in order, so the server has handled the begin_session request before it gets the token. A timer would lose the token when the app closes before the timer fires.
Token Request
An FCM token:
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'token_session=1' \
--data 'android_token=PUSH_TOKEN' \
--data 'token_provider=FCM'An APNs token:
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'token_session=1' \
--data 'ios_token=APNS_TOKEN_IN_HEX' \
--data 'test_mode=0'Token request parameters:
-
token_session=1- marks the request as a token request. - FCM -
android_tokenwith the token, andtoken_provider=FCM. - HMS -
android_tokenwith the token, andtoken_provider=HMS. - APNs -
ios_tokenwith the token as lowercase hexadecimal text, andtest_mode:0,1or2, always present (see setTestMode). - Web Push -
web_tokenwith the browser's push subscription as a JSON string, andtoken_provider=WEB.
No other push parameters are needed. A locale parameter, for example, is not read here; the user's language comes from the metrics sent with the session.
Clearing a token: To take the token away from the user, the SDK sends the same request with an empty token value (android_token=, ios_token= together with its test_mode, or web_token=), keeping token_provider. Servers from 26.10 on remove the stored token. Older servers ignore an empty token; there, the token goes away only when the push service reports it as invalid on a later send, or when another user registers the same token. The SDK sends an empty value, never a placeholder word such as BLACKLISTED: the server treats that word as empty for FCM, HMS and Web Push tokens, but not for APNs.
Browser SDKs
Browser SDKs use Web Push, and a service worker of the SDK does the work that the system and the messaging service do on apps:
-
Setup - the site serves the SDK's service worker file (by default
/countly_sw.js) over HTTPS, and the SDK is given the VAPID public key from the app's push settings on the server. The site can also import the worker into its own worker. The worker ignores pushes and clicks without a Countly message ID, so that other providers keep working. - Permission - askForNotificationPermission must run from a user gesture. It subscribes the browser with the VAPID key and sends the subscription as the token. On later page loads, when permission is already granted, the SDK checks the subscription without asking and registers it for the current user, once per session.
- Opt-out - clearPushNotificationToken (the "disable" call) also unsubscribes the browser and remembers the opt-out, because the browser keeps the permission and the next page would otherwise subscribe again. Only a new permission request lifts it. Removing the "push" consent unsubscribes and clears the token too, but is not remembered as an opt-out.
- Visible pushes only - browsers require every push to show a notification, so silent pushes are not possible.
- Taps - the worker opens only http and https links. It hands the tap to an open page of the site, which records the action and sends it at once, because the page usually navigates away. When no page is open, the worker keeps the tap in its own persistent storage and records it itself, or hands it to the next page that loads. Each tap is recorded once.
- Listener - instead of an action handler, browser SDKs offer a listener that is told when a Countly notification is received, clicked or closed. It cannot stop the navigation. "Closed" is best effort, because many browsers and systems don't report dismissals.
- Subscription changes - when the browser replaces the subscription, the worker tells the page, and the SDK registers the new one.
Storage
The SDK stores, persistently:
- the push service in use, and the last token it sent together with the device ID it was sent for. The SDK uses them to ignore the same token reported twice within 60 seconds and to clear the token for a leaving user. They never stop the send at every start, which repairs a token the server has dropped;
- whether the user allowed notifications at the last check, so that it can notice when the permission is taken back;
- the "push" consent state, readable before init, so that a message that starts the app can be handled;
- taps that happened before init, with their time, until they are recorded;
- in browser SDKs: the subscription's endpoint, the VAPID key and worker scope it was made with, and the opt-out flag. The worker keeps the taps that wait to be recorded in its own storage (IndexedDB).
Consent
The feature depends on the "push" consent. Asking for permission, sending the token, showing Countly messages and recording taps all require it. SDKs without push still treat a [CLY]_push_action event recorded through them as part of the "push" feature.
- Consent given - the SDK gets the token and sends it, if the permission allows it.
- Consent removed - the SDK sends an empty token (see Clearing a token) and stops showing Countly messages. It clears the token itself because servers from 26.01 on do not remove it when the consent changes; servers up to 25.03 also remove it through the consent change. It does not unregister the app from the push service: that registration (APNs, FCM, HMS) is shared with the app's other push providers. Browser SDKs unsubscribe the subscription that they made, which is Countly's own.
Recording Location
Exposed Methods
Config Methods
CountlyConfig.setLocation(countryCode, city, gpsCoordinates, ipAddress)
CountlyConfig.disableLocation()Instance Methods
CountlyInstance.setLocation(countryCode, city, gpsCoordinates, ipAddress)
CountlyInstance.disableLocation()Implementation Details
For config method setLocation:
CountlyConfig.setLocation(countryCode, city, gpsCoordinates, ipAddress)Valid values: The same as for the instance method setLocation.
Logic: Gives the location values to use from init on.
- The SDK keeps them in memory and uses them only while "location" consent is given: at init if consent is given then, otherwise as soon as it is given (see Consent). Without consent they are never sent
- When used, they are handled like an instance setLocation call: they go with the next begin_session, or in a separate location request when no begin_session will be sent (see Sending Location)
- Ignored when disableLocation is also set in the config
- Warns if city and country code are not given together
For config method disableLocation:
CountlyConfig.disableLocation()Logic: Starts the SDK with location turned off, as if the instance method disableLocation were called during init.
- Takes precedence over location values given in the config
- Applies whether or not "location" consent is given
- An instance setLocation call with at least one value turns location back on
- The server's "lt" setting takes precedence over it while the SDK Behavior Settings contain that key (see Server Setting)
For instance method setLocation:
CountlyInstance.setLocation(countryCode, city, gpsCoordinates, ipAddress)Valid values
- All four are nullable. Null or empty means "not given". At least one value should be given.
- countryCode: ISO 3166-1 alpha-2 code in uppercase, for example "US" or "DE"
- city: city name, for example "Berlin"
- gpsCoordinates: latitude and longitude in decimal degrees, separated by a comma, for example "52.52001,13.40495"
- ipAddress: an IPv4 or IPv6 address, for example "203.0.113.7"
- Values that don't fit these forms are logged as a warning and not used.
Logic: Replaces the stored location values with the given ones. A value that is not given clears the stored one.
- Ignored with a log while the server's "Allow Location Tracking" setting is off (see Server Setting)
- A call with at least one value turns location back on, if the app turned it off with disableLocation
- A call without any value only clears the stored values. To stop location tracking, the app calls disableLocation
- Warns if city and country code are not given together: the server can place a city only together with its country
- The values are sent as described in Sending Location: with the next begin_session if one is still to come, otherwise in a separate location request right away
For instance method disableLocation:
CountlyInstance.disableLocation()Logic: Turns location off until setLocation is called with a value.
- Applies whether or not "location" consent is given, so that a later consent does not turn location back on
- Clears all stored location values (country code, city, coordinates and IP address)
- Sends a location erase request (location=) right away, and every following begin_session carries location= as well (see Sending Location)
- While the server's SDK Behavior Settings contain "lt", the server's value takes precedence (see Server Setting). With "lt" true, location stays on and no erase request is sent; the stored values are still cleared. The app's choice is kept and applies once the settings no longer contain "lt"
- The off state is kept in memory only. After a restart, the app calls it again, or uses the config method
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
What the server does with these values:
- Country code and city - stored on the user's profile as the user's country and city, from any request that carries them.
- Coordinates - stored on the profile; the server also works out the country and city from them, where those are not given.
- IP address - used only with a begin_session request that carries no other location values, to work out the country and city. Without it, the server uses the IP address the request came from. Giving an IP address is useful when requests do not come from the user's own device, for example through a proxy or from a backend.
- Nothing - a begin_session request without any location parameter lets the server work out the country and city from the request's IP address.
-
The empty location (
location=) - on a begin_session request, the server records the country and city as "Unknown", removes stored coordinates, and does not look up the IP address. This is how the SDK keeps a user's location off.
Location is on while "location" consent is given and location tracking is allowed. Whether it is allowed is decided by the server's "Allow Location Tracking" setting (lt) while the SDK Behavior Settings contain it, and otherwise by the app: disableLocation turns it off, and setLocation with a value turns it back on. Otherwise location is off.
Sending Location
Every begin_session request carries the location:
- while location is on, all stored values that are not empty;
- while location is off, the empty location (
location=).
The SDK sends a separate location request with the stored values when setLocation is called, or stored values become usable because consent was given, and either:
- the begin_session of the current session was already sent, or
- no begin_session will be sent: "sessions" consent is not given, or the server turned session tracking off.
Otherwise, the values wait for the next begin_session. A separate location request leaves out the IP address, because the server uses it only with begin_session; the stored IP address goes with the next begin_session.
The SDK sends a separate location erase request (location=):
- when the app calls disableLocation, unless the server's settings contain
lttrue; - when "location" consent is removed;
- when the server turns "Allow Location Tracking" off;
- when the server's settings no longer contain
lttrue while the app has turned location off; - at the end of init, when location is off and no begin_session will be sent. While consent is required and not given, this request goes out without any consent. That may change (see User Consent).
Open issue: the server applies the empty location only on a begin_session request and ignores a separate location erase request. A location that was already recorded therefore stays on the user's profile until a begin_session carries location=. The server is to be changed to honor the separate erase request, possibly with a dedicated parameter that means "location is off on the SDK side". This section needs an update once that change is decided.
Server Setting
The SDK Behavior Settings key lt ("Allow Location Tracking" in the dashboard) takes precedence over the app's own choice in both directions, like every other behavior setting:
- When the settings contain
ltfalse, location tracking is off. When it turns off, the SDK clears the stored values and sends a location erase request. While it is off, setLocation calls are ignored, and every begin_session carries the empty location, so the server records no location at all, not even from the IP address. - When the settings contain
lttrue, location tracking is on, also when the app turned it off with disableLocation: the next begin_session lets the server work out the location from the IP address, and setLocation calls are accepted. Cleared values are not restored. - When the settings don't contain
lt, the app's own choice applies: disableLocation turns location off, and setLocation with a value turns it back on. The app does not need to call setLocation to end an off state that came from the server. - Consent applies on top of the setting: without "location" consent, location stays off whatever the server sends.
Networking and Params
Four location parameters can be sent to the server:
country_code : country code, in the two-letter ISO 3166-1 alpha-2 standard, in uppercase
city : city name
location : latitude and longitude in decimal degrees, separated by a comma, e.g. "52.52001,13.40495"
ip_address : IP addressLocation requests are sent to the "/i" endpoint, through the request queue.
Empty values are left out. The only empty parameter that is sent is the empty location (location=), which turns location off.
The server stores the country code as it is sent, and its IP lookups produce uppercase codes, so a lowercase "us" would show up as a different country than "US".
# begin_session with location
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'begin_session=1' \
--data 'country_code=DE' \
--data 'city=Berlin' \
--data 'location=52.52001,13.40495' \
--data 'ip_address=203.0.113.7'
# Separate location request (no IP address)
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'country_code=DE' \
--data 'city=Berlin' \
--data 'location=52.52001,13.40495'
# Location erase request
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'location='Storage
Location values and the off state set by disableLocation are kept in memory only, never in persistent storage. The methods above describe how values are replaced and how config values wait for consent.
Consent
The feature depends on the "location" consent. The "sessions" consent also affects it, because it decides whether begin_session requests are sent.
- Consent given - stored values (for example from the config) become usable: they go with the next begin_session, or in a separate location request (see Sending Location). If the app turned location off with disableLocation, it stays off.
- Consent removed - the SDK clears the stored values and sends a location erase request. Every following begin_session carries the empty location until consent is given again.
Test scenarios
Some sample situations for handling location. Unless stated otherwise, all consents are given and sessions are tracked automatically.
1) Dev Sets Location Sometime After Init
init without location
begin_session (no location parameters; the server works out the location from the IP address)
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; city and country are cleared)
end_session
begin_session (with location - gps)
end_session
3) Dev Sets Location Before the First begin_session
init with location (city, country)
setLocation(gps, ipAddress) (no request, as begin_session is still to come)
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
4) No Sessions Consent
init with location (city, country, ipAddress), only "location" consent given
end of init (location request with city, country; no IP address)
setLocation(gps) (location request with gps)
5) Consent Given After Init
init with location (city, country), consent required, no consent given yet
end of init (location erase request, as location is off and no begin_session will be sent)
"location" and "sessions" consent given
begin_session (with location - city, country)
6) Dev Disables Location Before Consent
init without location, consent required, no consent given yet
end of init (location erase request, as location is off and no begin_session will be sent)
disableLocation() (location erase request)
"location" and "sessions" consent given
begin_session (with location=, as location stays off)
setLocation(city, country) (location request with city, country)
end_session
begin_session (with location - city, country)
end_session
7) Server Turns Location Tracking Off
init with location (city, country)
begin_session (with location - city, country)
"Allow Location Tracking" turned off (location erase request; stored values cleared)
setLocation(gps) (ignored)
end_session
begin_session (with location=)
"Allow Location Tracking" turned on
end_session
begin_session (no location parameters; the server works out the location from the IP address)
end_session
8) Server Turns Location Tracking On While the App Turned It Off
init with disableLocation in the config
begin_session (with location=)
behavior settings with "Allow Location Tracking" on (lt true) arrive
end_session
begin_session (no location parameters; the server works out the location from the IP address)
behavior settings without lt arrive (the app's disableLocation applies again: location erase request)
end_session
begin_session (with location=)
end_session
Heatmaps
Heatmaps is a browser-only feature: browser SDKs implement it, app and server SDKs don't.
Exposed Methods
Config Methods
CountlyConfig.heatmaps.enableClickTracking()
CountlyConfig.heatmaps.enableScrollTracking()
CountlyConfig.heatmaps.setHeatmapWhitelist(domains: List<String>)Instance Methods
CountlyInstance.trackClicks(parent?: Element)
CountlyInstance.trackScrolls(parent?: Element)Implementation Details
For config method enableClickTracking and instance method trackClicks
CountlyConfig.heatmaps.enableClickTracking()
CountlyInstance.trackClicks(parent?: Element)Valid values: parent: the element whose clicks are tracked. Optional; the whole document when not given.
Logic: Starts recording clicks for heatmaps, from init (config) or from the call on (instance).
- Listens to clicks on the parent element, or on the whole document
- Records each click as a "[CLY]_action" internal event with type "click" (see Tracking Clicks)
- After a recorded click, further clicks are ignored for 1 second, to limit traffic
- A click is recorded only if "clicks" consent is given at that moment
- Calling it again for the same element does not add a second listener
For config method enableScrollTracking and instance method trackScrolls
CountlyConfig.heatmaps.enableScrollTracking()
CountlyInstance.trackScrolls(parent?: Element)Valid values: parent: the element whose scrolling is tracked. Optional; the window when not given.
Logic: Starts recording how far down users scroll each page.
- Keeps in memory the lowest point of the page the user has seen during the current view, updated on every scroll
- Records it once per view, as a "[CLY]_action" internal event with type "scroll" (see Tracking Scrolls), when the next view starts and when the page is left
- Starts again from the top of the page for every new view
- The event is recorded only if "scrolls" consent is given at that moment
For config method setHeatmapWhitelist
CountlyConfig.heatmaps.setHeatmapWhitelist(domains: List<String>)Valid values: List of dashboard addresses: scheme and host, with the port if there is one, for example "https://countly.example.com".
Logic: Lists the Countly dashboard addresses that may load the heatmap overlay into the site (see Heatmap Overlay).
- The server URL given to the SDK is always allowed
- A trailing slash on an entry is ignored. Otherwise an address must match exactly
- Needed when the dashboard runs on a different address than the server URL the SDK sends its data to
- Prevents a crafted link from loading a script from another address into the site (XSS)
General Information
Heatmaps is a plugin for browser SDKs 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:
Countly.q.push(['track_scrolls']);
Countly.q.push(['track_clicks']);Countly.track_scrolls();
Countly.track_clicks();Heatmap Overlay: A user goes to the Heatmaps section of the dashboard and clicks a view from the list. The dashboard then opens the website in a new window and passes it a message in the window's name: cly: followed by a JSON object. The SDK reads it at init. It also accepts the same JSON after #cly: in the URL's hash. The JSON contains:
-
app_key- the app the heatmap belongs to. It must be the SDK's own app key. -
token- a short-lived read token that the overlay uses to load the heatmap data. -
purpose-"heatmap". -
url- the address of the dashboard that opened the window. The overlay script is loaded from there. -
period,currentMapandshowHeatMap- the dashboard's choices (the date range, and click or scroll map), read by the overlay itself.
When the app key matches, the token and purpose are present, and url is allowed (see XSS Prevention), the SDK:
- stores the token, for the overlay's data requests;
- stops tracking for this visit, because a dashboard user looking at a heatmap is not real usage of the site;
- shows a loading indicator and loads the overlay script from
url+/views/heatmap.js.
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.
Countly.app_key = "YOUR_APP_KEY";
Countly.url = "https://try.count.ly";
Countly.heatmap_whitelist = ["https://you.domain1.com", "https://you.domain2.com"];
Countly.init({
app_key:"YOUR_APP_KEY",
url: "https://try.count.ly",
heatmap_whitelist: ["https://you.domain1.com", "https://you.domain2.com"]
});What the Server Needs:
- The server reads heatmap data from Drill, so "Record view actions in Drill" must be on (it is by default).
- Scroll points are matched to a page by the event's
view. Clicks are matched by the user's last recorded view, which is the name of the last view event. So click heatmaps also need view tracking, with the page path as the view name (the default in browsers), and the event'sviewmust be that same path. - The event's
widthdecides which screen size (resolution) a point is shown for. - The server adds the platform to each event itself; the SDK does not send it.
Tracking Clicks
A sample click event:
{
"key":"[CLY]_action",
"count":1,
"segmentation":{
"type":"click",
"x":120,
"y":2200,
"width":1920,
"height":5400,
"view":"/pricing",
"domain":"www.example.com"
}
}-
x,y- where the click happened, measured from the top-left corner of the whole page, including the part scrolled out of sight, in CSS pixels. -
width,height- the size of the whole page, not of the window. -
view- the page path, for example/pricing. The app may change how it is built (for single-page sites, for example), but it must be the same value the overlay asks for. -
domain- the site's host name, sent while domain tracking is on (the default), so that sites on several domains can be told apart.
Tracking Scrolls
A sample scroll event:
{
"key":"[CLY]_action",
"count":1,
"segmentation":{
"type":"scroll",
"y":3100,
"width":1920,
"height":5400,
"view":"/pricing",
"domain":"www.example.com"
}
}y is the lowest point of the page the user has seen during the view: the largest scroll offset so far plus the height of the visible area. There is no x value. width, height, view and domain mean the same as for clicks.
To catch the moment the page is left, the SDK listens to the event that browsers fire reliably when a page is hidden or closed (pagehide). beforeunload alone is often not fired on phones.
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.
Their fields are shown under Tracking Clicks and Tracking Scrolls.
Storage
Heatmap data is not stored persistently. Click events are recorded and added to the event queue immediately. The lowest point seen is held in memory per view, and added to the event queue on view change or when the page is left. The overlay's token is stored, so that the overlay's requests can use it.
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
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: Lets the SDK download all values (a full download) by itself, in these situations:
- At the end of init, unless the SDK is in offline mode
- When "remote-config" consent is given after init
- After a device ID change, with or without merge. After a change with merge, the download waits until the merge request has been sent (see General Information)
- When the SDK leaves offline mode
- After a successful testingEnrollIntoVariant call
Each of these downloads requires "remote-config" consent. The registered callbacks are notified, as for any download.
For config method enableRemoteConfigValueCaching
CountlyConfig.enableRemoteConfigValueCaching()Logic: Keeps the previous values readable when they stop being the current user's.
- Without caching, the stored values are deleted after a device ID change and after a successful testingEnrollIntoVariant call
- With caching, they are kept and marked as cached: their isCurrentUsersData is false, so the app can tell that they may belong to the previous user
- The next full download replaces all stored values, cached ones included. A partial download replaces only the keys it returns
For config method remoteConfigRegisterGlobalCallback
CountlyConfig.remoteConfigRegisterGlobalCallback(callback: RCDownloadCallback)Logic: Registers a callback at init that is notified about every download, including the automatic ones.
- 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: Success; NetworkIssue when the server could not be reached; Error for everything else, including a download that could not start
- 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 values this download returned (the delta), not the whole stored set
- RCData will be mentioned in the Storage section.
For instance method downloadAllKeys
CountlyInstance.downloadAllKeys(callback: RCDownloadCallback)Logic: Downloads all values (a full download).
- The download does not start, and the callbacks (the given one and the registered ones) get Error with a message, when:
- "remote-config" consent is not given
- the device ID is not known yet
- the SDK is in offline mode, or the request queue still holds requests with the offline placeholder ID
- the server's SDK Behavior Settings turned networking off
- The request is sent right away, not through the request queue (see Networking and Params). When the server can't be reached, the callbacks get NetworkIssue
- On success, the downloaded set replaces all stored values, and every value is marked as current (isCurrentUsersData true). The callbacks get Success, with fullValueUpdate true and the downloaded values
- If the device ID changed while the download was running, the answer is discarded and the callbacks get Error, because the values belong to the previous user
For instance methods downloadSpecificKeys and downloadOmittingKeys
CountlyInstance.downloadSpecificKeys(keysToInclude: Array<String>, callback: RCDownloadCallback)
CountlyInstance.downloadOmittingKeys(keysToOmit: Array<String>, callback: RCDownloadCallback)Logic: Partial downloads.
- downloadSpecificKeys asks only for the given keys. downloadOmittingKeys asks for all keys except the given ones
- When no valid key is left, the call behaves like downloadAllKeys
- The same checks and callbacks as for downloadAllKeys apply, with fullValueUpdate false
- On success, only the returned keys are replaced. Other stored values, and requested keys that the server did not return, stay as they are
For instance methods getValues and getValue
Map<String, RCData> CountlyInstance.getValues()
RCData CountlyInstance.getValue(key: String)Logic: Read the stored values. No request is made.
- getValues returns all stored values, cached ones included
- getValue returns the stored value of the key. When the key is not stored, it returns an RCData whose value is null (isCurrentUsersData true)
- A value can be of any JSON type: string, number, boolean, object or array
For instance method registerDownloadCallback
CountlyInstance.registerDownloadCallback(callback: RCDownloadCallback)Logic: This will add the given callback to the internal callback list
For instance method removeDownloadCallback
CountlyInstance.removeDownloadCallback(callback: RCDownloadCallback)Logic: This will remove given callback from the internal callback list
For instance method clearAll
CountlyInstance.clearAll()Logic: Deletes all stored values, cached ones included. Caching is not considered.
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.
A full download (downloadAllKeys and the automatic downloads) replaces all stored values; a partial download (downloadSpecificKeys, downloadOmittingKeys) replaces only the keys it returns, as the methods above describe. 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.
Device ID changes: after a device ID change, with or without merge, the stored values belong to the previous user. The SDK deletes them, or with value caching marks them as cached. With automatic triggers, a full download for the new ID follows. After a change with merge, that download waits until the merge request has been sent: any request for an ID the server has not seen yet creates that user, and the merge request would then merge two users instead of moving one.
Offline mode: no download runs while the SDK is in offline mode, or while the request queue still holds requests with the offline placeholder ID, because the values would be for the wrong user.
Networking and Params
The remote config fetch request has these parameters:
method : always "rc"
keys : JSON array of the keys to fetch (downloadSpecificKeys)
omit_keys : JSON array of the keys to leave out (downloadOmittingKeys)
metrics : the device metrics, added only if "metrics" consent is given
(see Device Metrics)
oi : "1" to also enroll the user in the A/B tests of the returned keys,
only when enrollABonRCDownload is set (see A/B Testing)The method parameter is always sent. keys and omit_keys are optional, and a request carries at most one of them. The older method=fetch_remote_config request is not used, because the server always enrolls the user in A/B tests on it.
Remote config fetch requests are sent to the "/o/sdk" endpoint (NOT /i).
# Download specific keys
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=rc' \
--data 'keys=["url", "limit"]' \
--data 'metrics={...}' # if metrics consent is given
# Download all keys
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=rc' \
--data 'metrics={...}' # if metrics consent is givenThe response is a JSON object of keys and their values. A value can be of any JSON type:
{
"url": "https://example.com",
"limit": 20,
"colors": { "button": "#f77a22" }
}Storage
Remote config values are stored persistently, in any key-value structure, for example this JSON:
{
"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: booleanisCurrentUsersData is false for a cached value, which may belong to a previous user (see enableRemoteConfigValueCaching), and true for a value downloaded for the current user.
Consent
The feature depends on "remote-config" consent, and "metrics" consent decides whether the metrics are added to the fetch request (see Device Metrics).
- If consent is given after init, and automatic triggers are enabled, all values are downloaded.
- If consent is revoked, the stored values stay as they are, but no new download runs.
A/B Testing
Users can participate in A/B tests. The server assigns every user a variant of each running test, and every remote config download already returns the assigned variant's values. Enrolling is what makes a user count in the test's results. Enrolling is off by default, so that the results compare users who actually used the tested value: the app enrolls users when it uses the value (getValueAndEnroll, enrollIntoABTestsForKeys), or turns on enrolling with every download (enrollABonRCDownload).
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: Off by default. When set, every remote config download also enrolls the user in the A/B tests of the keys it returns, by adding oi=1 to the fetch request.
- Without it, the SDK leaves oi out (or sends 0), and downloads enroll nobody
- Meant for apps where everyone who downloads the values also sees the tested change. Otherwise the app enrolls users when they use a value, with getValueAndEnroll or enrollIntoABTestsForKeys
For instance method getAllValuesAndEnroll
Map<String, RCData> CountlyInstance.getAllValuesAndEnroll()Logic: Returns all stored values, like getValues, and enrolls the user for all their keys with enrollIntoABTestsForKeys. When no value is stored, nothing is enrolled.
For instance method getValueAndEnroll
RCData CountlyInstance.getValueAndEnroll(key: String)Logic: Returns the stored value of the key, like getValue. If a value exists, it also enrolls the user for that key with enrollIntoABTestsForKeys.
For instance method enrollIntoABTestsForKeys
CountlyInstance.enrollIntoABTestsForKeys(keys: Array<String>)Logic: Enrolls the user in the A/B tests of the given keys.
- Requires the "remote-config" consent
- If no valid key is given, the call is omitted with a warning
- Omitted when the SDK is in offline mode, the request queue still holds requests with the offline placeholder ID, or the device ID is not known
- The enroll request goes through the request queue (to "/o/sdk", see Networking and Params), so that it is not lost while the device is offline. No callback
For instance method exitABTestsForKeys
CountlyInstance.exitABTestsForKeys(keys: Array<String>)Logic: Removes the user from the A/B tests of the given keys.
- If keys are null or empty, the user is removed from all A/B tests
- The same consent, offline mode and device ID checks as for enrollIntoABTestsForKeys apply
- The exit request goes through the request queue (to "/i"). No callback
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, ExperimentInformation> CountlyInstance.testingGetAllExperimentInfo()Logic: Before using this function, experiment information must be downloaded. This function will return all experiment information.
ExperimentInformation will be mentioned in the Storage section.
For instance method testingGetVariantsForKey
String[] CountlyInstance.testingGetVariantsForKey(key: String)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)Logic: Downloads the variants of all running A/B tests.
- If the SDK is in offline mode, the request queue still holds requests with the offline placeholder ID, or the device ID is not known, the call is omitted and the callback gets Error
- The request is sent right away, not through the request queue. When the server can't be reached, the callback gets NetworkIssue
- On success, the downloaded variants replace all stored variants, and the callback gets Success
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)Logic: Downloads information about all running A/B tests (experiments), including the user's current variant in each.
- The same checks, request handling and callback results as for testingDownloadVariantInformation apply
- On success, the downloaded experiments replace all stored experiment information
For instance method testingEnrollIntoVariant
CountlyInstance.testingEnrollIntoVariant(keyName: String, variantName: String, completionCallback: RCVariantCallback)Logic: Puts the user into the given variant of the A/B test of the given key, for testing.
- The same checks as for testingDownloadVariantInformation apply
- The request is sent right away, not through the request queue
- The enrollment succeeded when the server answers {"result": "Success"}. Any other answer gives the callback Error
- On success, the stored remote config values no longer match the user's variant: they are deleted, or marked as cached with value caching. With automatic triggers, all values are downloaded again. The callback gets Success
Networking and Params
If the configuration enrollABonRCDownload is set, the SDK adds a parameter to the remote config fetch request: oi=1
# a remote config fetch request that also enrolls the user
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=rc' \
--data ...other remote config fetch request params if any \
--data 'oi=1'Enrolling in the A/B tests of some keys, sent through the request queue to "/o/sdk":
# an enroll request
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=ab' \
--data 'keys=["key1", "key2"]'Exiting the A/B tests of some keys, sent through the request queue to "/i". Without keys, the user leaves all tests:
# an exit request
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'method=ab_opt_out' \
--data 'keys=["key1"]'Here is a request that fetches all variants, sent directly to "/o/sdk". It has no parameter other than method:
# a variants fetch request
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=ab_fetch_variants'And the response would look like this:
{
"key1": [
{
"name": "variant1",
"value": "value1"
},
{
"name": "variant2",
"value": "value2"
}
],
"key2": [
{
"name": "variant1",
"value": "value1"
}
]
}Here is a request that fetches all experiment information, sent directly to "/o/sdk". It has no parameter other than method:
# an experiment information fetch request
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=ab_fetch_experiments'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 request that enrolls the user into a variant, sent directly to "/i":
# an enrolling into a variant request
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'method=ab_enroll_variant' \
--data 'key=keyName' \
--data 'variant=variantName'A successful enrollment is answered with:
{
"result": "Success"
}Storage
AB Testing is not stored persistently. Variants and experiment information 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 information is stored in a string and ExperimentInformation structured map where key is experiment id and value is ExperimentInformation structure:
ExperimentInformation:
experimentID: id of the experiment, string
experimentName: name of the experiment, string
experimentDescription: description of the experiment, string
currentVariant: the user's variant in that experiment, string (null when the
user is not enrolled)
variants: the experiment's variants, keyed by variant name. Each one maps the
remote config keys that the variant sets to their valuesConsent
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.
"Rating" is used in two senses in this guide. The Rating feedback widget described in this section is one of the three widget types; it needs the "feedback" consent. The legacy rating widget APIs (recordRatingWidgetWithID, presentRatingWidgetWithID) and the native star rating dialog are older APIs, documented under the Star Rating section in Legacy Features; they need the "star-rating" consent. Both record [CLY]_star_rating events.
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)Logic: Sends an immediate (non-queued) request to /o/sdk, with method=feedback and the base request parameters in the body. The server answers with the widgets this user may see now (see General Information). 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. widgetVersion is null when "wv" is missing or null. Entries without an ID, or with a type other than nps, survey or rating, are dropped with a log. Returns the list and null error on success, or empty list and error string on failure. The callback is called in every case.
For instance method presentFeedbackWidget
CountlyInstance.presentFeedbackWidget(widgetInfo: CountlyFeedbackWidget, callback: FeedbackCallback)Preconditions
- The app has not turned off the SDK's web views (disableWebView, see Init Configuration); otherwise the widget is not shown, and onFinished gets an error.
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.
For widgets WITH wv:
- Display in a fullscreen/transparent overlay where the webview handles its own close button.
- Not shown while content from the content zone is on screen; onFinished gets an error instead.
The WebView starts invisible; once the page loads, it becomes visible and interactive (see Page Load Handling). The widget page records the user's answers itself. The SDK records only the "closed" event (see Report widget result), when the user closes the widget with the SDK's close button or the page's own close button, and only if the widget was shown: a widget that failed to load was not closed by the user, and a "closed" event can hide a widget from that user for good. Callback:
- onFinished(null) once the widget is shown; onFinished(error) if it could not be shown
- onClosed() when the widget is closed
For instance method getFeedbackWidgetData
CountlyInstance.getFeedbackWidgetData(widgetInfo: CountlyFeedbackWidget, callback: RetrieveFeedbackWidgetData)Logic: Sends an immediate (non-queued) POST request to /o/surveys/{type}/widget, with the base request parameters (see SDK Storage and Requests) and widget_id, shown=1, app_version and platform in the body, like every other request. The "shown=1" parameter causes the server to count this as a widget impression, so the app calls it only when it is about to show the widget in its own UI. 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>)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. Entries with a null or empty key, or a null value, are dropped with a warning.
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" is required, and must be a whole number from 0 to 10.
- Rating: "rating" is required, and must be a whole number from 1 to 5.
- A result with a missing rating, a rating that is not a whole number, or a rating out of range is not recorded; the SDK logs a warning.
- String values are truncated to the maxValueSize limit.
- If widgetData is given and its ID or type does not match widgetInfo, a warning is logged.
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, so that the server stops offering the widget to this user.
For more information about manual feedback reporting, see the 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.Logic
- Calls getAvailableFeedbackWidgets to fetch the current widget list.
- Filters the list by the target type (nps, survey, or rating).
- If nameIDorTag is provided and non-empty:
- A widget whose ID matches wins, then a widget whose name matches, then the first widget that has it as a tag.
- If nameIDorTag is empty or not provided:
- Selects the first widget of the matching type.
- Calls presentFeedbackWidget with the matched widget and the provided callback.
- If the list can't be fetched, or no widget matches, nothing is shown and onFinished gets an error message. A given nameIDorTag that matches nothing never falls back to another widget.
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.
The widget list is made for the current user. The server leaves out widgets whose targeting (cohort) does not include the user, and widgets the user has already answered, or closed if the widget is shown until closed. That is why the SDK sends the "closed" event and manual results right away.
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) and onClosed() (see presentFeedbackWidget).
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, and the widget page sends the user's answers to the server itself.
- 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.
All feedback widget operations are blocked in offline mode: getting the widget list or the widget data gives the callback an error (with an empty list or no data), presenting a widget shows nothing and its callback gets an error, and a manual report is ignored with a warning.
Browser SDKs: the widget is shown in an iframe inside the page instead of a native web view:
- Where and when it appears follows the widget's settings from the list: its position, and for surveys the show policy (after page load, after a delay, when the visitor is about to leave, or after scrolling halfway down). A rating widget can also show a sticker on the page that opens it.
- The widget page and the SDK talk through
postMessageinstead of intercepted URLs. The page sendscloseandresize_memessages, as content does (see Content Zone). The SDK passes the page's origin in the widget URL (origin) so that the widget knows where to send them, and accepts messages only from the widget's own iframe. - The SDK adds the visitor's window size to the
customparameter (width,height).
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_eventAll 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=1When 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_ratingdepending on widget type, with"closed":"1"in segmentation), and send it right away - 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=1The 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 opens the provided URL with the same link rules as content (see Content Zone): the app's URL handler gets it first, links with dangerous schemes are never opened, and if the app set allowed schemes, only those are opened. Otherwise the URL opens 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=1When the SDK detects a URL ending with cly_x_int=1 in the WebView’s URL loading callback, it opens the full URL outside the WebView, following the same link rules, and prevents 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, in the dialog or the full-screen overlay that presentFeedbackWidget describes
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. A failure close calls onFinished with an error, and records no "closed" event.
Once the widget is shown, there is no time limit: the user may take as long as needed to answer.
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]All of these parameters are required. The widget page sends the user's answers with them, so a missing app_version or platform files the answers under a wrong or unknown version or platform.
The SDK also adds th with the device's appearance (light or dark mode), so that the widget page can match it.
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.
If the app set global content segmentation (see Content Zone), the SDK adds it to the custom parameter as sg, so that the widget page includes it in the answers it records. The SDK also adds it to the "closed" event and manual results it records itself.
For a widget without a version: {"tc":1}
For a widget with a version: {"tc":1, "xb":1, "rw": 1}
With global content segmentation: {"tc":1, "xb":1, "rw": 1, "sg": {"campaign": "spring"}}Even if parameter tamper protection is enabled, this URL does not use the checksum param. The widget page sends the answers to endpoints that do not check it.
Consent
Feedback widget operations, the Rating feedback widget included, require "feedback" consent.
When "feedback" consent is removed while a widget is on screen, the SDK closes the widget right away. It records no "closed" event, because the consent is gone, and calls onClosed(), so that the app is not left waiting. The same happens when a device ID change without merge removes the consent (see User Consent).
The legacy rating widget APIs and the native star rating dialog 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' \
--data 'method=feedback'The response:
{
"result": [
{ "_id": "614871419f030e44be07d82f", "type": "nps", "name": "Checkout NPS", "tg": ["checkout"], "wv": "1" },
{ "_id": "600e9e0b563e89201631633e", "type": "survey", "name": "Onboarding", "tg": [], "wv": null }
]
}2. Fetch widget data
Immediate POST 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 POST \
--url 'https://YOUR_SERVER/o/surveys/{type}/widget' \
--data 'widget_id=WIDGET_ID' \
--data 'shown=1' \
--data 'app_version=APP_VERSION' \
--data '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}&th=dark4. 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. rating is sent as a number, because the server adds NPS ratings into the score totals.
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data-urlencode 'events=[{"key":"[CLY]_nps","count":1,"segmentation":{"widget_id":"WID","platform":"PLATFORM","app_version":"VERSION","rating":8,"comment":"Great"}}]'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 sent to the server explicitly via save(), or automatically (see save).
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 (the server also accepts an empty string).
Custom properties support MongoDB-style modifier operations (increment, multiply, array push/pull, etc.) that are applied server-side. Repeated calls of the same modifier on the same key are combined locally and sent together on save() (see Combining Changes).
Order with events: the server stores every event together with the profile values the user had at that moment. So before an event is recorded, the SDK saves the pending profile changes, and before a profile request is queued, the SDK moves the events recorded so far into a request. Each event then reaches the server after the profile changes made before it, and before the ones made after it.
Combining Changes: until save, the SDK combines the changes of each key:
- A new value set for a key replaces the pending one.
- The same modifier called again on the same key is combined: increments add up, multiplications multiply, saveMax keeps the highest value and saveMin the lowest, setOnce keeps the first value, and push, pushUnique and pull collect all values in call order.
- A different change for a key that already has a pending one (another modifier, or a new value) makes the SDK save the pending changes first, then queue the new one. Both are applied in call order; the server can't apply two different modifiers to the same property in one request.
What the server keeps: the server stores at most 20 custom properties per user by default (its "Maximum number of custom properties" setting). A new custom property beyond that is dropped; existing ones can still be updated. An array property keeps the latest 50 values by default.
Implementation Details
For instance method setProperty
CountlyInstance.setProperty(key: String, value: Object)Valid values: value: String, Number, Boolean, 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: 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: maxValueSizePicture, 4096 chars by default)
- "picturePath" - path to a local image file (PNG, GIF or JPEG), uploaded with the request instead of a URL (limit: maxValueSizePicture)
- "gender" - gender (String, e.g. "M", "F")
- "byear" - birth year (Integer; other values are ignored with a warning)
Logic: Queues multiple property changes in a single call.
- Predefined string values are truncated to the maxValueSize limit (256 chars by default), except "picture" and "picturePath", which use the maxValueSizePicture limit (4096 chars by default, see SDK Internal Limits)
- Null values are ignored with a warning
- Empty string value sends JSON null for that key, deleting the property on the server
- Custom property keys are truncated to maxKeyLength, and string values to maxValueSize
- Custom properties are stored under a nested "custom" JSON object
- Custom property keys are checked against the upb/upw filter lists before queuing
- Changes are combined with the pending ones (see Combining Changes) and are NOT sent until save() is called (or triggered automatically)
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. Pending increments of the same key add up.
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. Pending increments of the same key add up.
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. Pending multiplications of the same key multiply together.
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. Of the pending calls for the same key, the highest (saveMax) or lowest (saveMin) value is sent.
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. Of the pending calls for the same key, the first value is kept.
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: Sends all pending user property changes to the server.
- First moves the recorded events into a request (see Order with events)
- Serializes predefined and custom properties into a JSON object sent as "user_details"
- Custom modifiers are nested under the "custom" key, as an object per property that holds the modifier and its value (see Networking and Params)
- After queuing, the pending changes are cleared
- Requires "users" consent; without it, the pending changes are discarded
- Does nothing when there are no pending changes
The SDK also saves automatically:
- before an event is recorded
- before the begin_session, session update and end_session requests
- on every tick of the update timer (setUpdateSessionTimerDelay, see Init Configuration)
- before a device ID change, so that the changes stay with the previous ID
- when the pending changes reach the "upcl" limit (see SDK Behavior Settings)
- at the end of init, when the config gave user properties
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 and "picturePath" use maxValueSizePicture (default 4096 chars)
instead of maxValueSize
User property cache limit: number of pending user property changes that
triggers an automatic save (default 100, configurable via server setting "upcl")SDK Behavior Settings:
upb (user property blacklist) — String array, default: empty
Custom property keys in this list are silently dropped before queuing.
An empty list is ignored. If both are non-empty, upb takes precedence.
upw (user property whitelist) — String array, default: empty
Only custom property keys in this list are accepted; all others are silently dropped.
Has no effect while a non-empty upb is set.
upcl (user property cache limit) — Integer, default: 100
When the pending user property changes reach this number, the SDK saves them
automatically. Pending changes are never dropped because of this limit.Consent
The feature depends on "users" consent. Without it, user property calls are ignored, and pending changes are not sent.
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,
"phone": null,
"custom": {
"level": 5,
"nickname": null,
"score": { "$inc": 10 },
"badges": { "$push": ["gold", "silver"] },
"tags": { "$addToSet": ["beta"] },
"old_tags": { "$pull": ["alpha"] },
"first_login": { "$setOnce": "2026-10-29" }
}
}curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'user_details={"name":"Jane Doe","email":"jane@example.com","custom":{"level":5,"score":{"$inc":10}}}'Modifier operations ($inc, $mul, $max, $min, $setOnce, $push, $addToSet, $pull) are nested under the custom key per property: each modified property maps to an object that holds one modifier and its value, for example "score": {"$inc": 10}. Array modifiers ($push, $addToSet, $pull) send their accumulated values as arrays.
A null value (or an empty string) deletes the property: "phone" and "nickname" in the example above.
A local picture (picturePath) is uploaded as a file in the same request (multipart/form-data). The server accepts PNG, GIF and JPEG images and stores them as the user's picture.
Application Performance Monitoring
APM is dropped. New SDKs don't implement it. SDKs that already have it keep their current implementation, which the documentation below describes, without new features.
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 config methods enableAppStartTimeTracking, enableManualAppLoadedTrigger and setAppStartTimestampOverride
CountlyConfig.apm.enableAppStartTimeTracking()
CountlyConfig.apm.enableManualAppLoadedTrigger()
CountlyConfig.apm.setAppStartTimestampOverride(timestamp: long)Logic: enableAppStartTimeTracking records an "app_start" device trace: the time from the app's start to the moment the app has finished loading.
- The start is the moment the SDK's code was first loaded in the app's process, or the timestamp given with setAppStartTimestampOverride (ms since epoch), for apps that know an earlier start
- The end is the moment the first screen is shown, or, with enableManualAppLoadedTrigger, the setAppIsLoaded call
- Recorded once per app start
For config method enableForegroundBackgroundTracking
CountlyConfig.apm.enableForegroundBackgroundTracking()Logic: Records how long the app stays in the foreground and in the background, as "app_in_foreground" and "app_in_background" device traces, one each time the app moves between the two states.
For instance method startTrace
CountlyInstance.startTrace(traceKey: String)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>)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). With the default maxKeyLength of 128, the 128 limit applies first
- 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
- An end call for a trace that is not running is ignored with a warning
For instance method recordNetworkTrace
CountlyInstance.recordNetworkTrace(networkTraceKey: String, responseCode: int,
requestPayloadSize: int, responsePayloadSize: int,
requestStartTimestampMs: long, requestEndTimestampMs: long)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 are set to 0
- If startTimestamp > endTimestamp, the values are swapped
- Trace key is truncated by maxKeyLength, then validated (max 2048 chars)
- The trace is added to the request queue right away (see Networking and Params)
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)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" trace, from the app's start (see enableAppStartTimeTracking) 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.
The key rules and limits (the 32-character metric keys, the 2,048-character trace keys, the "$" and "." warnings, the reserved metric keys and the internal limits) are listed with endTrace and recordNetworkTrace above.
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. The JSON is built with a JSON serializer, so that a trace key with quotes or backslashes still gives valid JSON. stz and etz are the start and end timestamps in milliseconds since epoch. Custom traces and app start, foreground and background traces have the type "device"; network traces have the type "network".
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'apm={"type":"device","name":"app_start","apm_metrics":{"duration":3200},"stz":1584698900000,"etz":1584698903200}'Sample custom trace request:
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'apm={"type":"device","name":"forLoopProfiling_1","apm_metrics":{"duration":10,"memory":200},"stz":1584698900000,"etz":1584698900010}'Sample network trace request:
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--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":1584698901330}'Sample foreground trace request:
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'apm={"type":"device","name":"app_in_foreground","apm_metrics":{"duration":15000},"stz":1584698900000,"etz":1584698915000}'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. When it is removed, all running custom traces and network requests (started with startNetworkRequest) are cancelled without being recorded.
User Consent
Exposed Methods
Config Methods
CountlyConfig.setRequiresConsent(shouldRequireConsent: boolean)
CountlyConfig.setConsentEnabled(featureNames: String[])
CountlyConfig.giveAllConsents()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 valid feature names are listed under Exposing Available Features for Consent.
An SDK offers the names of the features it has: "clicks", "scrolls" and "forms" exist only in browser SDKs, and an SDK without heatmaps, push or APM leaves those names out (see Exposing Available Features for Consent). A name the SDK does not know is ignored with a warning.
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
- The server can turn the requirement on or off with the SDK Behavior Settings key "cr" (see General Information)
For config methods setConsentEnabled and giveAllConsents
CountlyConfig.setConsentEnabled(featureNames: String[])
CountlyConfig.giveAllConsents()Valid values: featureNames must contain valid feature name strings (see list above)
Logic: Provides initial consent during SDK initialization: for the listed features, or for every feature (giveAllConsents).
- Only has an effect when consent is required
- Features not listed start with consent=false
- The resulting consent state is sent to the server once, at the end of init (see Networking and Params)
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
- Ignored with a log when consent is not required. Consent given while the requirement is off is not remembered
- Unknown feature names are ignored with a warning
- 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, and the features react (see Feature reactions in General Information)
- If anything changed, 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, and is ignored in the same cases
- 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, and the features react (see Feature reactions in General Information)
- Data collected while consent was given and already queued is still sent; removing consent only stops new collection
- If anything changed, a consent update request is queued to notify the server
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: Logs whether consent is required, and the consent state of every feature. A debugging aid; it changes nothing.
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).SDK Behavior Settings:
cr (consent required) — Boolean
When the server sends cr=true, the SDK requires consent as if
setRequiresConsent(true) had been configured locally. When it sends cr=false,
the SDK lifts the requirement, even if the app set it: every getConsent() call
returns true. When cr is removed from the settings, the developer's own value
applies again.
The change takes effect immediately: turning the requirement on acts like
removing consent for every feature that has none, and lifting it acts like
giving consent for every feature.Device ID change without merge: see Silent Consent Removal below. The SDK tells the modules that the change comes from the device ID change, for example with 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:
- consent has to first be required in the app — otherwise the SDK works as if all consent is given
- 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 threeFeature reactions: when consent changes, each feature reacts as its own section describes:
- sessions - given while the app is in the foreground with automatic session tracking: a session begins. Removed: the running session ends.
- events - removed: running timed events are cleared.
- views - removed: all active views are stopped.
- location - given: stored location values are sent. Removed: stored values are cleared and a location erase request is sent.
- crashes - removed: the breadcrumbs are cleared.
- users - removed: pending profile changes are not sent.
- push - given: the token is obtained and sent, if permission allows. Removed: an empty token is sent and Countly messages are no longer shown.
- remote-config - given: values are downloaded, if automatic triggers are on. Removed: stored values stay.
- content - removed: the content zone is exited and displayed content is removed.
- feedback - removed: a widget on screen is closed, without a "closed" event (see User Feedback).
- metrics - removed: metrics are no longer added to requests (see Device Metrics).
- apm - removed: running traces are cancelled.
Common integration flow when requiresConsent = true:
- Set requiresConsent = true in config — the SDK starts but collects nothing. It still sends a few requests that need no consent: the consent state, the health check, the SDK Behavior Settings fetch, a connection test report when the server arms one, and a location erase request at the end of init. The location erase request may change (see Recording Location).
- Show your consent UI; persist the user's choices in your app storage.
- Call giveConsent(featureNames) for the approved features — the SDK activates those features and notifies the server.
- On every subsequent app launch, re-provide consent from your stored choices.
- If the user later opts out, call removeConsent(featureNames) — the SDK stops those features and notifies the server.
Silent Consent Removal
A device ID change without merge removes all consent silently: no consent request goes out (see Device ID Management). Everything the previous user needed was already sent under the old device ID before the switch. So on this removal, the features run only the reactions that change state on the device, and skip the ones that would send something:
- Run: timed events are cleared; the breadcrumbs are cleared; stored location values are cleared; a content or feedback widget on screen is closed; running traces are cancelled; Countly push messages are not shown until the "push" consent is given again.
- Already done before the switch: the running views were stopped, the session was ended, and pending events and profile changes were sent under the old device ID.
- Skipped: the consent request, the location erase request, and the empty push token, which was sent under the old device ID before the switch.
After the switch, each feature starts working for the new user once the app gives its consent again.
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. Their names are sent with hyphens, not in camelCase: "star-rating", not "starRating", and "remote-config", not "remoteConfig".
-
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 (browser SDKs) -
clicks- allow user clicks for heatmaps as well as link clicks to be tracked (browser SDKs) -
forms- allow user's form submissions to be tracked (browser SDKs) -
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 sent, both on their own (recordMetrics) and inside other requests: session start, remote config fetch and health check (see Device Metrics)
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 features the SDK has 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' \
--data 'consent={"sessions":true,"events":true,"views":false,...}'The request is sent:
- once at the end of init, when consent is required, so that the server knows the starting state;
- after every consent change made by the app;
- after the server URL or the app key changes, while consent is required, because the new server or app doesn't know the state yet (see Changing the Server URL and Changing the App Key).
No consent request is sent while consent is not required, and none for the removal that comes with a device ID change without merge (see Silent Consent Removal).
An SDK may combine consent changes made within about one second into one request, as long as that request carries the final state.
The server keeps the consent state on the user's profile and records every change as a [CLY]_consent event, showing which features were given and which were removed.
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), so that a notification arriving while the app is not running can be handled (see Push Notifications). 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, for example CountlyConfig.setParameterTamperingProtectionSalt(salt: String). The same salt is set in the app's settings on the server.
If this SALT is provided, right before making the request, the SDK should take all the payload it is about to send (the query string encoded body of the POST request, including the app_key and device_id) and make a sha256 hash of this data with the SALT appended, and append the hash as checksum256={hash}.
if(salt){
data += "&checksum256=" + sha256Hash(data + salt);
}If SALT is not provided, the SDK should make ordinary requests without any checksums.
Rules for the checksum:
-
Every request the SDK sends to the server carries it: requests to
/i, and the read requests to/o/sdkand similar endpoints (remote config, feedback widget list, content, SDK Behavior Settings). Pages that the SDK opens in a web view (feedback widgets, content) don't carry it; their endpoints don't check it. -
Last step: the checksum is computed after every other parameter is final, including
rr, over exactly the string that is sent, andchecksum256is added as the last parameter. Changing anything after that, even re-encoding a value, makes the checksum fail. - Format: the SHA-256 hash as hexadecimal text. The server compares it without regard to case.
-
Multipart requests (requests that upload a file, such as a profile picture): the hash is computed over the text fields joined as
key=valuepairs with&, in the order they are sent, with the values not URL-encoded. The checksum is sent as one more text field,checksum256. The server rebuilds the same string from the fields it receives. - The salt itself is never sent and never written to the logs (see Logging).
What the server does: if the app has a salt, the server checks every request. A request without a checksum, or with one that doesn't match, is rejected with HTTP 400 and the message "App does not exist", the same answer as for an unknown app key, so that the answer does not reveal whether an app key is valid. The SDK treats it like any other rejected request (see SDK Storage and Requests), so a wrong salt holds back every request until it is fixed. If the app has no salt, the checksum is ignored.
The server also still accepts an older SHA-1 checksum in a checksum parameter. New SDKs use only checksum256.
Other Measures
Other security and privacy rules are described with the features they belong to:
- Transport - apps in production use an HTTPS server URL. Certificate pinning, where offered, needs one; pinning with an HTTP URL counts as an invalid URL (see Init Configuration).
- Logs - the salt, the values of custom request headers and other secrets never appear in log lines, and the app can keep console logs off in production builds (see Logging).
- Web views - an app that doesn't use content or feedback widgets can turn off every web view the SDK would show (see Web View UI in Init Configuration).
- Consent - with consent required, a feature collects nothing until its consent is given (see User Consent).
- Location - without location consent, or with location turned off, sessions carry the empty location, so that the server does not work out a location from the IP address (see Recording Location).
- Links from the server - links in push notifications, content and feedback widgets never open dangerous schemes such as "file", "javascript" or "intent", and the app can restrict them further (see Push Notifications and Content Zone).
- Scripts from the dashboard - browser SDKs load the heatmap overlay only from allowed dashboard addresses (see Heatmaps).
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. Values given in the config are recorded at the end of init.
These values are sent in a separate request, which goes into the request queue right away.
This requires "attribution" consent; without it, the calls are ignored.
What the server does with them: the server's attribution feature (campaigns created in the dashboard) counts the install for the campaign, stores the campaign on the user's profile, and sends the campaign's postbacks to ad networks. A user is attributed once; later attributions are ignored unless the server allows reassigning campaigns, which it always does for web apps.
Exposed Methods
Config Methods
CountlyConfig.setDirectAttribution(campaignType: String, campaignData: String)Instance Methods
CountlyInstance.recordDirectAttribution(campaignType: String, campaignData: String)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.
There is one type: "countly". That type expects the data to look like following: '{"cid":"[PROVIDED_CAMPAIGN_ID]", "cuid":"[PROVIDED_CAMPAIGN_USER_ID]"}'. The inserted values would be retrieved from install attribution, for example from the install referrer of the store.
The call to record it is recordDirectAttribution (see Exposed Methods); browser SDKs take the campaign ID and the click ID instead (see below).
If the provided type is "countly", the data string is a stringified JSON object that has 2 values: "cid", the Campaign ID, and "cuid", the Campaign user ID (the ID of the user's click on the campaign link).
- An empty type or data, data that is not valid JSON, or a type other than "countly" produces a warning, and nothing is sent.
- The "Campaign ID" value is mandatory. If it has no valid value, the call is ignored with a warning.
- The "Campaign user ID" value is optional and if it is missing or invalid, only the "Campaign ID" value should be sent.
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]"The server records the install only for a campaign that exists in the app. When a campaign user ID is sent, it must be the ID of a click the server recorded for that campaign; with an unknown one, the server records nothing. The campaign ID cly_organic records an organic install.
The type "_special_test" is reserved for internal tests: its data is sent, URL-encoded, in an attribution_data parameter, which the server does not store. New SDKs don't need to offer it.
Browser SDKs: a campaign link sends the visitor to the website with cly_id (the campaign ID) and cly_uid (the click ID) in the page URL. The SDK stores both when the page loads. Its direct attribution call takes the campaign ID and the click ID as arguments, uses the stored values when they are not given, and falls back to cly_organic when there is no campaign at all. The site calls it at the moment of the conversion, for example after a sign-up.
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 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 is set to the "aid" param and queued right away.
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 a warning is logged.
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 legitimate values.
If no valid pair is left after the validation, the call is ignored with a warning.
The call to record this value should be named something similar to "recordIndirectAttribution".
The server stores each value on the user's profile in lowercase, together with its MD5 and SHA-1 hashes, so that ad networks can match their own IDs to the user.
Browser SDKs don't offer indirect attribution, as browsers have no advertising IDs.
The param in the request would look something 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 limits the size of the data it records, so that a mistake in an app cannot produce oversized requests. The developer can change each limit in the config, for example CountlyConfig.sdkInternalLimits.setMaxKeyLength(value: int), and the server can override it with the SDK Behavior Settings key named next to it.
The SDK should have the following limits:
- "maxKeyLength" - 128 chars (server key "lkl")
Limits the maximum size of all string keys.
"Keys" include:
- event names
- view names
- custom trace key name (APM)
- network 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 (server key "lvs")
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" and "picturePath" fields, which have their own limit, "maxValueSizePicture" (see below)
- 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)
- "maxValueSizePicture" - 4096 chars (no server key)
Limits the user profile's "picture" URL and "picturePath" values, which are often longer than other values. The developer can change it with setMaxValueSizePicture; the server can't override it.
- "maxSegmentationValues" - 100 developer-supplied entries (server key "lsv")
Max amount of custom (dev-provided) segmentation in:
- Event segmentation
- View segmentation
- Global view segmentation
- Custom (Global) crash segmentation
- Crash segmentation
- Custom APM Metrics
- Custom user properties sent in one profile update
When there are more entries, the SDK keeps entries up to the limit and drops the rest with a warning that names the dropped keys. Where the platform's map type keeps the order in which the entries were added, the first ones are kept.
- "maxBreadcrumbCount" - 100 entries (server key "lbc")
Maximum amount of breadcrumbs that can be recorded before the oldest one is deleted
- "maxStackTraceLinesPerThread" - default value of 30 (server key "ltlpt")
limits how many stack trace lines would be recorded per thread
- "maxStackTraceLineLength" - default value of 200 (server key "ltl")
limits how many characters are allowed per stack trace line. This also limits the crash message length.
Besides these 2 crash limits, there is an internal one that can't be changed: "maxStackTraceThreadCount", at most 50 recorded threads, as a sanity check. Where a stack trace comes as one string, the SDK splits it into lines and applies the limits to each, so a report has at most 50 × 30 = 1,500 lines. The whole stack trace is also capped at 20,000 characters (see Crash Reporting).
No limits are applied to native crash reports, such as PLCrashReporter reports on iOS and native (NDK) crash dumps on Android.
Rules for all limits:
- Invalid values - a value below 1 is ignored with a warning. From the config, the default stays; from the server, it counts as a missing setting, so the developer's value (or the default) applies (see SDK Behavior Settings). It is never raised to 1 or used as given.
- Cutting - a string over its limit is cut to the limit, and a warning is logged. Values that are not strings (numbers, booleans) are not changed.
- Order - limits are applied when the data is recorded, before it is queued. A limit changed later by the server applies to data recorded from then on.
- Internal data - the SDK's own keys and values (for example "[CLY]_" event keys and the reserved segmentation of internal events) are not cut, so that the server can read them.
Backoff Mechanism
The SDK uses a backoff mechanism to pause sending queued requests for a while when the server answers slowly, so that a struggling server gets time to recover instead of receiving more load.
Exposed Methods
Config Methods
CountlyConfig.disableBackoffMechanism()Implementation Details
The backoff mechanism should be applied where the SDK sends the request queue.
It is checked after every request that succeeded, once that request has been removed from the queue. It uses a 3-stage check to determine whether to initiate backoff:
- Response Time Check: the time from sending the request until its whole response arrived is greater than or equal to the accepted timeout (default: 10 seconds). This is the full round trip, including the time the server takes to answer, not only the time to open the connection.
- Queue Load Check: the number of requests still in the queue is less than or equal to the accepted share of the maximum request queue size (default: 50%). A fuller queue means the SDK is behind, so it keeps sending.
-
Request Age Check: the request that just succeeded was created no longer ago than the accepted request age (default: 24 hours), measured from its
timestampparameter, which is in milliseconds. An older request means the SDK is catching up on a backlog, so it keeps sending.
If all conditions are met, the SDK enters backoff mode and pauses sending queued requests for a defined duration (default: 60 seconds). After that duration, the SDK starts sending the queue again right away.
The current request is still processed and removed from the queue, since a response was received.
While the pause lasts:
- The SDK keeps recording. Events and other data are still recorded and queued; only sending waits.
- Calls that ask the SDK to send the stored requests also wait for the pause to end. Server SDKs are the exception: a call that waits until the queue is sent, used before the process ends, sends right away, so that shutting down is not delayed.
- The pause is kept only in memory. When the app or the process starts again, sending starts normally.
Note: Immediate requests are not subject to the Backoff Mechanism. These are the requests the SDK sends directly instead of through the queue, for example remote config downloads, feedback widget lists, content, and the SDK Behavior Settings fetch.
The backoff mechanism only reacts to slow answers that succeeded. A failed request (a network error, or an answer that is not a success) follows the retry cool-down described in SDK Storage and Requests. It doesn't start a backoff and isn't counted as one.
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, as a fraction above 0 and at most 1 (default: 0.5). The dashboard shows it as a percentage, for example 50%, and the server sends it as a fraction. -
bom_ra: Accepted request age in hours (default: 24) -
bom_d: Backoff duration in seconds (default: 60)
bom_at, bom_ra and bom_d are whole numbers of at least 1. A value that is out of range or of the wrong type is ignored with a warning, as if the server had not sent it (see SDK Behavior Settings). In the dashboard, operators can pick a preset (Default, Aggressive, Relaxed) or set the values one by one.
The developer's choice and the server's setting: disableBackoffMechanism() turns the mechanism off from the start of init, before any server settings have arrived. When the server's settings contain bom, that value wins in both directions: true turns the mechanism on even if the developer turned it off, and false turns it off. When the server's settings don't contain bom, the developer's choice applies.
To observe SDK health and how the backoff mechanism is doing, two health check parameters are sent for the backoff mechanism:
-
bom: how many times a backoff started (each time is one pause) -
cbom: the longest series of backoffs that followed each other, with no request in between that was answered in time
Both counters are kept across restarts. Once a health check has been delivered, the amount it reported is subtracted from bom, and cbom goes back to 0 if it still holds the reported value. Backoffs counted while the health check was on its way are kept for the next one. For more information about SDK Health Checks see here.
For config method disableBackoffMechanism
CountlyConfig.disableBackoffMechanism()Logic: Disables the backoff mechanism
- After disabling, the mechanism must not work, unless the server's SDK Behavior Settings turn it on with "bom".
- It applies from the start of init, also before any server settings have been received.
SDK Behavior Settings
SDK Behavior Settings provide a way for the server to configure the SDK. An operator sets them in the SDK Manager of the dashboard. They decide which features are allowed to work, which internal limits are used, how much data is batched and how often it is sent, and which events and user properties are filtered out.
Exposed Methods
Config Methods
CountlyConfig.setSDKBehaviorSettings(sdkBehaviorSettings: String)
CountlyConfig.disableSDKBehaviorSettingsUpdates()Implementation Details
During init, the SDK applies the behavior settings it already has. At the end of init, it fetches the current settings from the server, stores the answer persistently and reconfigures itself.
The settings come in layers, and each layer overrides the ones before it:
SDK's Default < Developer's Config < Provided Behavior Settings < Stored Behavior SettingsSBS = SDK Behavior Settings
- Stored behavior settings are the last valid answer from the server.
- Provided behavior settings (setSDKBehaviorSettings) stand in for a server answer while nothing is stored, for example on the first launch without internet access. Once the server has answered, they are no longer used.
- A setting that is in the answer replaces the developer's value for it, whether that turns something on or off, or raises or lowers a value.
- A setting that is not in the answer, or whose value is invalid, falls back to the developer's config, and without one, to the SDK's default.
| Init Time Status | Initial Behavior | ||
|---|---|---|---|
| Stored SBS | Provided SBS | Offline Mode | |
| ● | Uses Stored SBS | ||
| ● | ● | Uses Stored SBS | |
| ● | ● | Uses Stored SBS | |
| ● | ● | ● | Uses Stored SBS |
| ● | Uses Provided SBS | ||
| ● | ● | Uses Provided SBS | |
| ● | Uses Developer's Config | ||
| Uses Developer's Config | |||
After this initial behavior, the SDK fetches the SDK Behavior Settings again to be up to date and stores the answer. If that request fails or the answer is invalid, it keeps using what it has.
When the SDK fetches the settings:
- at the end of init;
- every
scuihours while the SDK runs (default: 4 hours). Whenscuichanges, the timer restarts with the new interval; - when the app comes back to the foreground and
scuihours have passed since the last fetch; - when the SDK leaves offline mode;
- after a device ID change, because an answer can carry parts meant for one user (see Networking and Params). After a change without merge, right away. After a change with merge, once the merge request has been sent, because the fetch would otherwise create the new user on the server first (see Remote Config);
- after the server URL or the app key changes (see Changing the Server URL and Changing the App Key).
While offline mode is on (see Device ID Management), the SDK doesn't fetch the settings. The settings themselves never stop the fetch: it still goes out when networking or tracking is off, because otherwise the server could never turn them back on. It is the only request that ignores the networking setting (see Immediate Requests).
Applying an answer:
- Each valid answer replaces the stored one completely; it is never merged with it. An answer with an empty
cis valid and means that the operator set nothing, so every setting goes back to the developer's config or the default. - A value of the wrong type, or outside the valid values in the table under Storage, is ignored with a warning, as if the key were missing.
- Keys the SDK doesn't know are ignored, so that a newer server can add settings.
- Changes apply to data recorded from then on. Data that is already queued is not changed.
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.
When a setting changes while the SDK runs:
-
trackingoff: the SDK records nothing new. Requests already in the queue are still sent. -
networkingoff: the SDK sends nothing except the behavior settings fetch. It keeps recording and queuing, and sends the queue once networking is back on. -
stoff: a running session ends normally, and no new session begins (see Sessions). -
ast,avt,acr: automatic session tracking, automatic view tracking and automatic crash reporting start or stop as if the developer had configured them that way. Withastoff, sessions follow the app's own session calls. -
cr: the consent requirement turns on or off, which acts like removing or giving consent (see User Consent). -
ltoff: location tracking stops, and sessions carry an empty location.lton: location tracking works, also when the app turned it off with disableLocation. Without the key, the app's own choice applies (see Recording Location). -
scui: the fetch timer restarts with the new interval. - Limits, queue sizes and intervals: they apply to data recorded from then on (see SDK Internal Limits, SDK Storage and Requests, Content Zone and Backoff Mechanism).
For config method setSDKBehaviorSettings:
CountlyConfig.setSDKBehaviorSettings(sdkBehaviorSettings: String)Valid values
- A JSON object in the same format as the server's answer (see Networking and Params), for example an answer copied from the server:
{"v":2,"t":1742459739383,"c":{"tracking":true,"lkl":64}} - An empty or invalid value is ignored with a warning, and init continues.
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.
- They are used only while no answer from the server is stored, and they are checked like a server answer.
- 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 are disabled, only the fetch requests stop.
The SDK still reads the stored behavior settings and the provided behavior settings.
The feature exposes an internal interface, for example ConfigurationProvider, through which the other components read the settings in effect: one getter per key of the settings table under Storage, typed like its values (for example Boolean getNetworkingEnabled() for "networking", Integer getRequestQueueSize() for "rqs", Double getBOMRQPercentage() for "bom_rqp"). The filters are read as one FilterList per category (eb/ew, upb/upw, sb/sw, esb/esw), and the journey triggers as sets of names (jte, jtv). Getters that only the feature itself uses may stay internal.
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. An empty list is ignored, as if it were missing. If a category has both a non-empty blacklist and a non-empty whitelist, the blacklist applies.
The filters apply only to the app's own data: custom events, their segmentation, and custom user properties. The SDK's internal events (event keys starting with "[CLY]_") and the named user properties (such as name, email or picture) are never filtered. Names are matched exactly, including their case.
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. Only custom events are affected by this. When one of these events is recorded, the SDK sends the queued events right away and, once they have been delivered, asks for content. If no content is shown, it asks again, up to 3 tries in total, 1 second apart.
Journey Trigger Views (jtv) is a JSON array of view names. When the SDK records a view event ("[CLY]_view") whose name is in the list, it does the same as for a journey trigger event.
Filter Application Logic:
Event filter (eb/ew):
- Applied before recording any custom 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 custom user property, including property modifiers. If the property key is filtered out, it is not sent.
- Same whitelist/blacklist logic as event filter
Segmentation filter (sb/sw):
- Applied to the segmentation of every custom event 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 filterNetworking and Params
Behavior settings fetch request has 1 parameter of its own:
method: this is always "sc"method parameter is always sent with behavior settings fetch request. The request also carries the common parameters, such as app_key, device_id, sdk_name and sdk_version (see SDK Storage and Requests), and the checksum when a salt is set (see Security and Privacy).
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, and the backoff mechanism doesn't hold them back.
# Common way to send
curl --request POST \
--url 'https://YOUR_SERVER/o/sdk' \
--data 'method=sc'Response would look like this:
{
"v": 2,
"t": 1742459739383,
"c": {
"tracking": false,
"networking": false,
// ...
}
}Which are:
v - schema version
t - timestamp of the answer
c - config object (key value pairs)
A valid answer is a JSON object with a numeric v, a numeric t and an object c, which may be empty. The SDK doesn't depend on the value of v.
The answer can carry other top-level keys that belong to other features, for example lg (see Log Gathering) or ct (see Connection Test). Such keys are meant for the current user only: they are read from a live answer and never stored. An SDK that doesn't support them ignores them, and it never rejects an answer because of an extra top-level key.
Storage
The newly fetched answer replaces the stored one as long as it is valid. Values are stored in their original format, as received in the answer. The stored answer belongs to the SDK instance, like the rest of its storage.
Here is the full list of settings, with their defaults and valid values:
| Feature Name | Key | Default Val | Valid Values |
|---|---|---|---|
| Allow all tracking | tracking | TRUE | true or false |
| Allow sending requests | networking | TRUE | true or false |
| Request queue size | rqs | 1000 requests | whole number, at least 1 |
| Event queue size (or batch) | eqs | 100 events | whole number, at least 1 |
| Session update interval | sui | 60 seconds | whole number, at least 1 |
| Allow session tracking | st | TRUE | true or false |
| Automatic session tracking | ast | TRUE | true or false |
| Allow crash tracking | crt | TRUE | true or false |
| Automatic crash reporting | acr | FALSE | true or false |
| Allow location tracking | lt | TRUE | true or false |
| Allow view tracking | vt | TRUE | true or false |
| Automatic view tracking | avt | FALSE | true or false |
| Key length limit | lkl | 128 chars | whole number, at least 1 |
| Value size limit | lvs | 256 chars | whole number, at least 1 |
| Segmentation values count limit | lsv | 100 key/values | whole number, at least 1 |
| Breadcrumb count limit | lbc | 100 breadcrumbs | whole number, at least 1 |
| Stack trace line count limit | ltlpt | 30 lines | whole number, at least 1 |
| Stack trace line length limit | ltl | 200 chars | whole number, at least 1 |
| Allow custom event tracking | cet | TRUE | true or false |
| Enter Content Zone after init | ecz | FALSE | true or false |
| Content Zone request interval | czi | 30 seconds | whole number, at least 15 |
| Consent required | cr | FALSE | true or false |
| Server config update interval | scui | 4 hours | whole number, at least 1 |
| Allow Refresh Content Zone | rcz | TRUE | true or false |
| Drop old request time | dort | 0 (disabled) | whole number of hours, 0 or more |
| Backoff mechanism enabled | bom | TRUE | true or false |
| Backoff mechanism accepted timeout | bom_at | 10 seconds | whole number, at least 1 |
| Backoff mechanism request queue % | bom_rqp | 0.5 | above 0 and at most 1 |
| Backoff mechanism request age | bom_ra | 24 hours | whole number, at least 1 |
| Backoff mechanism duration | bom_d | 60 seconds | whole number, at least 1 |
| User property cache limit | upcl | 100 | whole number, at least 1 |
| Event blacklist | eb | [] (empty) | JSON array of event names |
| Event whitelist | ew | [] (empty) | JSON array of event names |
| User property blacklist | upb | [] (empty) | JSON array of property keys |
| User property whitelist | upw | [] (empty) | JSON array of property keys |
| Segmentation blacklist | sb | [] (empty) | JSON array of segmentation keys |
| Segmentation whitelist | sw | [] (empty) | JSON array of segmentation keys |
| Event segmentation blacklist | esb | {} (empty) | JSON object: event name to JSON array of segmentation keys |
| Event segmentation whitelist | esw | {} (empty) | JSON object: event name to JSON array of segmentation keys |
| Journey trigger events | jte | [] (empty) | JSON array of event names |
| Journey trigger views | jtv | [] (empty) | JSON array of view names |
The defaults of ast, avt and acr are the SDK's own defaults: automatic sessions on, automatic views and automatic crash reporting off. When the developer configured them differently, the developer's config applies until the server sends the key.
Consent
This feature does not need any consent. The SDK fetches and applies the settings even while consent is required and none is given.
Health Checks
The SDK Health Monitoring feature automatically tracks internal SDK health indicators and reports them to the server once per SDK initialization (in browser SDKs, once per visit). This gives server-side visibility into whether the SDK is operating correctly: whether requests are succeeding, whether the backoff mechanism is being triggered excessively, whether any internal warnings or errors have occurred since the last successful report, how the app and the SDK call the SDK's features (method usage), and which known problems occurred (log codes).
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 counters 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). Browser SDKs send it once per visit instead, see General Information.
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 offline mode is active. It is then sent when the SDK leaves offline mode.
- Aborted if networking is disabled by the SDK Behavior Settings. The counters wait for the next SDK initialization.
- 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), only while the "metrics" consent allows it (see Device Metrics).
- Prepares the request data: the base request parameters (see Making Requests) and the metrics.
- Takes a snapshot of the health counters and keeps it until the request completes.
- Appends the serialized snapshot 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 a successful answer, by the same rule as for queued requests (a 2xx status and a JSON object with a "result" key):
- The reported amounts are taken away from the counters (clearSentValues(snapshot)). What was counted while the request was on the way stays for the next health check.
- On a network failure, no answer, a status outside 2xx, or an answer without "result". A 400 "App does not exist", the answer to a wrong app key or salt, is a failure too, so the counters that show the problem are kept:
- 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, whether or not logging is enabled. Entries logged before the module exists are counted once it does.
logError()
- Increments the error log counter ("el") by 1.
- Called whenever the SDK emits an internal error-level log entry, whether or not logging is enabled. Entries logged before the module exists are counted once it does.
logFailedNetworkRequest(statusCode: integer, errorResponse: string)
- Called when the server answers a request from the request queue with a failure. A request that fails without an answer, such as without a connection, changes nothing.
- Records the HTTP status code of a failed request ("sc").
- Status code must be > 0 and < 1000.
- Records the error response body ("em") as plain text, truncated to 1000 characters if longer.
- Only the most recent failed request is stored; previous values are overwritten.
logBackoffRequest()
- Increments the backoff counter ("bom") by 1: one more pause.
- Increments a temporary consecutive backoff run counter by 1, then raises "cbom" to the run counter when the run counter is higher. "cbom" is therefore always up to date, and nothing else has to end a run to update it.
- Records log code w320, and also w321 when the run counter was above 0 before this backoff.
- The run counter is kept in memory only, like the backoff pause: a restart ends the run.
- Called when a request is backed off, excluding immediate requests.
logConsecutiveBackoffRequest()
- Resets the temporary consecutive backoff run counter to 0, which ends the run.
- Called when a request completes without backing off. Excludes immediate requests.
- Only a request answered in time ends a run; saving the state does not (see saveState).
recordUsage(area: string, method: string)
- Adds 1 to the method usage count ("fu") of the method in its feature area.
- Ignored when the area or the method is empty, and while crash handling stops recording (see crash handling).
- A count stops growing at 65535 until it is reported.
- Called at the public entry point of every method listed under method usage.
- Ignored while consent is required and the feature of the area has no consent, see Consent.
recordLogCode(code: string, area?: string)
- Adds 1 to the count of the log code ("lc").
- Ignored when the code is empty, and while crash handling stops recording.
- A code that a public method of a feature area records passes that area, and is then ignored while the area's feature has no consent, like recordUsage.
- A count stops growing at 65535 until it is reported.
- Called next to the log line of the condition, see log codes.
For internal behavior method usage
What the app and the SDK call, per feature area. Counted at the public entry point of the method, before server configuration or any other check that may end up ignoring the call, but only while the feature of the area has consent.
Keys: "fu": { "<area>": { "<method>[:a][:d]": <count> } }
- Area and method names use letters, digits and "_", 1 to 40 characters. The server drops any other key.
- ":a" marks a call the SDK made by itself, for example for automatic session or view tracking. A call the app made carries no modifier.
- ":d" marks a call of a deprecated method. The method part is then the deprecated method's own name, for example "views" / "recordView:d", so the dashboard can name the call to replace.
- When both apply, they are written in this order: ":a:d".
Areas and methods
| Area | Method | What is counted |
|---|---|---|
| sessions | begin, update, end | the manual session calls of the app |
| sessions | begin:a, update:a, end:a | the sessions the SDK begins, updates and ends by itself: automatic session tracking, the update timer of the hybrid mode, SDK behavior settings turning automatic sessions on or off |
| events | record | every recordEvent call of the app, once, whichever convenience form it uses. Not counted for the events the SDK records itself (reserved events, content events) and not for the event an ended timed event records |
| events | start, end, cancel | startEvent, endEvent, cancelEvent |
| views | start | startView |
| views | startAuto | startAutoStoppedView |
| views | stop | stopViewWithName, stopViewWithID |
| views | stopAll | stopAllViews |
| views | pause, resume | pauseViewWithID, resumeViewWithID |
| views | start:a | a view automatic view tracking starts |
| crashes | record | every manual exception or error record call |
| crashes | log | a breadcrumb the app records |
| crashes | clear | clearing the breadcrumbs |
| crashes | record:a | an automatically detected crash the SDK reports, see crash handling |
Rules
- Only the methods listed here are counted, under the names used here in every SDK. A new area or method is added to this list before an SDK sends it.
- The server pairs the calls that open something with the calls that close it: events start with end and cancel, views start with stop and stopAll, sessions begin with end. Count every closing call the SDK offers, otherwise the dashboard reports work that was started and never ended.
- Count a call once, where the app or the SDK enters the feature. A public method that calls another public method internally counts only the one that was called.
For internal behavior log codes
Known problems, counted where the SDK detects them, next to the log line that reports them.
Keys: "lc": { "<code>": <count> }
- A code is a level letter ("e" error, "w" warning) and three digits, for example "w740". The letter is the level of the log line the condition writes (see Log Levels). The number means the same condition in every SDK. The hundreds group the topic: 1xx init and configuration, 2xx data limits, 3xx network and requests, 4xx consent, 5xx storage, 6xx device ID, 7xx feature misuse, 9xx internal.
- Only a condition that writes a warning or an error, and that a running SDK can report, has a code. Two kinds have none: conditions that stop the SDK from starting, such as a missing or invalid server URL or app key at init, because an SDK that did not start sends no health check; and failed requests, which the health check reports in "sc" and "em".
- Record a code once per occurrence. A drop of several requests or events at once counts each of them.
- Record only the codes listed below. A new condition gets a new number here first. A number is never reused for another condition once a released SDK has sent it.
- A code can carry a detail suffix that names where it fired: "-", an area letter and the two digit position of the method in the area's list, for example "w202-v01" for a value truncated in startView. The lists only ever grow at the end:
- "s" sessions: 01 begin, 02 update, 03 end
- "e" events: 01 record, 02 start, 03 end, 04 cancel
- "v" views: 01 start, 02 startAuto, 03 stop, 04 stopAll, 05 pause, 06 resume
- "c" crashes: 01 record, 02 log, 03 clear
Codes
| Code | Record when |
|---|---|
| w101 | a public method is called before the SDK is initialized, and the call is ignored (browser SDKs queue such calls instead, so they record nothing) |
| e102 | changeServerUrl gets a missing or invalid URL, and the change is ignored. With certificate pinning configured, an "http" URL is invalid too |
| e103 | changeAppKey gets a missing or invalid app key, and the change is ignored |
| w110 | init is called again after the SDK was started, and the call is ignored |
| w120 | a deprecated config option or method is used |
| w121 | a config option or a server setting has an invalid value, which is ignored |
| e130 | push is enabled, but none of the push services the platform supports (for example FCM or HMS) is in the app, so push does not start |
| w201 | a key is longer than the key length limit and is cut |
| w202 | a value is longer than its length limit and is cut |
| w203 | a map has more entries than its limit, and the extra entries are dropped |
| w205 | an error text or a stack trace is longer than its limit and is cut |
| w206 | a segmentation value has an unsupported type and is removed |
| w310 | the request queue is full, and the oldest requests are dropped |
| w320 | a backoff starts, because the server answered slowly |
| w321 | a backoff starts while a run of backoffs is going on, with no request answered in time since the previous one. Counted in addition to w320 |
| w330 | a request is dropped because it is older than the drop age |
| w402 | a consent call names no feature, or a feature the SDK does not know, and the call or that name is ignored |
| e501 | storage is unavailable, or a write fails |
| w502 | a storage fallback is used, for example cookies in a browser |
| e503 | stored data can not be read and is reset, for example a stored health check state that is not valid JSON |
| w504 | a crash report file can not be read, written or deleted |
| w505 | the SDK Behavior Settings turn tracking off, so a call that would record data is ignored |
| w601 | a device ID call gets a missing or invalid ID, and the call is ignored |
| w700 | a call is ignored, or a value in it is not used, because a value is missing or invalid, in a case without a code of its own |
| w701 | an event is recorded with a missing or empty key |
| w710 | a view is started with a missing or empty name |
| w720 | endEvent is called for a key that is not running |
| w721 | cancelEvent is called for a key that is not running |
| w722 | startEvent is called for a key that is already running |
| w730 | a view that does not exist is stopped, paused, resumed or segmented, for example with a view ID from before a device ID change |
| w731 | a paused view is paused again, or a view that is not paused is resumed |
| w740 | a manual session or view call is ignored because the SDK tracks it automatically, for example updateSession in hybrid mode |
| w750 | an event's count is below 1, its duration is negative, or its timestamp is 0 or less, so that value is ignored and its default is used |
| w760 | a manual session call is out of order and ignored: beginSession while a session is running, or updateSession or endSession without one |
| e901 | an unexpected internal error is caught in a public method |
For internal behavior crash handling
Reporting an automatically detected crash can run inside a signal handler, where queuing work, taking locks or allocating memory can deadlock the dying app.
Logic
- As soon as the crash handler starts reporting an automatically detected crash, recordUsage and recordLogCode do nothing, so the code the crash report runs (truncating its segmentation, queuing the report) never calls into the health check module. Lift this when crash reporting is started again and on halt, since an app can survive a reported crash through a handler of its own.
- The crash itself is counted without the health check module: as a plain number kept with the data the crash report persists anyway (the request queue on iOS), written by the save the crash report makes. On the next start the health check module adds that number to "crashes" / "record:a" and sets it back to 0, see initialization.
- A crash report a separate crash library captured and the SDK processes on the next start (for example PLCrashReporter) is counted with recordUsage("crashes", "record:a") when it is sent.
For internal behavior saveState / clearSentValues / clearAndSave
saveState()
- Leaves the current backoff run going: "cbom" already holds the longest run (see logBackoffRequest), so a save at any moment, on the update timer too, can not shorten it.
- Serializes all current counter values to a JSON object, the method usage ("MU") and log code ("LC") counts included.
- Persists the JSON string to the platform key-value store.
- Called on platform-specific app lifecycle events (e.g. activity stop, update timer, session end) to survive process death.
clearSentValues(snapshot)
- Called after a successful health check server acknowledgement, with the snapshot the request was built from.
- Takes the reported amount away from "el", "wl", "bom", every method usage count and every log code count. A count that reaches 0 is removed.
- Sets "sc" and "em" back to -1 and "" only while they still hold the reported values, so a request that failed after the snapshot is kept.
- Sets "cbom" back to 0 only while it still holds the reported value.
- Persists the remaining counters.
clearAndSave()
- Resets all in-memory counter values to their defaults:
- countLogWarning = 0
- countLogError = 0
- statusCode = -1
- errorMessage = ""
- countBackoffRequest = 0
- consecutiveBackoffRequest = 0
- consecutiveBackoffRequestCounter = 0
- method usage and log code counts emptied
- Clears the persisted storage entry (sets it to an empty string).
- Called when the stored state is malformed, see initialization.
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). The stored method usage and log code counts are added to what was counted before the stored state could be read. An entry of the wrong type is skipped.
- Adds the automatically detected crashes the previous run counted (see crash handling) to "crashes" / "record:a" and sets that number back to 0.
- 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 on by default, and the developer can turn the request off with disableHealthCheck(). sendHealthCheck above describes when the request is sent and when it is skipped: once per SDK initialization, right after init, as an immediate request.
Browser SDKs: the SDK starts again on every page load, so a browser SDK sends the health check once per visit instead: when it begins a new session. When the SDK doesn't track sessions, the health check is sent once per page load.
The tracking switch of the SDK Behavior Settings doesn't stop the health check, because it is a diagnostic, not tracking. Only the networking switch stops it.
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)
- Number of backoffs: how many times the SDK paused sending because the server answered slowly
- Longest run of backoffs in a row: the most backoffs that followed each other with no request in between that was answered in time
- How often each method of the session, event, view and crash features was called, by the app or by the SDK itself (method usage)
- How often each known problem occurred, as a log code shared by every SDK (log codes)
The health check module must not log at warning or error level, and neither may anything it calls: warning and error logs are counted by the module itself through logWarning() and logError(), so such a log would count itself, or enter the module again while it is being created.
Consent
The SDK Health Monitoring feature does not require a consent of its own. It is an internal SDK diagnostic mechanism and does not track personally identifiable information, and the health check is sent whatever the consent state. Method usage and log codes are counts of SDK calls and conditions only: they never carry event keys, view names, segmentation, values or any other data the app passes.
Knowing which methods an app calls is still tracking information, so method usage follows the consent of each feature. While consent is required, a call of a feature whose consent is not given is not counted, and neither is a log code that the call records. Each area follows the consent of the same name: sessions, events, views and crashes. Counting of an area starts when its consent is given and stops when it is withdrawn; what was counted before a withdrawal is still reported. Without required consent, every call is counted.
Networking and Params
The health check is an immediate request to the /i endpoint. Besides the base request parameters, it carries the &metrics param with only the app version (the overridden value when the developer set one, and only with the "metrics" consent, see Device Metrics), and the &hc param with the serialized health counters.
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'metrics={"_app_version":"1.0.0"}' \
--data 'hc={"el":5,"wl":12,"sc":503,"em":"Service Unavailable","bom":3,"cbom":2,"fu":{"sessions":{"begin:a":1,"update:a":4},"views":{"start":4,"stop":4}},"lc":{"w740":2}}'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, as plain text ("" if none)
"bom": <number>, // number of backoffs (pauses) since last successful health check
"cbom": <number>, // maximum consecutive backoff request count observed
"fu": <object>, // method usage { "<area>": { "<method>": <number> } }, {} if none
"lc": <object> // log codes { "<code>": <number> }, {} if none
}fu and lc are sent with every health check of an SDK version that supports them, as {} when nothing was counted. Their presence tells the server that the SDK reports them, so an empty list can be shown as nothing to address instead of not reported.
What the server does: it keeps the latest health check on the user's profile and uses the health checks for the health view of the SDK Manager. Like any request, a health check also updates the user's last activity time and app version on the server.
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>, // number of backoffs (pauses)
"CBReq": <number>, // maximum consecutive backoff count
"MU": <object>, // method usage counts, same shape as "fu"
"LC": <object> // log code counts, same shape as "lc"
}The state is not written after every change, only on app lifecycle events: the update timer, the end of a session, and any platform signal that the app is about to be closed or killed (for example activity stop on Android). It is reduced by what a successful health check reported. 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. The count of automatically detected crashes is not part of this state: it is written with the data the crash report persists, see crash handling.
Connection Test
A connection test lets a Countly operator check which parts of the Countly server one user's device can reach. Firewalls, proxies, content blockers and CDN rules sometimes block single paths, so that sessions arrive while feedback widgets or content never load. The operator arms the test for a user in the dashboard; the SDK on that user's device runs it once and reports what it found. Like the health check, it is a diagnostic: it needs no consent, and its probes carry no app data.
Arming
- The operator arms a test for one user. The server keeps it armed for 1 hour, and while it is armed, it adds the top-level key
ctto that user's SDK Behavior Settings answers, next to "c", "v" and "t". - The SDK reads "ct" only from a live answer and never stores it (see SDK Behavior Settings). A value of 1, or true, arms the test; 0, false, an empty value or a missing key don't.
- Each delivery runs the test at most once. While a test is running, another delivery is ignored.
When the Test Runs
- Right after the behavior settings answer that delivered "ct", on a background thread. It never blocks the app.
- Not while the server's settings turn networking or tracking off: the probes would go around the operator's own switch, and the report could not be queued.
- Not while the app is in the background, where network requests stall. The SDK waits until the app is in the foreground. If the app goes to the background during the test, the SDK drops the results and runs the test again once the app is back.
- For the user it was armed for: the report goes out under the device ID and type that the delivering fetch used. If that user was merged into another one since, the SDK drops the test, because under the merged ID the server rejects the report, and under the old ID it would create that user again.
- Not in offline mode, where no behavior settings are fetched, and not in backend mode.
- Halting the SDK drops a running test and its report.
Probes
The test checks a fixed list of rows, in this order. Each row stands for a feature and lists the paths that feature uses:
Row Paths 2xx required
core /o/ping yes
core-write /i no
sc no probe: the behavior settings fetch that delivered "ct"
rc /o/sdk with method=rc no
ab /o/sdk with method=ab_fetch_variants no
feedback /o/sdk with method=feedback no
feedback-widget /o/surveys/nps/widget, /o/surveys/survey/widget, no
/o/feedback/widget
feedback-submit /i/feedback/inputs no
content /o/sdk/content no
feedback-page /feedback/nps, /feedback/survey, /feedback/rating no
feedback-assets /surveys/images/ct-probe.png, yes
/star-rating/images/ct-probe.png
content-page /_external/content/ noHow a probe is sent:
-
API rows (core to content) are POST requests, like the SDK's own requests (see Using GET or POST). They carry no app data: only the method parameter where the row names one, and
ct=1. Without an app key and device ID, the server answers without doing any work, and a 4xx answer shows that the request reached Countly. - Page and file rows (feedback-page, feedback-assets, content-page) are GET requests, because they check what a web view loads, and a web view loads pages and files with GET.
- Every probe goes to the server URL in use, with the SDK's custom headers and its certificate or public key pinning, like the SDK's own requests. The SDK doesn't use a cache or cookies for probes and doesn't follow redirects.
- A probe waits at most 10 seconds. The whole test may take the number of probes times 10 seconds, plus 30 seconds. Rows that were not reached in that time are reported as not run.
How a probe is graded:
- No answer: not reached, with the reason "timeout" or "transport error".
- A 2xx status: reached.
- A 3xx status: not reached, with the reason "redirected". Redirects are not followed, so that a captive portal or a login page is not taken for the server.
- A 4xx status other than 403: reached, because Countly itself answered, except in rows that require a 2xx status.
- Any other status: not reached, with the reason "HTTP" and the status, for example "HTTP 403".
A row is reached only when all its paths are. It reports the status and reason of its first failing path (otherwise the status of its first path), the latency summed over its paths, and the number of paths when there is more than one. The "sc" row reports the fetch that delivered "ct": reached, status 200, and the fetch's full round trip as its latency.
Browser SDKs: the paths of the page and file rows send no CORS headers, so a browser can't read their answers. The SDK first tries a normal request, then an opaque one ("no-cors"). An opaque answer counts as reached, with the reason "opaque", to show that its status could not be read. Where the environment can't make these requests, the row is reported as skipped: {"f": "content-page", "sk": "unsupported"}.
Report
When the test is done, the SDK queues one request to /i with the base request parameters and the ct_results parameter. It goes through the request queue, so that it is not lost when the device goes offline.
ct_results={
"ts": 1762300000000, // when the report was made, in ms
"sdk": {"name": "java-native-android", "version": "26.8.0"},
"results": [
{"f": "core", "ok": true, "st": 200, "ms": 84},
{"f": "sc", "ok": true, "st": 200, "ms": 130},
{"f": "feedback-widget", "ok": false, "st": 403, "ms": 210, "e": "HTTP 403", "n": 3},
{"f": "content-page", "sk": "unsupported"}
]
}
f - row name ok - reached or not st - HTTP status, 0 when none
ms - latency in ms e - reason, when there is one
n - number of paths, when more than one sk - why the row was skipped- A report has at most 32 rows and 8 KB of text, and a reason has at most 256 characters.
- When the report is too long, the SDK first drops the reasons, except "opaque", which is not a detail but tells how certain the row is. Then it drops rows from the end.
- The server accepts the report while the user's test is armed and for 2 hours after it expired, stores it for the operator, and ends the test.
SDKs that wrap a native SDK leave the test to the native SDK.
Changing the Server URL
This feature adds the ability to change the server URL after the SDK is initialised, for example when an app moves to another server.
The call should be named something similar to "changeServerUrl".
CountlyInstance.changeServerUrl(serverUrl: String)Valid values
- The same as the server URL in the config: a non-empty, valid absolute URL with an "http" or "https" scheme (see Initialization). Trailing "/" characters are removed.
- With certificate pinning configured, the URL must use "https".
Logic
- An invalid URL is ignored with an error log, and the current URL stays.
- A valid URL overwrites the current URL in memory. It is not stored: the next init uses the URL from the config again.
After the URL is changed, all previously saved events and requests should be sent to the new URL. The request queue keeps no URL of its own: each queued request goes to the server URL that is in use when it is sent. A request that is already on its way when the URL changes finishes at the old URL; if it fails, its retry goes to the new URL.
Everything else that talks to the server also uses the new URL from then on: immediate requests (remote config, feedback widget lists, content, the SDK Behavior Settings fetch and the health check) and the web views the SDK opens after the change (feedback widgets and content).
The other settings stay as they are: the app key, the salt and custom request headers. The new server must know the app key.
Right after a valid change, the SDK catches the new server up, because a different server knows nothing that was sent before:
- it sends the push token again, if it would send the token at all (see Push Notifications);
- it sends the current consent state, while consent is required (see User Consent);
- it fetches the SDK Behavior Settings from the new server; until the answer arrives, the stored settings stay in use;
- if the app downloads remote config automatically, it downloads remote config from the new server.
If the new URL points to the same server, for example under a new domain, this only repeats values the server already has. In offline mode, the fetches are skipped as usual, and the queued requests wait until offline mode ends.
Changing the App Key
Some apps need to report to another Countly app while they run, for example a white-label app that learns at login which customer it belongs to. The SDK can offer a call for this, named something similar to "changeAppKey".
CountlyInstance.changeAppKey(appKey: String)Valid values
- The same as the app key in the config: non-empty, and not a placeholder such as "YOUR_APP_KEY" (see Initialization).
Logic
- An invalid app key is ignored with an error log, and the current key stays.
- A key that is the same as the current one is ignored.
- Before switching, the SDK closes what belongs to the old app: it stops the running views, moves pending events into a request, saves pending user profile changes and ends the running session. These requests carry the old app key.
- Then it switches: new requests, immediate requests and the web views the SDK opens after the change use the new key. The key is kept in memory only: the next init uses the key from the config again.
- After switching, it begins a session for the new app if one was running, and automatic view tracking starts a view for the current screen.
- The device ID, consent, the server URL, the salt, custom request headers and running timed events stay as they are.
Requests that are already in the queue keep the app key they were created with, so they still go to the old app (see Storage in SDK Storage and Requests). The app decides with the queue operations whether they go to the new app instead (replaceAllAppKeysInQueueWithCurrentAppKey) or are dropped (removeDifferentAppKeysFromQueue).
Right after a valid change, the SDK catches the new app up, as it does after a server URL change, because the new app knows nothing that was sent before:
- it sends the push token again, if it would send the token at all (see Push Notifications);
- it sends the current consent state, while consent is required (see User Consent);
- it fetches the new app's SDK Behavior Settings; until the answer arrives, the stored settings stay in use;
- it deletes the stored remote config values, which belong to the old app, or marks them as cached with value caching, and downloads the new app's values if the app downloads remote config automatically.
In offline mode, the fetches are skipped as usual, and the queued requests wait until offline mode ends.
Queue Operations
The SDK should provide queue management methods for advanced use cases. They work on what the SDK has stored but not sent yet: the event queue (events not yet packed into a request) and the request queue.
CountlyInstance.flushQueues()Logic: Clears both the request queue and event queue. All pending data is discarded. Started timed events are not affected.
- Data that is not queued yet is not affected either: running views, pending user profile changes and the health check counters.
- A request that is already on its way is not stopped.
CountlyInstance.attemptToSendStoredRequests()Logic: Packs the events in the event queue into a request and starts sending the request queue right away, without waiting for the next timer tick.
- During a backoff pause, sending waits as usual (see Backoff Mechanism). In offline mode, nothing is sent.
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.
- The checksum is computed when a request is sent (see Security and Privacy), so the changed requests stay valid.
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.
An app key changes, for example, when a new version of the app is configured with another app key, or when the app calls changeAppKey (see Changing the App Key): requests stored before the change still carry the old key. The app decides with these two methods whether that data goes to the new app or is dropped.
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.
- The SDK adds its common parameters and "dr=1", which marks the request as a direct request.
- Keys the SDK sets itself are reserved and removed with a warning: app_key, device_id, t, timestamp, hour, dow, tz, sdk_name, sdk_version, av, old_device_id, rr, dr, checksum and checksum256. Otherwise the request would carry such a key twice.
- An empty map, or one that is empty after the reserved keys are removed, is ignored with a warning.
- The values are sent as given: the SDK's internal limits don't apply to them.
- While the tracking switch of the SDK Behavior Settings is off, the call is ignored, like any other recording.
A direct request always belongs to the current device and app. Sending data for other devices or apps is what Backend Mode is for.
Offline Mode
Offline mode is described under Device ID Management, in Offline Mode. The SDK records under a placeholder device ID and sends nothing until it leaves offline mode. It enters offline mode with enableOfflineMode(), and leaves it with disableOfflineMode(), setID() or one of the device ID change methods.
Markdown Linting
To keep the Markdown files of every SDK (README, CHANGELOG and other documents) formatted the same way, SDK repositories check them with "markdownlint". Every SDK repository has the same ".markdownlint.json" at its root.
Installation
You can add the markdownlint command line tool to your project with npm:
npm install markdownlint-cli --save-dev
The package named "markdownlint" alone is the library behind the tool. It has no command, so the script under Usage doesn't run with it.
Another way is the markdownlint extension for Visual Studio Code, which reads the same config file.
Rules
Markdownlint has multiple rules that can be turned on, off or configured in a file called ".markdownlint.json". This file must be created at the root of the project with the following content. The comments only explain the rules: remove them in the real file, or name the file ".markdownlint.jsonc".
{
"MD001": true, /*Heading levels should increase one at a time*/
"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*/
"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 not have multiple spaces 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 numbers should count up from 0 or 1, or all be 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 marker 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*/
}
MD002 and MD006, which older versions of this config listed, no longer exist in markdownlint: MD041 (first line should be a top-level heading) and MD007 (list indentation) replaced them, and the tool ignores the old names.
To exclude certain files from being analyzed, you can create a ".markdownlintignore" at the project root. It uses the same patterns as ".gitignore", and lines starting with "#" are comments:
# exclude the 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
The "--fix" option corrects what it can by itself and lists the rest.
Instead, if you have only installed the VS Code extension, you can right click on your markdown file and press 'Format Document'. 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. It is meant for server-side tracking: a backend service records analytics on behalf of its users, where no real device context exists. SDKs that run on servers offer it; app and browser SDKs don't.
Backend mode is not a tool for importing historical data from another system. Data import is a different use case with its own requirements, such as sending large batches, and is not covered here.
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)Every BackendMode method takes the device ID it records for (deviceId, or newDeviceId and oldDeviceId for a merge), which is required and must be a non-empty string. appKey is optional and defaults to the app key given at init; timestamp is optional and defaults to the current time, in milliseconds since the epoch. A call without a required value is ignored with a warning.
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 update timer is started (used to periodically flush the event pool).
- All standard client-side feature calls become no-ops.
- The SDK fetches no SDK Behavior Settings and sends no health check: backend mode can send for many app keys, while those features belong to one app.
- The internal limits apply to the recorded data as in normal mode (see SDK Internal Limits).
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. Why it differs from normal mode (one queue, default 100): in backend mode the SDK keeps a separate event queue for every device and app key, and a request can only carry the events of one device. A backend records for many devices at once, so most devices have only a few events at a time; with a threshold of 100 they would wait in memory until the timer fires, and they are lost if the process ends (see Storage). A smaller threshold sends them sooner. The SDK Behavior Settings key "eqs" doesn't apply, because backend mode fetches no behavior settings.
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
- eventKey: required, must not be null or empty
- count: defaults to 1; values below 1 are ignored with a warning, and the default 1 is used (as in Events)
- 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; unlike the general rule (see Conventions), backend mode accepts no arrays
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
- metrics: optional device metrics; if null, internal SDK metrics are used
- location: optional location parameters (country_code, city, location, ip_address)
Logic: Creates a begin_session request with &begin_session=1 and the provided or default metrics. If location data is provided, it is added as additional 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: duration: required, a whole number of seconds, at least 1; smaller values are rejected with a log
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: duration: optional, the session duration in seconds, not negative
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: userProperties: required, must not be null or empty
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
- error: required, must not be null or empty (used as _name in crash data)
- stackTrace: required, must not be null or empty (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: required, and must contain _os and _app_version. The server accepts a crash only with _error, _app_version and _os (see Crash Reporting). 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
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
- 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
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
- name: required
- duration: required, must not be negative
- segment: platform or domain identifier
- segmentations: 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: parameters: required, must not be null or empty
Logic: Sends a raw request with the provided key-value pairs added as request 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. Keys the SDK sets itself are reserved and removed with a warning, as for addDirectRequest (see Queue Operations).
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, for example the server of a web shop that records purchases as they happen.
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_keythat 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.
- The internal limits apply as in normal mode.
- No SDK Behavior Settings and no health check.
- 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 \
--url 'https://YOUR_SERVER/i' \
--data 'app_key=APP_KEY_OF_THE_CALL' \
--data 'device_id=DEVICE_ID_OF_THE_CALL' \
--data 't=0' \
--data 'timestamp=TIME_OF_THE_CALL_MS' \
--data 'hour=HOUR' \
--data 'dow=DAY_OF_WEEK' \
--data 'tz=TIMEZONE_OFFSET' \
--data 'sdk_name=SDK_NAME' \
--data 'sdk_version=SDK_VERSION' \
--data 'av=APP_VERSION'The &t=0 parameter is always included in backend mode requests. It is the device ID type "developer supplied", because every backend mode call provides its device ID; the server then counts these users as identified. With a salt, each request carries the checksum like any other request (see Security and Privacy). Requests go through the in-memory request queue and are sent one at a time.
Feature-specific parameters are appended to this base. For example, an event request adds:
# on top of the base parameters above
curl --request POST \
--url 'https://YOUR_SERVER/i' \
--data 'events=[{"key":"purchase","count":1,"sum":2.99,"timestamp":1761638400000,"hour":10,"dow":2}]'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=SECONDSUser properties are sent as:
&user_details=URL_ENCODED_JSONCrash data is sent as:
&crash=URL_ENCODED_JSONDevice ID merge requests include:
&old_device_id=URL_ENCODED_OLD_IDDirect requests include:
&dr=1&custom_param1=value1&custom_param2=value2Event 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 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. The app therefore gives the SDK time to send the queue before the process ends; whatever is still in memory then is lost.
Platform Specifics
Most platform rules live with the feature they belong to. This section holds the platform features that have no feature section of their own, and points to the platform rules that are described elsewhere.
Platform rules elsewhere in this guide:
- App SDKs - app lifecycle and automatic sessions (Sessions), automatic views (View Tracking), native crashes (Crash Reporting), web view UI (Init Configuration), console logs in production builds (Logging), app crawlers (Bot/Crawler Detection), notification display, intent targets and notification service extensions (Push Notifications).
- Server SDKs - manual sessions only (Sessions), sending the queue before the process ends (Backoff Mechanism), and Backend Mode.
- Browser SDKs - calls before init (General SDK Structure Overview), request storage across tabs (SDK Storage and Requests), bots and prerendered pages (Bot/Crawler Detection), sessions across page loads (Sessions), the device ID from the page URL (Device ID Management), Web Push through a service worker (Push Notifications), messages from content and widgets through postMessage (Content Zone, User Feedback), and Heatmaps.
Browser SDKs
Link Tracking
Link tracking shows which links visitors click, for example to see how many leave the site through a partner link.
CountlyInstance.trackLinks(parent?: Element)Valid values: parent: the element whose links are tracked. Optional; the whole document when not given.
Logic: Records a "linkClick" event when the visitor clicks a link.
- Listens to clicks on the parent element, or on the whole document, and finds the closest link (an "a" element) around the clicked element. Clicks outside links are ignored
- Segmentation: "href" (the link's address), "text" (its visible text), "id" (its element ID) and "view" (the page, the same value a view of that page gets as its name, see View Tracking). Internal limits apply to them
- Needs the "clicks" consent at the moment of the click, not the "events" consent
- The key has no "[CLY]_" prefix, so the server shows the event with the app's custom events, and the behavior settings filters for custom events apply to it
- The event queue is stored on every change, so a click that leaves the site before the next heartbeat is sent with the next page load of the site
Form Tracking
A browser SDK can record form submissions, and it can fill the user profile from the forms visitors submit.
CountlyInstance.trackForms(parent?: Element, trackHidden?: boolean)
CountlyInstance.collectFromForms(parent?: Element, useCustom?: boolean)trackForms - records a "formSubmit" event when a form inside the parent element (or the whole document) is submitted:
- Segmentation: the form's "id", "name", "action" and "method", and one "input:<name>" key per field with the field's value (several values are joined with ", "). Internal limits apply to them
- Hidden fields are recorded only with trackHidden. Password fields, and fields marked to be ignored (the class cly_user_ignore, on the field or on an element around it), are never read
- Needs the "forms" consent at the moment of the submission, not the "events" consent
- The key has no "[CLY]_" prefix, so the server shows the event with the custom events, and the behavior settings filters for custom events apply to it
collectFromForms - fills user profile fields from a submitted form:
- It recognizes the email, username, phone and organization fields by their type, name, ID or label, and takes a field with the class cly_user_<key> as that profile key
- Password fields and fields marked to be ignored are never read
- The values are saved as user profile changes (see User Profiles); with useCustom, as custom properties
- Needs the "users" consent
Ignoring Visitors
Some visits should create no data at all. A browser SDK decides this at init, before it records anything, and then creates no requests for the whole page load:
-
Bots and crawlers - detected from the user agent (see Bot/Crawler Detection). On by default (
ignore_bots). -
Prerendered pages - pages the browser prepares in advance, which the visitor may never see. On by default (
ignore_prefetch). -
Visitors the site excludes - the developer can mark the current visitor as ignored in the init config (
ignore_visitor), for example for the site's own staff or for test traffic.
Letting a visitor opt out of tracking is done with consent (see User Consent), not with a separate opt-out switch: consent gives the visitor a choice per feature, and the server learns the state.
Storage Modes
A browser offers several places to keep data, and some sites can't or won't use all of them. A browser SDK lets the developer choose in the init config where it keeps its data: the request and event queues, the device ID, the stored behavior settings and the other feature data.
- default - localStorage, and cookies where localStorage is not available.
- localstorage - localStorage only. Where it is not available, nothing is stored.
- cookie - cookies only. They are kept for 30 days.
- none - nothing is stored, and the SDK works in memory only. A device ID that the developer doesn't provide is generated again on every page load, and requests that were not sent before the page is left are lost.
- A storage object - the developer passes an object with getItem, setItem and removeItem, for environments without browser storage or with their own storage rules.
Rules for all modes:
- Every key starts with the app key, and with the developer's namespace when one is set, so that several Countly setups on one site don't read each other's data.
- When the chosen storage fails, the SDK logs it, counts it in the health check (log code w502 when it falls back to cookies, e501 when a write fails), and keeps working in memory.
- The mode is read once, at init.
SDK Specifics
Some rules apply to one SDK, or to one kind of SDK, because of its language or the way it is delivered. They are collected here.
C++ SDK
The C++ SDK runs on many systems, from desktops to embedded devices, and can't assume that a network library or a cryptography library is available. It can therefore be built without them, and the app supplies the missing piece through a hook.
App-Supplied HTTP Client
Countly::setHTTPClient(client: HTTPClientFunction)HTTPClientFunction: (usePost: bool, path: String, data: String) -> HTTPResponse
HTTPResponse: { success: bool, data: JSON }Logic: Lets the app send the SDK's requests with its own HTTP client.
- In a build without the SDK's own HTTP client (COUNTLY_USE_CUSTOM_HTTP), the app must set it. Without it, no request can be sent: every attempt fails with an error log, and the requests stay in the queue
- In other builds it is optional. When set, it replaces the built-in client (libcurl, or WinHTTP on Windows)
- The SDK passes the endpoint path ("/i", "/o/sdk"), the encoded request data, and whether to send it as POST, which is always true (see Using GET or POST). The client sends the data to the server URL plus the path, as the body of a POST with "application/x-www-form-urlencoded", and applies the timeouts, proxy and certificate rules the app needs
- The client returns success true only for a 2xx status, with the parsed JSON body in data. The SDK applies its usual checks on top, for example the "result" field of a queued request
- The SDK calls it from its own thread. It must not call back into the SDK
App-Supplied SHA-256
Countly::setSha256(hash: SHA256Function)SHA256Function: (input: String) -> StringLogic: Lets the app compute the SHA-256 hash that parameter tampering protection needs (see Security and Privacy).
- In a build without the SDK's own cryptography (COUNTLY_USE_CUSTOM_SHA256, for example a build without OpenSSL), the app must set it when a salt is used. Without it, the SDK logs an error and can't compute the checksum, so the server rejects the requests
- The function returns the hash of the whole input as hexadecimal text, 64 characters long
- In other builds, the SDK uses its own implementation and doesn't call it
Bridge SDKs
A bridge SDK (Flutter, React Native and similar) wraps the native SDKs of the platforms it runs on. Its rules are spread through this guide; in short:
- It sends its own name and version instead of the native SDK's (see SDK Metadata).
- It passes every config option and call it offers on to the native SDK, which does the work: queues, storage, sessions, behavior settings, health checks and the connection test. The bridge doesn't keep a second copy of them.
- It offers the calls that act on the whole SDK and passes them on too: disableHealthCheck, changeServerUrl, changeAppKey, the queue operations, the experimental options and halt.
- Errors of its own language, for example Dart or JavaScript errors, are recorded as crashes through the native SDK, and those it catches as unhandled get "_nonfatal" false (see Crash Reporting).
- Callbacks from the native SDK reach the app in the bridge's own environment, on the thread its platform expects (see Thread Safety).
Experimental
This section describes features that are under development and in a prototype phase. They are turned on through the experimental part of the config, and their names and the data they record can still change.
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.
- Every event the SDK records gets "cly_v" in its segmentation: 1 when the app is in the foreground, 0 when it is in the background. Anything but the background counts as foreground, for example an app under a system dialog.
- A view gets it in the event sent when the view starts. The event sent when a view ends doesn't get it.
- Browser SDKs use the visibility of the page: 1 while the page is visible.
- The key is added after the segmentation filters and the internal limits, so neither removes it.
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.
- View events get "cly_pvn": the name of the view shown before this one.
- Custom events get "cly_cvn": the name of the view shown when the event is recorded, and "cly_pen": the key of the previous custom event.
- A value that doesn't exist yet, such as the previous view of the first view, is sent as an empty string.
- The SDK's own events other than views (keys starting with "[CLY]_") don't get these keys, and they don't count as the previous event.
- The keys are added after the segmentation filters and the internal limits.
The server stores these keys as ordinary segmentation.
Legacy Features
This section keeps earlier APIs for the SDKs that still offer them. New SDKs implement the current APIs instead: User Feedback for ratings, and Remote Config.
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 (setStarRatingSessionLimit, 5 sessions by default). 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)Validation
- widgetId must be a non-empty string (reject otherwise).
- rating must be a whole number from 1 to 5; otherwise the call is ignored with a warning. The SDK never moves a rating into the range.
Logic: Records an event with key "[CLY]_star_rating" and the following segmentation:
- widget_id: provided widget ID
- rating: the given rating
- 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)Logic
- Fetches the widget configuration from /o/feedback/widget, with widget_id and the base request parameters in the body.
- 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.
- If targeting passes, displays the widget in a WebView with the provided closeButtonText for the native close button.
- Calls the callback on completion or error.
For instance method showStarRating
CountlyInstance.showStarRating(callback: StarRatingCallback)Logic: Displays a native 1-to-5 star rating dialog. The dialog title, message, and dismiss button text are configurable via CountlyConfig (each platform has its own default texts, for example the message "How would you rate the app?"). 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.
Consent
All legacy star rating and rating widget operations require "star-rating" consent:
recordRatingWidgetWithIDpresentRatingWidgetWithIDshowStarRating- 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' \
--data 'widget_id=WIDGET_ID'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' in the request 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.
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.
Their usage can be seen below. Like every request, they are sent as POST to /o/sdk, with these parameters in the body (see Using GET or POST):
// legacy API
method=fetch_remote_config, metrics, app_key, device_id, ... (optional: keys, omit_keys)
// latest API for remote config
method=rc, metrics, app_key, device_id, ... (optional: keys, omit_keys, oi)
// enrolling users
method=ab, keys, app_key, device_id, ...
There are 2 init time config flags 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 (oi=1) or not (oi=0) 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 is performed at SDK start and after a device ID change (see Device ID Change below), 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 parameter. 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 log a warning. 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 consentRequired flag is set upon initial config, remote config actions should only be executed if the "remote-config" consent is given. Additionally, if metrics consent is given, the remote config requests will have metrics info, as described in Remote Config and Device Metrics.
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. The legacy calls share the stored values with the current API, so value caching and the wait after a change with merge apply as described in Remote Config.
Salt
Remote config requests need to include the checksum if enabled upon initial config. The checksum is calculated as for all other requests (see Security and Privacy).