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 | KapeLogSources | File on the report |
|---|---|---|
| Low Level SDK logs, from the app and the PacketTunnel extension | .sdk | kp-<n>.jsonl, one per log file |
| Platform SDK log of the app process | .platformApp | platform-app.jsonl |
| Platform SDK log of the PacketTunnel extension | .platformTunnel | platform-tunnel.jsonl |
| Your own files | — | the name you give each KapeLogAttachment |
.all, the default, is all three sources. A source with nothing logged is skipped.
The platform logs also carry 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 logs as possibly sensitive too. The .sdk source is not redacted. Unified logging
(os.Logger) output is unchanged and not redacted.
Setup
1. Turn on file logging
In KapeSDK.plist, in both the app and the extension:
<key>FileLoggingEnabled</key>
<true/>
<key>MaxLogLevel</key>
<string>Debug</string>
MaxLogLevel must be Info, Debug or Trace.
At Warn or below the Low Level SDK writes no log files, and deletes the ones already there.
That includes the other process's files, since the app and the extension share the folder. Use the
same MaxLogLevel in both.
The Low Level SDK writes encrypted log files under <App Group container>/KapeSDK/logs. On tvOS that
is in the container's Library/Caches. The app can only read the extension's files if both
processes share the keychain access group, because the encryption key is stored there. That is the
default when both read the same AppGroupIdentifier.
Optionally, set limits with a LogsCollector dictionary:
<key>LogsCollector</key>
<dict>
<key>PrelogMaxMb</key>
<integer>2</integer>
<key>PrelogMaxMinutes</key>
<integer>60</integer>
<key>LogFileMaxMb</key>
<integer>50</integer>
<key>LogRetentionHours</key>
<integer>168</integer>
</dict>
| Key | Default | Meaning |
|---|---|---|
PrelogMaxMb | 4 | Size of the in-memory buffer kept while the level is below Info |
PrelogMaxMinutes | 60 | Age of the oldest record that buffer keeps |
LogFileMaxMb | 100 | Size at which a log file rotates |
LogRetentionHours | 168 | Age after which log files are deleted |
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 → View Logs → Send Logs menu, in apple/Examples/VPNDemo/VPNDemo/Views/LogsExportActions.swift: it uploads and shows the report code, or exports a zip to the share sheet.
uploadLogs does everything in one call. It flushes the logs to disk, opens a report, uploads the
chosen sources, then attaches your files:
import KapeCore
let collector = try await handle.logsCollector()
do {
let result = try await collector.uploadLogs(
sources: .all,
attributes: ["support_channel": "contact_form"],
attachments: [
KapeLogAttachment(filename: "report.txt", data: Data(diagnostics.utf8)),
KapeLogAttachment(filename: "app.log", data: appLogData),
]
)
showToUser(code: result.code)
for failure in result.failed {
print("Not uploaded: \(failure.filename) — \(failure.error.localizedDescription)")
}
} catch let error as KapeError {
// The report could not be opened, or not a single file was attached.
print(error.domain, error.httpStatus ?? 0, error.localizedDescription)
}
When one file fails, the others are still uploaded, and the failure is listed in result.failed.
The call throws only when the report cannot be opened, or when no file at all was attached. Uploads
of the Low Level SDK logs succeed or fail as a batch, listed as KapeLogUploadFailure.sdkLogs.
KapeError.httpStatus and KapeError.httpBody give the service's response when it rejected a
request.
Progress and cancelling
let result = try await collector.uploadLogs { completed, total in
Task { @MainActor in progressView.progress = Float(completed) / Float(max(total, 1)) }
return !userCancelled // return false to stop
}
if result.isCancelled { /* files already uploaded stay on the report */ }
The total grows by the number of Low Level SDK files once their upload starts.
Step by step
To keep the report open, for example to add a ticket ID once your helpdesk returns one, use
createReport:
let report = try await collector.createReport()
let result = await report.uploadLogs(sources: [.sdk, .platformApp])
try await report.attach(KapeLogAttachment(filename: "report.txt", data: reportData))
let ticket = try await helpdesk.submit(message, logsCode: report.code)
try await report.setAttributes(["zendesk_ticket": ticket.id])
report.uploadLogs never throws: every failure is in result.failed.
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.
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:
let url = FileManager.default.temporaryDirectory.appendingPathComponent("logs.zip")
let count = try await collector.exportLogs(
to: url,
attachments: [KapeLogAttachment(filename: "report.txt", data: reportData)]
)
// Share `url`, e.g. with a ShareLink.
It replaces any file already at url, 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.
Attachment file names must not contain a path, and must not repeat each other or a platform log
file name. uploadLogs, exportLogs and attach check this before any request is made, and throw
a KapeError with domain "Logging".
Flushing
uploadLogs and exportLogs flush first. KapePacketTunnelProvider also flushes when the tunnel
stops and when the device sleeps, so the extension's last records are on disk for the app's next
upload. If you write your own NEPacketTunnelProvider, call flush() in the same places:
try await handle.logsCollector().flush()