Why no native SDK

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")

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.