Runar Ovesen Hjerpbakk

Leadership, product and technology

Using self-hosted Umami for iOS app analytics

Since I already self-host Umami, I wanted the same experience for my apps without a heavyweight analytics SDK, an IDFA prompt, or a third party collecting data on my behalf. I created a small open source Swift package to achieve this: umami-swift. This gives me the same visitor and visit numbers as the sites, in the same dashboard.

My Umami-instance now has apps too

Every app is registered in Umami as a website and shows up in the websites list next to the actual websites, real app icon included:

Umami's websites list with apps like Simplest Workout Tracker and Pantelappen side by side with actual websites, each app showing its real icon

An app’s overview reads exactly like a website’s, with visitors, visits, and views counted the same way:

The Umami overview for Simplest Workout Tracker showing 5 visitors, 6 visits, and 42 views in the last 24 hours

An app’s screens report themselves as pages. In my workout tracker, Simplest Workout Trakcer, the calendar, the annual summary, the settings sheet, and the log workout sheet each show up, so the pages list shows which parts of the app people actually use:

Umami's pages list for the workout tracker, with screens like /settings, /summary, /calendar, and /log-workout listed as paths with visitor counts

How it was done

An iOS app with umami-swift sending events over HTTPS straight to the Umami container on Fly.io, which writes to Supabase, while the Cloudflare Worker used by websites is skipped

umami-swift is an open source Swift package with no dependencies of its own. The public API is deliberately small:

import Umami

// At launch:
Umami.configure(
    websiteId: "your-website-id",
    host: "cardgame.ios",
    baseURL: URL(string: "https://your-umami-host")!
)

// Anywhere:
Umami.track("game_started")
Umami.track("level_completed", ["level": 7, "won": true])

// When the user moves to another screen:
Umami.screen("settings")

// Opt-out:
Umami.setEnabled(false)

configure takes the website id and a host string. The host is the domain you enter when adding the website in Umami; apps have no real domain, so I use a pseudo-domain like simplestworkouttracker.ios. baseURL is for me set to https://hjerpbakk-analytics.fly.dev, the Fly machine from the previous post. Anyone else running Umami points it at their own instance. Configuring also queues the launch pageview and the app_started event, so a launch shows up in Umami, Overview included, with no further code.

track sends a named event with an optional dictionary of extra data. screen sends a pageview for a named screen. Both queue to disk and send in batches, so a launch without network loses nothing, and the UI never waits for analytics.

Adding it to an app

Adding it to your own app takes a few steps:

  1. In Xcode, choose File → Add Package Dependencies, paste https://github.com/hjerpbakk/umami-swift, set the rule to Up to Next Major Version from 1.3.0, and add the Umami product to your app target. For a Package.swift project, add .package(url: "https://github.com/hjerpbakk/umami-swift", from: "1.3.0") to dependencies and the Umami product to your target.
  2. In your Umami dashboard, add a new website for the app. Apps have no real domain, so give it a stable pseudo-domain like myapp.ios and copy the website id it generates.
  3. Call Umami.configure once at launch, from your app’s init (or application(_:didFinishLaunchingWithOptions:)). Pass the website id, the same domain you entered in Umami as host, and your own Umami instance as baseURL:

    Umami.configure(
       websiteId: "<your-website-id>",
       host: "myapp.ios",
       baseURL: URL(string: "https://your-umami-host")!
    )
    
  4. Run the app and open its website in your Umami dashboard. The app_started event should appear within a few seconds, tagged with a visitor id, confirming events are flowing.

That’s the whole integration.

The rest of this post is the details of how it all works.

A different client for the same backend

Umami’s tracking script talks to two endpoints: /api/send for a single event and /api/batch for several at once. Both take a JSON body, and neither asks for an API key or a signed request. Umami identifies a website by the website-id in the payload, not by who sent it.

So a Swift app can call the same endpoints directly, without a browser or a script tag. It sends the same shape of JSON the tracking script would have produced, with a User-Agent that doesn’t trip Umami’s bot filter. The honeypot from the previous post applies to apps too: a request that looks like a bot gets a 200 back, and the hit never shows up in the dashboard.

