Mobile analytics SDKs are a recurring tax: binary size, a dependency to keep current, opaque background behaviour, and an App Store review conversation about what the third-party code does. Infralytiqs asks for none of that, because the ingest endpoint is plain JSON over HTTPS.
The trade is real and worth stating: you own the queueing, the retry policy and the identifiers. In exchange you get complete visibility into what leaves the device and when, which is a conversation with your privacy reviewer that goes much better.
The clients below are complete rather than illustrative. Each is under 150 lines, which is roughly the whole point.
Wiring a mobile app to the portal
There is no SDK to add to your build, so most of this is configuration rather than code.
1
Create the site in the console
Sign in, add your ClickHouse module under Infralytiqs → Settings, and give the mobile app its own siteId within your tenant — acme-ios and acme-android rather than pooling both into the web site id.
Separate ids cost nothing and make the reports far easier to read: platform becomes a property of the site rather than a dimension you have to remember to filter on.
Settings
2
Build the endpoint URL
The collector URL is POST {serverUrl}/il/analytics/{tenantId}/{siteId}/events. There is no Authorization header and no API key: the registered tenant and site pair in the path is what authorises the write, and an unregistered pair is rejected with 403.
Because there is no secret in the request, treat the endpoint as public. Anything you would not want a third party to be able to write, validate on your own server and forward from there.
Contract
3
Generate and persist the identifiers
This is the part the browser SDK would otherwise do for you. Mint an anonymous_id once and keep it in the app’s own storage; mint a session_id and roll it after 30 minutes of inactivity. Set device_type and device_os yourself.
Use the platform’s private storage rather than the advertising identifier. It survives fewer resets, but it is the app’s own state and does not drag a consent question into an analytics decision.
App
4
Queue, batch and flush
Buffer events on disk and post them in batches — up to 1,000 per request. Flush on a timer, when the buffer fills and when the app backgrounds, and keep the buffer across launches so a commute through a tunnel does not cost you the session.
Retry with backoff on 5xx. Do not retry a 403: the configuration is wrong and retrying will not fix it.
App
5
Verify against your database
A 201 with { success: true, count: N } means the rows are committed. Confirm in ClickHouse with a SELECT ordered by event_timestamp — that is the only check that proves the whole path rather than just the HTTP hop.
Verify
6
Report on it beside the web
Mobile events land in the same website_events table as everything else, so a single panel can span web, mobile and server-side by grouping on device_type or by including several site ids in the suite.
Reports
What you are integrating against
The entire contract.
①
0
Endpoints to call
POST /il/analytics/{tenant_id}/{site_id}/events
🔑
0
Auth headers needed
The tenant and site pair in the URL is the credential
📦
0
Events per request
Batch aggressively; mobile networks are expensive
💾
0
Binary size added
It is your own HTTP client
What to get right
The parts that separate a mobile client that works from one that quietly loses data.
📴
Queue to disk, not memory
Mobile apps are killed without warning. An in-memory queue loses everything on a background kill, so persist events on capture and delete them only after a 201. Nothing else gives you accurate counts.
⏰
Send event_time yourself
The server defaults to arrival time, which is wrong for anything captured offline and flushed hours later. Capture the timestamp on the device and send it explicitly.
🆔
A stable anonymous_id
Generate a UUID on first launch and keep it in Keychain or encrypted preferences. Do not use the advertising identifier — it is resettable, permission-gated and semantically wrong for this.
🔄
Back off on failure
Retry with exponential backoff and jitter. A thundering herd of retries after an outage is a self-inflicted second outage, and it drains batteries.
🔋
Flush on backgrounding
The lifecycle transition to background is your last reliable moment to send. Flush there, and let the OS-provided background task window finish the request.
📱
Set device fields explicitly
There is no user agent to parse, so populate device_type and device_os yourself. Put the app version and build in custom_dimensions — you will want them the first time a release regresses.
Complete clients
Copy, change the tenant and site, and you are collecting. Each handles persistence, batching, backoff and lifecycle flushing.
import Foundation
import UIKit
/// Minimal Infralytiqs client. Persists to disk on capture, batches on a
/// timer, and flushes when the app backgrounds.
final class Infralytiqs {
static let shared = Infralytiqs(
serverURL: URL(string: "https://api.infralytiqs.com")!,
tenantId: "acme",
siteId: "acme-ios"
)
private let serverURL: URL
private let tenantId: String
private let siteId: String
private let queue = DispatchQueue(label: "com.acme.infralytiqs")
private let storeURL: URL
private var pending: [[String: Any]] = []
private var userId: String?
private var sessionId = UUID().uuidString
private var lastActivity = Date()
private var timer: Timer?
private var backoff: TimeInterval = 1
/// Survives reinstall-free launches; regenerated only if the device is wiped.
private lazy var anonymousId: String = {
if let existing = UserDefaults.standard.string(forKey: "il_anonymous_id") {
return existing
}
let fresh = UUID().uuidString
UserDefaults.standard.set(fresh, forKey: "il_anonymous_id")
return fresh
}()
private init(serverURL: URL, tenantId: String, siteId: String) {
self.serverURL = serverURL
self.tenantId = tenantId
self.siteId = siteId
self.storeURL = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)[0]
.appendingPathComponent("infralytiqs-queue.json")
loadFromDisk()
startTimer()
observeLifecycle()
}
// MARK: - Public API
func identify(_ userId: String) {
queue.async { self.userId = userId }
}
func track(_ eventType: String,
dimensions: [String: String] = [:],
metrics: [String: Double] = [:],
subtype: String? = nil) {
// Capture the timestamp now, not at send time — these may not be the
// same day if the device was offline.
let capturedAt = Self.iso8601.string(from: Date())
queue.async {
self.rollSessionIfIdle()
var event: [String: Any] = [
"event_type": eventType,
"event_time": capturedAt,
"anonymous_id": self.anonymousId,
"session_id": self.sessionId,
"device_type": UIDevice.current.userInterfaceIdiom == .pad ? "tablet" : "mobile",
"device_os": "iOS \(UIDevice.current.systemVersion)",
"language_iso_code": Locale.preferredLanguages.first ?? "en",
"custom_dimensions": dimensions.merging(self.globalDimensions) { a, _ in a },
"custom_metrics": metrics
]
if let userId = self.userId { event["user_id"] = userId }
if let subtype = subtype { event["event_subtype"] = subtype }
self.pending.append(event)
self.persistToDisk()
if self.pending.count >= 50 { self.flush() }
}
}
func flush() {
queue.async { self.send() }
}
// MARK: - Internals
private var globalDimensions: [String: String] {
let info = Bundle.main.infoDictionary
return [
"app_version": info?["CFBundleShortVersionString"] as? String ?? "unknown",
"app_build": info?["CFBundleVersion"] as? String ?? "unknown",
"platform": "ios"
]
}
/// 30 minutes of inactivity starts a new session, matching the web SDK.
private func rollSessionIfIdle() {
if Date().timeIntervalSince(lastActivity) > 1800 {
sessionId = UUID().uuidString
}
lastActivity = Date()
}
private func send() {
guard !pending.isEmpty else { return }
// The server rejects anything over 1000 per request.
let batch = Array(pending.prefix(1000))
var request = URLRequest(
url: serverURL.appendingPathComponent("/il/analytics/\(tenantId)/\(siteId)/events")
)
request.httpMethod = "POST"
request.setValue("application/json", forHTTPHeaderField: "Content-Type")
request.httpBody = try? JSONSerialization.data(withJSONObject: batch)
URLSession.shared.dataTask(with: request) { [weak self] _, response, _ in
guard let self = self else { return }
self.queue.async {
let status = (response as? HTTPURLResponse)?.statusCode ?? 0
// 2xx: delivered. 4xx other than 429: the payload will never be
// accepted, so drop it rather than retrying forever.
if (200...299).contains(status) || (400...499).contains(status) && status != 429 {
self.pending.removeFirst(min(batch.count, self.pending.count))
self.persistToDisk()
self.backoff = 1
} else {
self.backoff = min(self.backoff * 2, 300)
}
}
}.resume()
}
private func startTimer() {
timer = Timer.scheduledTimer(withTimeInterval: 30, repeats: true) { [weak self] _ in
self?.flush()
}
}
private func observeLifecycle() {
NotificationCenter.default.addObserver(
forName: UIApplication.didEnterBackgroundNotification,
object: nil, queue: nil
) { [weak self] _ in
self?.track("app_background")
self?.flush()
}
NotificationCenter.default.addObserver(
forName: UIApplication.didBecomeActiveNotification,
object: nil, queue: nil
) { [weak self] _ in
self?.track("app_foreground")
}
}
private func persistToDisk() {
try? JSONSerialization.data(withJSONObject: pending).write(to: storeURL)
}
private func loadFromDisk() {
guard let data = try? Data(contentsOf: storeURL),
let events = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]]
else { return }
pending = events
}
private static let iso8601: ISO8601DateFormatter = {
let f = ISO8601DateFormatter()
f.formatOptions = [.withInternetDateTime, .withFractionalSeconds]
return f
}()
}
// Usage:
// Infralytiqs.shared.track("screen_view", dimensions: ["screen": "product_detail"])
// Infralytiqs.shared.identify("usr_10482")
package com.acme.analytics
import android.content.Context
import android.os.Build
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.ProcessLifecycleOwner
import kotlinx.coroutines.*
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.text.SimpleDateFormat
import java.util.*
/**
* Minimal Infralytiqs client. Persists to disk on capture so a background
* kill does not lose events, and flushes when the process backgrounds.
*/
class Infralytiqs private constructor(
context: Context,
private val serverUrl: String,
private val tenantId: String,
private val siteId: String
) : DefaultLifecycleObserver {
companion object {
@Volatile private var instance: Infralytiqs? = null
fun init(context: Context, serverUrl: String, tenantId: String, siteId: String) {
instance ?: synchronized(this) {
instance ?: Infralytiqs(context.applicationContext, serverUrl, tenantId, siteId)
.also {
instance = it
ProcessLifecycleOwner.get().lifecycle.addObserver(it)
}
}
}
fun track(
eventType: String,
dimensions: Map<String, String> = emptyMap(),
metrics: Map<String, Double> = emptyMap(),
subtype: String? = null
) = instance?.trackInternal(eventType, dimensions, metrics, subtype)
fun identify(userId: String) { instance?.userId = userId }
fun flush() { instance?.scope?.launch { instance?.send() } }
}
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
private val http = OkHttpClient()
private val mutex = Mutex()
private val store = File(context.filesDir, "infralytiqs-queue.json")
private val prefs = context.getSharedPreferences("infralytiqs", Context.MODE_PRIVATE)
private val pending = mutableListOf<JSONObject>()
private var userId: String? = null
private var sessionId = UUID.randomUUID().toString()
private var lastActivity = System.currentTimeMillis()
private var backoffMs = 1_000L
private val anonymousId: String = prefs.getString("anonymous_id", null)
?: UUID.randomUUID().toString().also { prefs.edit().putString("anonymous_id", it).apply() }
private val appVersion: String = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "unknown"
} catch (_: Exception) { "unknown" }
init {
loadFromDisk()
scope.launch {
while (isActive) { delay(30_000); send() }
}
}
private fun trackInternal(
eventType: String,
dimensions: Map<String, String>,
metrics: Map<String, Double>,
subtype: String?
) {
// Timestamp at capture. An event queued offline overnight must not
// claim to have happened at flush time.
val capturedAt = iso8601.format(Date())
scope.launch {
mutex.withLock {
if (System.currentTimeMillis() - lastActivity > 1_800_000) {
sessionId = UUID.randomUUID().toString()
}
lastActivity = System.currentTimeMillis()
val event = JSONObject().apply {
put("event_type", eventType)
put("event_time", capturedAt)
put("anonymous_id", anonymousId)
put("session_id", sessionId)
put("device_type", "mobile")
put("device_os", "Android ${Build.VERSION.RELEASE}")
put("language_iso_code", Locale.getDefault().toLanguageTag())
put("custom_dimensions", JSONObject(
dimensions + mapOf(
"app_version" to appVersion,
"platform" to "android",
"device_model" to "${Build.MANUFACTURER} ${Build.MODEL}"
)
))
put("custom_metrics", JSONObject(metrics))
userId?.let { put("user_id", it) }
subtype?.let { put("event_subtype", it) }
}
pending += event
persistToDisk()
}
if (pending.size >= 50) send()
}
}
private suspend fun send() = mutex.withLock {
if (pending.isEmpty()) return@withLock
val batch = pending.take(1000) // server hard limit
val body = JSONArray(batch).toString()
.toRequestBody("application/json".toMediaType())
val request = Request.Builder()
.url("$serverUrl/il/analytics/$tenantId/$siteId/events")
.post(body)
.build()
try {
http.newCall(request).execute().use { response ->
val code = response.code
// Drop on success, and on client errors that will never succeed.
if (response.isSuccessful || (code in 400..499 && code != 429)) {
repeat(minOf(batch.size, pending.size)) { pending.removeAt(0) }
persistToDisk()
backoffMs = 1_000
} else {
backoffMs = (backoffMs * 2).coerceAtMost(300_000)
}
}
} catch (_: Exception) {
backoffMs = (backoffMs * 2).coerceAtMost(300_000)
}
}
override fun onStop(owner: LifecycleOwner) {
// Last reliable moment before the process may be killed.
trackInternal("app_background", emptyMap(), emptyMap(), null)
scope.launch { send() }
}
override fun onStart(owner: LifecycleOwner) {
trackInternal("app_foreground", emptyMap(), emptyMap(), null)
}
private fun persistToDisk() =
runCatching { store.writeText(JSONArray(pending).toString()) }
private fun loadFromDisk() = runCatching {
if (!store.exists()) return@runCatching
val array = JSONArray(store.readText())
for (i in 0 until array.length()) pending += array.getJSONObject(i)
}
private val iso8601 = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US)
.apply { timeZone = TimeZone.getTimeZone("UTC") }
}
// Application.onCreate:
// Infralytiqs.init(this, "https://api.infralytiqs.com", "acme", "acme-android")
// Anywhere:
// Infralytiqs.track("screen_view", mapOf("screen" to "checkout"))
# If you would rather write your own client, this is the entire contract.
# Note the absence of an Authorization header.
curl -sS -X POST \
'https://api.infralytiqs.com/il/analytics/acme/acme-ios/events' \
-H 'Content-Type: application/json' \
-d '[
{
"event_type": "screen_view",
"event_time": "2026-07-25T18:04:11.482Z",
"anonymous_id": "8F2A1C7D-5E2B-4064-AF31-C9D2B7E4A106",
"session_id": "1753466651-x9k2m4",
"device_type": "mobile",
"device_os": "iOS 18.4",
"language_iso_code": "en-GB",
"custom_dimensions": {
"screen": "product_detail",
"app_version": "4.12.0",
"platform": "ios"
},
"custom_metrics": { "load_time_ms": 284 }
}
]'
# 201 { "success": true, "message": "1 event(s) ingested successfully", "count": 1 }
# Worth knowing:
# · Send a single object or an array; both are accepted
# · Max 1000 events per request
# · event_time is optional and defaults to server arrival time — always
# send it from mobile, where capture and delivery can be hours apart
# · tenant_id and site_id come from the URL only; putting them in the
# body has no effect
Events worth sending from a mobile app
A starting taxonomy. Keep event_type low-cardinality and push the detail into dimensions.
Event type
When
Useful dimensions
Useful metrics
app_install
First launch after install
install_source, app_version
—
app_foreground · app_background
Lifecycle transitions
—
session_duration_s on background
screen_view
A screen becomes visible
screen, previous_screen
load_time_ms
user_action
A deliberate interaction
action, screen, element
—
site_search
In-app search
search_term — MATERIALIZED into its own column
result_count
purchase
A transaction completes
product_id, currency, store
value, quantity
api_error
A request your app made failed
endpoint, error_code, subtype for the class of failure
status_code, duration_ms
push_received · push_opened
Notification lifecycle
campaign_id, notification_type
—
permission_prompt
A system permission is answered
permission, subtype granted or denied
—
Mobile questions
They are discoverable by anyone who inspects your traffic, so treat them as public. The ingest path is write-only and append-only: it cannot read your data, enumerate configuration or reach another tenant. What must never ship in an app is the client secret, which does grant read access to reports.
Read the install referrer on Android or the attribution token on iOS at first launch, then send it as dimensions on an app_install event. Infralytiqs stores and reports whatever you send; it does not run an attribution network itself.
Because there is no third-party SDK, you declare exactly what your own code sends — which you can read in the file above. The default payload contains no advertising identifier, no precise location and no contact information. Anything personal in there is something you chose to put in a custom dimension.
No. It is permission-gated, user-resettable, and on iOS usually unavailable, so your visitor counts would be wrong in a way that is hard to detect. A UUID generated on first launch and kept in Keychain or encrypted preferences is more stable and less sensitive.
Through user_id. Call identify with the same identifier on both, and a cross-platform journey becomes a group by user_id. Before login the two are necessarily separate, since anonymous_id is per-platform by design.
Batching is what prevents that. One request every thirty seconds carrying fifty events costs far less radio time than fifty separate requests, and the flush on backgrounding means you are not waking the radio while the app is idle.
Server-side next
The same endpoint from Node, Java, Python or a shell script — including backfilling history.