Logs Collector
KapeLogsCollector sends this device's logs to the Kape Logs Collector Service so support can read
them. It can also write them to a zip file for the user to share themselves.
An upload opens a report and attaches log files to it. The report has a short code: show it to the user, or pass it along with a support ticket, so support can find the logs.
Uploads are anonymous: the user does not need to be signed in. The SDK adds the OS, OS version, app version, SDK version and device model to every report.
What gets uploaded
| Source | KapeLogSource | File on the report |
|---|---|---|
| Low Level SDK logs | SDK | kp-<n>.jsonl, one per log file |
| Platform SDK log, including the VPN service's | PLATFORM | platform-app.jsonl |
| Your own files | — | the name you give each KapeLogAttachment |
KapeLogSources.ALL, the default, is both sources. A source with nothing logged is skipped.
KapeVpnService runs in your app's process, so its log lines are in platform-app.jsonl along
with the rest of the Platform SDK's. Unlike iOS, where the tunnel is a separate process, there is no
platform-tunnel.jsonl.
The platform log also carries the Low Level SDK's log lines, tagged with the process llsdk. In
every line, the Platform SDK's own and the Low Level SDK's, IPv4 and IPv6 addresses and hostnames on
common top-level domains are replaced with <redacted>. The redaction matches patterns, so treat
the platform log as possibly sensitive too. The SDK source is not redacted.
Setup
1. Turn on file logging
In res/xml/kape_sdk_settings.xml:
<kape-sdk-settings
...
max-log-level="Debug"
file-logging-enabled="true" />
max-log-level must be Info, Debug or Trace.
At Warn or below the Low Level SDK writes no log files, and deletes the ones already there.
The SDK logs a warning at start-up when file-logging-enabled is set with such a level.
With file logging on, the SDK writes:
- the Low Level SDK's encrypted log files, under
noBackupFilesDir/KapeSDK/logs. The encryption key is kept in the SDK's own storage, so only your app can read them; - the Platform SDK's own log, as JSON Lines, to
noBackupFilesDir/kape_sdk_log-app.jsonl. It rotates at 256 KB, keeping one older file. Logcat output does not change, and Low Level SDK lines in Logcat are not redacted.
Nothing under noBackupFilesDir is included in Android backups.
Optionally, set limits on the Low Level SDK's log files:
<kape-sdk-settings
...
file-logging-enabled="true"
prelog-max-mb="2"
prelog-max-minutes="60"
log-file-max-mb="50"
log-retention-hours="168" />
| Attribute | Default | Meaning |
|---|---|---|
prelog-max-mb | 4 | Size of the in-memory buffer kept while the level is below Info |
prelog-max-minutes | 60 | Age of the oldest record that buffer keeps |
log-file-max-mb | 100 | Size at which a log file rotates |
log-retention-hours | 168 | Age after which log files are deleted |
Each value must be a non-negative whole number; anything else fails KapeSDKSettings.fromXml.
2. Point at your Logs Collector tenant
Set logs_collector_api_domain under domains: in your YAML config. Give the staging value in your
staging YAML: the Low Level SDK does not read environment, so a domain you leave out defaults to
production.
domains:
# Production
logs_collector_api_domain: "https://cp.expressapisv2.net/apigw/lcs/lcs/tenants/xv/"
# Staging
# logs_collector_api_domain: "https://xv-cp.apis-staging.xvtest.net/apigw/lcs/lcs/tenants/xv/"
These are the ExpressVPN (xv) tenant URLs. For another brand, replace /tenants/xv/ with your
tenant.
Uploading
For a working example, see VpnDemo's Settings → SDK Logs screen, whose menu uploads (showing the report code) or exports a zip to the share sheet: android/Examples/VpnDemo/src/main/java/com/kape/vpndemo/logs/LogViewerViewModel.kt.
uploadLogs does everything in one call. It flushes the logs to disk, opens a report, uploads the
chosen sources, then attaches your files:
import com.kape.platformsdk.core.KapeError
import com.kape.platformsdk.core.logging.KapeLogAttachment
import com.kape.platformsdk.core.logging.KapeLogSources
import com.kape.platformsdk.core.logging.logsCollector
try {
val result = handle.logsCollector().uploadLogs(
sources = KapeLogSources.ALL,
attributes = mapOf("support_channel" to "contact_form"),
attachments = listOf(
KapeLogAttachment("report.txt", diagnostics.toByteArray()),
KapeLogAttachment("app.log", appLogBytes),
),
)
showToUser(result.code)
result.failed.forEach { Log.w(TAG, "Not uploaded: ${it.filename} — ${it.error.message}") }
} catch (e: KapeError) {
// The report could not be opened, or not a single file was attached.
Log.e(TAG, "${e.domain} ${e.httpStatus}: ${e.message}")
}
When one file fails, the others are still uploaded, and the failure is listed in result.failed.
The call throws only when an attribute or file name is invalid, when the report cannot be opened,
or when no file at all was attached. The Low Level SDK logs succeed or fail as a batch, listed as
KapeLogUploadFailure.SDK_LOGS.
KapeError.httpStatus and KapeError.httpBody give the service's response when it rejected a
request.
Progress and cancelling
val result = collector.uploadLogs { completed, total ->
progress.update { completed.toFloat() / maxOf(total, 1) }
!userCancelled // return false to stop
}
if (result.isCancelled) { /* files already uploaded stay on the report */ }
The callback can run on a background thread. The total grows by the number of Low Level SDK files once their upload starts. Cancelling the calling coroutine also stops the upload.
Step by step
To keep the report open, for example to add a ticket ID once your helpdesk returns one, use
createReport:
val report = collector.createReport()
val result = report.uploadLogs(sources = setOf(KapeLogSource.SDK, KapeLogSource.PLATFORM))
report.attach(KapeLogAttachment("report.txt", reportBytes))
val ticket = helpdesk.submit(message, logsCode = report.code)
report.setAttributes(mapOf("zendesk_ticket" to ticket.id))
report.uploadLogs does not throw a KapeError: every failure is in result.failed. Unlike
collector.uploadLogs, it does not flush first — call collector.flush() beforehand if the last
records matter.
Attributes
Attributes are key/value pairs that support staff see on the report. They are checked before any
request is made, and a broken rule throws a KapeError with domain "Logging":
- at most 20 per report;
- keys are 1–64 characters of
a-z,0-9,_,.,-, starting with a letter; - values are 1–128 bytes, with no control characters;
os_name,os_version,app_version,sdk_version,device_model, and keys startingkp_orkp., are reserved.
Each key can be set once. Setting it again to the same value does nothing; setting it to another value fails with HTTP 409.
Support staff see attribute values. Never put personal data, such as an email address, in them.
Exporting to a file
exportLogs writes the same logs to a zip file, without any network request:
val zip = File(context.cacheDir, "logs/logs.zip")
val count = collector.exportLogs(
zipFile = zip,
attachments = listOf(KapeLogAttachment("report.txt", reportBytes)),
)
// Share `zip`, e.g. through a FileProvider and an ACTION_SEND intent.
It replaces any file already at zipFile, and returns the number of files in the archive. The Low
Level SDK logs are decrypted, and not redacted, in the archive, so treat it as sensitive. The
archive is built beside zipFile under a temporary name and renamed into place once complete, so a
failed export leaves any existing file untouched and no partial file behind. With no logs to export,
the archive is written empty.
Attachment file names must not contain a path, and must not repeat each other or a platform log file name.
Flushing
uploadLogs and exportLogs flush first. KapeVpnService also flushes when it is destroyed or its
VPN permission is revoked. To flush yourself, for example before your process may be killed, call
the blocking flush() off the main thread:
withContext(Dispatchers.IO) { handle.logsCollector().flush() }