Getting unique users right

Umami counts traffic in two units: visitors and visits. A visitor is an identity that persists across visits, a visit is one session. On the web, Umami computes the visitor from a cookieless hash of IP and User-Agent. An app has no browser session to hash, so umami-swift supplies the visitor id itself: every event carries a payload.id, a UUID that Umami accepts as an already-computed visitor identifier.

That UUID is random, lives only in memory, and rotates when the calendar day changes. Nothing is written to the device, so there is no id to read back and usage on different days can’t be linked. Because the id dies with the process, each launch in practice counts as a new visitor, with a day as the ceiling rather than the typical lifetime. This keeps the app on the same footing as Umami’s cookieless web tracking, where a visitor is a rotating server-side hash and not a stored identifier. Nothing here touches IDFA or App Tracking Transparency, and the id can’t follow anyone across apps, installs, or days.

Pageviews make the Overview work

Umami computes visitors, visits, and views from pageviews. A payload with a name is a custom event, not a pageview, so named events on their own leave the Overview at zero and show up only under Events.

So the client sends a pageview for / on every launch and every return to the foreground. On the wire it’s nearly the same payload as the app_started event next to it, the pageview just has no name. That gives the Overview real numbers: one view per launch, with visits and visitors counted the same way as for my websites.

Screens can opt in too. Umami.screen("settings") sends a pageview for /settings, and the screen appears in Umami’s pages list like a page on a website. That is what fills the pages list shown earlier in the post.

No ad blockers in the way

Much of the previous post dealt with the Cloudflare /np/ path, because a browser routes every request through whatever ad blocker or privacy extension the visitor has installed. There the blocker sees the full URL, so block lists like EasyPrivacy match Umami’s usual hostnames and script paths. That is what the /np/ path sidesteps.

An app is harder to block. Safari content blockers, the common kind on iOS, only apply inside Safari and never see a native app’s traffic. A system-wide blocker like 1Blocker’s Firewall, or a DNS blocker like NextDNS or AdGuard, is different: it runs as an on-device VPN or resolver and does sit between the app and the network. But it only sees the domain, from the DNS lookup and the server name in the TLS handshake. The path stays inside the encrypted request, so the path and script-name rules that catch Umami in a browser have nothing to match, and all that is left is the hostname. A self-hosted instance on an unremarkable domain like hjerpbakk-analytics.fly.dev is on no block list, so the request goes through. Add that exact domain to a custom list by hand and it would stop, but nothing does so on its own.

So umami-swift talks straight to the Fly origin instead of going through the Cloudflare Worker. The /np/ proxy still does its job for the websites, but the apps skip it. It still has its role though, as you will see now.

Showing app icons in Umami

Umami shows a small icon next to each website in the dashboard, fetched from DuckDuckGo’s favicon service based on the registered domain. That works for actual websites. My apps are registered under pseudo-domains like simplestworkouttracker.ios, which no favicon service can resolve, so every app got the same gray globe.

Self-hosting means I can change this. Umami reads a FAVICON_URL environment variable that swaps the favicon service for any URL template with a {{domain}} placeholder. Mine points at the Cloudflare Worker from the previous post:

FAVICON_URL=https://hjerpbakk.com/icons/{{domain}}

So the apps skip the Worker for tracking, but it ended up doing one small job for them anyway. When asked for /icons/<domain>, it checks the domain against a list of my apps. A match returns the app’s real icon, a 48x48 PNG embedded in the Worker itself (the dashboard renders it at 16 points, so 48 covers a 3x display). Everything else is redirected to DuckDuckGo’s favicon service, and the websites keep the favicons they already had. A small script resizes an app’s 1024 pixel icon and embeds it, so adding an icon for the next app is one command.

The Umami websites list asking the Cloudflare Worker for icons: app domains get the app's embedded icon, every other domain is redirected to DuckDuckGo's favicon service

Purely cosmetic, but the websites list is the first thing I see when I open the dashboard, and as the first screenshot shows, the apps now have their actual icons instead of placeholders.

Next >>