What is Owlmetry?
Owlmetry is a self-hosted analytics platform. The Swift SDK captures events from iOS, iPadOS, macOS, and watchOS apps and delivers them to the Owlmetry server. It handles buffering, gzip compression, offline queuing, session management, and network monitoring automatically — you just call logging methods and the SDK takes care of delivery. On watchOS the SDK also auto-relays through the paired iPhone when direct HTTP isn't available — see the "watchOS Companion Apps" section below.
The SDK is a static Owl enum with no external dependencies. All calls are non-blocking (events are buffered and flushed in batches). A single configure() call initialises everything.
Version Check
Run silently. Only inform the user if an update is available.
- SDK version: Read
Package.resolvedfor the current resolved revision, then compare againstcurl -sf https://api.github.com/repos/owlmetry/owlmetry-swift/releases/latest | jq -r .tag_name. If newer, inform the user.
Skill updates arrive through Claude Code's plugin marketplace (/plugin marketplace update owlmetry-skills).
Prerequisite
You need an ingest endpoint and a client key (owl_client_...) for an Apple-platform app. Both come from the CLI setup flow.
If the user doesn't have these yet, follow the /owlmetry-cli skill first — it handles sign-up, project creation, and app creation. The ingest endpoint is saved to ~/.owlmetry/config.json (ingest_endpoint field) and the client key is returned when creating an app.
Any time you need to run an
owlmetryCLI command (querying events, creating metrics/funnels, listing apps, etc.), load the/owlmetry-cliskill first. Do not guess CLI syntax — it has non-obvious subcommand patterns and flags.
Add Swift Package
Minimum platforms: iOS 16.0, macOS 13.0, watchOS 10.0. Zero external dependencies.
First, fetch the latest SDK release tag — pin to a version so builds are reproducible:
curl -sf https://api.github.com/repos/owlmetry/owlmetry-swift/releases/latest | jq -r .tag_name
# e.g. "v0.1.0" → strip the leading "v" → use "0.1.0" in the snippets below.
If the GitHub API call fails or returns no tags (early alpha may have none), fall back to branch: "main" / Branch > main in the snippets below.
Option A — Package.swift projects
If the project has a Package.swift, add the dependency there (replace 0.1.0 with the tag you fetched above):
dependencies: [
.package(url: "https://github.com/owlmetry/owlmetry-swift.git", from: "0.1.0")
]
Add to your target:
.target(name: "YourApp", dependencies: [
.product(name: "Owlmetry", package: "owlmetry-swift")
])
Then run swift package resolve to fetch the dependency.
Option B — Xcode projects (.xcodeproj)
For .xcodeproj-based projects with no Package.swift, add the Owlmetry Swift package by editing <Project>.xcodeproj/project.pbxproj directly to add a remote Swift package reference for https://github.com/owlmetry/owlmetry-swift.git pinning kind = upToNextMajorVersion from the tag you fetched (e.g. minimumVersion = 0.1.0), product: Owlmetry. Do not ask the user to add it manually in Xcode.
Option C — Ask the user (last resort)
If pbxproj editing fails or the project structure is too complex, ask the user to add the package in Xcode:
- File > Add Package Dependencies
- Enter URL:
https://github.com/owlmetry/owlmetry-swift.git - Set rule to Up to Next Major Version starting at the tag from the fetch above (e.g.
0.1.0) - Add Owlmetry to the app target
Verify Package Integration
After adding the package, resolve dependencies and build:
xcodebuild -resolvePackageDependencies -project <path>.xcodeproj -quiet
xcodebuild -project <path>.xcodeproj -scheme <SchemeName> -destination 'platform=iOS Simulator,name=iPhone 16' build -quiet
If the build succeeds, proceed with configuration. The "No such module 'Owlmetry'" warning in editors (SourceKit) is expected and resolves during a real xcodebuild.
Configure
Configuration must happen once, as early as possible — in the @main App init() or AppDelegate didFinishLaunching. Do not defer it to a later point (e.g., after async setup or user consent). The SDK measures app launch time (_launch_ms) from process start to the configure() call, so placing it early gives an accurate cold-start metric. It also ensures no events are dropped before configuration. Each configure() call generates a fresh session_id (UUID) that groups all subsequent events together.
import Owlmetry
@main
struct MyApp: App {
init() {
do {
try Owl.configure(
endpoint: "https://ingest.owlmetry.com",
apiKey: "owl_client_..."
)
} catch {
print("Owlmetry configuration failed: \(error)")
}
}
// ...
}
Parameters:
endpoint: String— server URL (required)apiKey: String— client key, must start withowl_client_(required)flushOnBackground: Bool— auto-flush when app backgrounds (default:true)compressionEnabled: Bool— gzip request bodies (default:true)networkTrackingEnabled: Bool— auto-track URLSession HTTP requests (default:true)consoleLogging: Bool— print events to console/Xcode output (default:true)
Auto-detects: bundle ID, debug mode (#if DEBUG). Auto-generates: session ID (fresh each launch).
watchOS Companion Apps
Only relevant if the project ships a watchOS app target (typically alongside an iOS counterpart). Skip this section entirely for iOS-only / macOS-only projects.
The SDK ships native watchOS 10+ support: Owl.configure(...), Owl.track(...), Owl.error(...), attachments, attribution, everything works identically on the watch. Delivery uses a tiered pipeline (direct HTTP → WatchConnectivity → on-disk queue) so events survive the watch being suspended, out of cellular range, or far from its iPhone. Full background: https://owlmetry.com/docs/sdks/swift/watchos.
Watch-side: nothing special — call Owl.configure(...) in @main App.init(). The SDK auto-activates WCSession.default on the watch.
iPhone-side counterpart (REQUIRED): the iPhone app must forward inbound WatchConnectivity payloads into the Owlmetry pipeline. The SDK never claims WCSession.default.delegate on iPhone — the host app owns it. Add one line to the existing WCSessionDelegate.session(_:didReceiveUserInfo:):
import WatchConnectivity
import Owlmetry
final class PhoneSessionDelegate: NSObject, WCSessionDelegate {
func session(
_ session: WCSession,
didReceiveUserInfo userInfo: [String: Any]
) {
if Owl.handleWatchUserInfo(userInfo) { return }
// ... existing handling for non-Owlmetry payloads
}
func session(_ session: WCSession, activationDidCompleteWith state: WCSessionActivationState, error: Error?) {}
func sessionDidBecomeInactive(_ session: WCSession) {}
func sessionDidDeactivate(_ session: WCSession) { WCSession.default.activate() }
}
If the iPhone app doesn't use WatchConnectivity for anything else, wire the minimal delegate above in application(_:didFinishLaunchingWithOptions:):
private let sessionDelegate = PhoneSessionDelegate()
// in didFinishLaunching:
if WCSession.isSupported() {
WCSession.default.delegate = sessionDelegate
WCSession.default.activate()
}
Without the one-line forward, watch events that arrive via the WC fallback never reach the server. Direct HTTP from the watch still works (when cellular is available), but events emitted while the watch is offline and waiting to relay through the phone are silently dropped on receive.
Owl.handleWatchUserInfo(_:) is safe to call before Owl.configure(...) — pre-configure events are buffered internally and drained once configure completes. iOS may cold-launch the host app to deliver a watch payload, racing your app's configure call.
Watch events arrive on the server with environment: "watchos" (distinct from ios/ipados/macos). The app's broad platform stays apple — no separate app needed.
User Identity (set up during initial configuration)
After adding Owl.configure(), find where the app handles authentication and add Owl.setUser() / Owl.clearUser(). This is part of the basic setup — do it now, before moving on to instrumentation.
Look for the auth state change handler (e.g., Firebase Auth listener, login/logout methods) and add:
// After successful login — claims all previous anonymous events for this user
Owl.setUser(userId)
// On logout — reverts to anonymous tracking
Owl.clearUser()
Where to find it: Search for login/logout methods, auth state listeners, or session management code. Look for patterns like setting a user ID on other services (crash reporting, analytics), storing auth tokens, or clearing user state. Place Owl.setUser() right after the user ID becomes available. Place Owl.clearUser() in the sign-out/logout handler.
The SDK automatically flushes buffered events before claiming identity, so anonymous events from before login are retroactively linked to the user. It also handles the "claim made while offline" case: if the claim never reached the server, the SDK retries it on the next launch once a saved user id is detected, and the server re-attributes any late-flushing anonymous events to the real user automatically — no manual retry needed.
Next Steps — Codebase Instrumentation
Once Owl.configure() is in place and the project builds successfully, you MUST stop here and ask the user which area they'd like to instrument first — even if the user's original prompt asked you to "instrument the app." Do not proceed with any code changes until the user chooses. Present these options:
- Screen tracking — Add
.owlScreen("ScreenName")to every distinct screen in the app. This is the quickest win — automatic screen view and time-on-screen tracking with a single modifier per screen. No CLI setup needed. - Event & error logging — Audit the codebase for user actions, error handling, and key flows. Add
Owl.info(),Owl.warn(),Owl.error()calls at meaningful points. This is SDK-only — no CLI setup required beyond what's already done. - Structured metrics — Identify operations worth measuring (data loading, image processing, etc.). Add
Owl.startOperation()/Owl.recordMetric()to track durations and success rates. Requires CLI first: each metric slug must be defined on the server viaowlmetry metrics create(use the/owlmetry-cliskill) before the SDK can emit events for it. - Funnel tracking — Identify user journeys (onboarding, checkout, key conversions). Add
Owl.step()calls at each step to measure drop-off. Requires CLI first: the funnel definition (with steps and event filters) must be created viaowlmetry funnels create(use the/owlmetry-cliskill) before tracking makes sense.
After the user chooses, do a thorough audit of the entire codebase to find all relevant locations, then present a summary of proposed changes before making any edits.
Screen Tracking (.owlScreen())
The SDK provides a SwiftUI view modifier that automatically tracks screen appearances and time-on-screen with zero manual event calls.
struct HomeView: View {
var body: some View {
VStack { ... }
.owlScreen("Home")
}
}
struct SettingsView: View {
var body: some View {
Form { ... }
.owlScreen("Settings")
}
}
What it does automatically:
- On appear: emits
sdk:screen_appeared(debug level) withscreenNameset - On disappear: emits
sdk:screen_disappeared(debug level) withscreenNameset and_duration_msattribute
Both events are debug-level and filtered out of the default production view — switch to dev data mode (or filter by level) to see screen flow. The disappear event with _duration_ms is the more useful signal; appear is retained so you can detect screens opened but never closed (e.g. a crash mid-screen).
Where to place it: Attach .owlScreen("ScreenName") to the outermost view of each screen — typically on the NavigationStack, Form, ScrollView, or root VStack. Use it on every distinct screen in the app. Choose names that are short, readable, and consistent (e.g., "Home", "Settings", "Profile", "Checkout").
Prefer .owlScreen() over manual Owl.info() for screen views — it handles both appear and disappear with duration tracking. Use manual Owl.info() with screenName: only for events within a screen (button taps, state changes), not for screen appearances themselves.
Network Request Tracking
The SDK automatically tracks all URLSession HTTP requests made via completion handler APIs. This is enabled by default — no code needed beyond Owl.configure(). To disable:
try Owl.configure(
endpoint: "https://ingest.owlmetry.com",
apiKey: "owl_client_...",
networkTrackingEnabled: false
)
What it captures automatically:
_http_method— GET, POST, etc._http_url— sanitized URL (scheme + host + path only, query params stripped for privacy)_http_status— response status code_http_duration_ms— request duration in milliseconds_http_response_size— response body size in bytes_http_error— error description (failures only)
Log levels: .info for 2xx/3xx responses, .warn for 4xx/5xx, .error for network failures (no response).
Safety: The SDK's own requests to the Owlmetry ingest endpoint are automatically filtered out. Query parameters are stripped from URLs to prevent accidental logging of tokens or user IDs.
Coverage: Tracks requests made with URLSession.dataTask(with:completionHandler:) (both URL and URLRequest overloads). Delegate-based and async/await requests are not tracked in this version.
Log Events
Events are the core unit of data in Owlmetry. Use the four log levels to capture different kinds of information:
info— normal operations worth recording: screen views, user actions, feature usage, successful completions. This is your default level.debug— verbose detail useful only during development: cache hits, state transitions, intermediate values. These are filtered out in production data mode.warn— something didn't go as expected but the app can continue: failed validation, precondition checks that fail, slow responses, fallback paths taken, deprecated API usage, missing optional data.error— a caught exception or hard failure inside ado/catchblock: network errors, JSON decode failures, file I/O errors, keychain access failures. Reserve for actual thrown errors, not for anticipated validation outcomes.
Choose message strings that are specific and searchable ("Failed to load profile image" over "error"). Use screenName to tie events to where they happened in the UI. For the full rubric on what to log, what to attach, and what to skip, see Instrumentation Principles below.
message is silently truncated to 2000 characters; attribute values are silently truncated to 200 characters. Put long content in attributes, not in message.
// In a screen context — pass screenName to tie the event to the screen
Owl.info("User opened settings", screenName: "SettingsView")
Owl.debug("Cache hit", screenName: "HomeView", attributes: ["key": "user_prefs"])
Owl.warn("Invalid email format", screenName: "SignUpView", attributes: ["input": email])
do {
let profile = try await api.loadProfile(id: userId)
} catch {
// Pass the Error directly — the SDK extracts the runtime type, NSError
// domain/code, the underlying-error chain, and the call stack. The
// server's issue tracker uses the type as a fingerprint discriminator,
// so a URLError and a DecodingError with the same wording stay on
// separate issues.
Owl.error(error, "while loading profile", screenName: "ProfileView")
}
// Outside a screen context — omit screenName entirely
Owl.info("Background sync completed", attributes: ["items": "\(count)"])
// String-only Owl.error still works for cases where you don't have an
// Error value (precondition failures, manual checks, etc).
Owl.error("Keychain returned no payload for current session")
All logging methods share the same signature:
Owl.info(_ message: String, screenName: String? = nil, attributes: [String: String?] = [:], attachments: [OwlAttachment]? = nil)
Owl.error is overloaded — the first argument may be a String (logger-style) or an Error (exception-style; the SDK extracts type/stack/domain/code/cause-chain into reserved _error_* attributes). When you have an Error value from do/catch, prefer the Error form — you get a richer, queryable issue and per-type fingerprinting.
screenName is optional. Only pass it when the event originates from a specific screen in the UI (e.g., a button tap handler inside a view). Do NOT pass screenName when logging from utility functions, services, managers, network layers, background tasks, or anywhere that isn't directly tied to a visible screen. Passing a fabricated or guessed screen name is worse than omitting it — it pollutes screen-level analytics.
attributes accepts optional values. A String? from your domain code can flow into the literal directly — nil-valued keys are silently dropped before the event ships, so you don't need to unwrap or build the dict conditionally:
let contractId: String? = session.draftId // may be nil
Owl.info("Draft created", attributes: ["context": "createDraft", "contractId": contractId])
The same applies to every method on Owl and OwlOperation that takes an attributes: parameter (info/debug/warn/error, step, startOperation, recordMetric, complete/fail/cancel).
Source file, function, and line are auto-captured.
Avoid logging PII (emails, phone numbers, passwords) or high-frequency events (every frame, every scroll position). Focus on actions and outcomes.
Instrumentation Principles
Before adding info / warn / error calls throughout the app, internalise these four rules. They turn the SDK from a logger into a queryable analytics surface.
1. Log outcomes, not steps
Emit one rich event per user-meaningful outcome, not one event per line of code. The unit is the thing that happened (purchase completed, photo uploaded, document opened, sign-in succeeded), not the work your code did to make it happen.
Don't narrate the action:
Button("Buy") {
Owl.info("Buy tapped", screenName: "Paywall")
Owl.info("Validating receipt", screenName: "Paywall")
Owl.info("Calling StoreKit", screenName: "Paywall")
Owl.info("Receipt validated", screenName: "Paywall")
Owl.info("Unlocking feature", screenName: "Paywall")
}
One event with the full context:
Button("Buy") {
let startedAt = Date()
Task {
do {
try await store.purchase(product)
Owl.info("Subscription purchased", screenName: "Paywall", attributes: [
"product_id": product.id,
"price_locale": product.priceLocale.identifier,
"introductory_offer": product.hasIntroOffer ? "yes" : "no",
"duration_ms": "\(Int(Date().timeIntervalSince(startedAt) * 1000))",
])
} catch {
Owl.error(error, "purchase failed", screenName: "Paywall", attributes: [
"product_id": product.id,
])
}
}
}
.owlScreen() already gives you one event per screen visit with _duration_ms — that is the canonical "log the outcome, not the steps" pattern for navigation. Don't supplement it with extra Owl.info("View appeared") calls. Apply the same instinct to button taps, gestures, and lifecycle handlers: one event per user decision with all the relevant context attached.
For intermediate diagnostic signals (cache hits, state transitions, fallback decisions), use debug level — filtered out of production data mode automatically.
2. Pack attributes wide, not events deep
One event with 12 attributes beats 12 events with one attribute each. For a client-side event, think through these axes and attach whatever's relevant:
| Axis | iOS examples |
|---|---|
| Who | user_id (auto from setUser), subscription_status, plan_tier, account_age_days |
| What | product_id, document_id, playlist_id, feature_flag, notification_id |
| Where | screenName (only when the event is tied to a visible screen), navigation source (from_screen) |
| How | entry_point (push / deeplink / cold_start), gesture, format, source_of_truth (cache / network), retry_count |
| How much | duration_ms, item_count, size_bytes, network_kb, position_seconds |
The SDK auto-attaches a lot for free — don't re-emit any of these manually:
- Device model, OS version, locale (shown
Locale.current),preferred_language(the user's wanted language,Locale.preferredLanguages.first— powers the dashboard's Locales / localization-demand view),supported_languages(the app's shipped languages,Bundle.main.localizations— used to flag the localization gap),_connection(wifi / cellular / offline),app_version,build_number,is_dev,environment(ios / ipados / macos / watchos) — on every event. _http_method/_http_url(query-stripped) /_http_status/_http_duration_ms/_http_response_size/_http_error— auto-captured for every completion-handlerURLSessionrequest vianetworkTrackingEnabled(default on).screenName+_duration_ms— auto-captured per screen via.owlScreen().
High-cardinality attribute values (document IDs, playlist IDs, user IDs) are a feature, not a smell — they let you triage a specific user's broken session from a dashboard chart. The thing to control is event frequency (rule 3), not value uniqueness.
3. Aggregate hot paths; don't log per iteration
Anywhere a callback fires repeatedly — CADisplayLink, Timer.publish, scroll observers, animation .onChange, NotificationCenter chains, batch processors — log the outcome, not each invocation:
// Per-frame log — thousands of events for a single scroll
.onChange(of: scrollOffset) { newOffset in
Owl.debug("Scroll moved", attributes: ["offset": "\(newOffset)"])
}
// Meaningful endpoint — one event when the user reaches a state worth knowing
.onChange(of: scrollOffset) { newOffset in
if newOffset > thresholdToLoadMore {
Owl.info("Reached bottom of feed", screenName: "Feed", attributes: [
"items_visible": "\(visibleItems.count)",
"loaded_pages": "\(loadedPages)",
])
}
}
Same for batch work:
// Per-item — one event per photo
for photo in pendingPhotos {
Owl.info("Photo uploaded", attributes: ["id": photo.id])
}
// Per-session — one event for the whole upload
let startedAt = Date()
var failed = 0
for photo in pendingPhotos {
do { try await upload(photo) }
catch { failed += 1 }
}
Owl.info("Photo backup completed", attributes: [
"uploaded": "\(pendingPhotos.count - failed)",
"failed": "\(failed)",
"duration_ms": "\(Int(Date().timeIntervalSince(startedAt) * 1000))",
])
Same rule for URLSession retry chains, Combine .retry(n) operators, and SwiftUI task blocks that poll: log the final outcome with retry_count, not one event per attempt.
4. Log, metric, or funnel — pick by the question you want answered
- Log event (
Owl.info/warn/error) — "show me individual records of a specific thing that happened." User actions, error context, edge cases. Read on Dashboard → Events. - Lifecycle metric (
Owl.startOperation→.complete/.fail/.cancel) — "show me p50/p95/p99 duration and success rate of this operation over time." Photo uploads, image processing, model loads, API round-trips. Requires server-side metric definition. Read on Dashboard → Metrics. - Single-shot metric (
Owl.recordMetric) — "show me this point-in-time value trended." Cold-start time, items in cart, memory usage at a checkpoint. - Funnel (
Owl.step) — "show me where users drop off across this multi-step flow." Onboarding, checkout, key conversions. Requires server-side funnel definition. - Screen view (
.owlScreen("Name")) — "show me which screens are most/least visited and time-on-screen distributions." One modifier per screen; covers appear + disappear + duration with zero manual calls. Always prefer this over a manualOwl.info("Screen viewed").
The same flow can warrant several: an onboarding sequence gets .owlScreen() on each step, a funnel for conversion across the sequence, a lifecycle metric on the longest single step (e.g. "create-account"), and Owl.error on any catch path for triage.
When in doubt, write one event with more attributes rather than several events with fewer.
File Attachments (use sparingly)
When an error cannot be reproduced without the original input bytes — a media conversion that failed on a specific image, a 3D model that failed to parse, a document that failed to decode — you can attach the file to the error event. The attachment appears on the resulting issue in the dashboard, CLI, and MCP so an engineer can download and reproduce.
do {
try await PhotoConverter.convert(inputURL: url)
} catch {
Owl.error(
"image conversion failed",
screenName: "PhotoConverterView",
attributes: ["stage": "decode", "error": "\(error)"],
attachments: [
OwlAttachment(fileURL: url), // from disk
OwlAttachment(data: debugJSON, name: "debug.json",
contentType: "application/json"), // in memory
]
)
}
Attachments are a limited resource. Each project has a storage quota (default 5 GB) and each end-user has their own bucket within that project (default 250 MB per user — the SDK automatically tags uploads with the currently identified Owl.userId). Before adding attachments: anywhere, make sure the file's bytes are essential to reproduce the bug. Good candidates:
- ✅ A failed media conversion where only the input bytes can reproduce the decoder bug.
- ✅ A 3D model / document parse failure where the file format itself is the suspect.
- ✅ A CoreML or similar blob that fails to load at runtime.
Bad candidates — do not attach:
- ❌ Every error. Routine failures (network timeouts, validation) already have enough detail in
attributes. - ❌ Files you can reconstruct from event attributes alone (URLs, IDs, small config).
- ❌ Large asset files that are downloaded rather than user-supplied — include the source URL instead.
- ❌ Screens or UI state. Use
screenNameandattributesfor that.
Upload behaviour is strictly non-fatal: if the device is offline, the user's per-user bucket or the project quota is exhausted, or the server otherwise rejects the file, the event itself still posts — the attachment is dropped silently and a warning is logged via OSLog. Uploads run on a separate serial queue so a 200 MB file never blocks event batching. There is no offline queue for attachments in v1: if the device is offline when the error fires, the attachment is discarded but the event queues normally.
User Identity
Identity connects events to real users. Before setUser() is called, all events are tagged with an anonymous ID (owl_anon_...). After login, calling setUser() does two things:
- Tags all future events with the real user ID.
- Retroactively claims all previous anonymous events for that user (server-side), so you get a complete history.
Call setUser() right after successful authentication. Call clearUser() on logout to revert to anonymous tracking.
// After login — claims all previous anonymous events
Owl.setUser("user_123")
// On logout — reverts to anonymous tracking
Owl.clearUser()
// On logout with fresh anonymous ID
Owl.clearUser(newAnonymousId: true)
Read the current user id (real if setUser was called, otherwise the anonymous device id; nil before configure()) via Owl.currentUserId: String? — useful for wiring other SDKs or surfacing the id in debug UI.
Important: The SDK automatically flushes buffered events before claiming identity. If a previous setUser() call failed to reach the server (e.g. the device was offline), the SDK re-issues the claim on the next launch, and any anonymous events that only flush after the claim are still re-attributed to the real user automatically. Owl.setUser(id) just works once the device gets network.
Funnel Tracking
Funnels measure how users progress through a multi-step flow (onboarding, checkout, activation) and where they drop off. The system has three parts:
- Define the funnel server-side (via CLI or API) with ordered steps and event filters.
- Record steps client-side with
Owl.step("step-name"). - Query analytics to see conversion rates and drop-off between steps.
The step name you pass to Owl.step() must match the step_name in the funnel definition's event_filter. For example, if the step filter is {"step_name": "welcome-screen"}, then call Owl.step("welcome-screen").
Funnel design rules:
- Each step must be a point that every user in the funnel passes through on the way to the goal. If a step is conditional (e.g., paywall only shown to free users), it breaks the chain — users who skip it show as 0% conversion from that point.
- Keep funnels focused on one flow. Don't combine "import a model" + "explore features" into one funnel — those are separate journeys with separate goals.
- Optional interactions are not steps. Toggling a setting, viewing info, or using a tool are engagement events (log with
Owl.info()), not funnel progression. A funnel step should represent the user moving closer to the goal. - Split alternative paths into separate funnels. If users can take a screenshot OR record a video, create two funnels — don't put both paths in one.
- Aim for 3-6 steps per funnel. Too few = no drop-off insight. Too many = noise.
Use attributes when you need to segment funnel analytics later (e.g., by signup method or referral source).
Owl.step("welcome-screen")
Owl.step("create-account", attributes: ["method": "email"])
Owl.step("complete-profile")
Owl.step("first-post")
Define matching funnel definitions via /owlmetry-cli:
# Write steps to a JSON file (avoids shell quoting issues)
cat > /tmp/funnel-steps.json << 'EOF'
[
{"name": "Welcome", "event_filter": {"step_name": "welcome-screen"}},
{"name": "Account", "event_filter": {"step_name": "create-account"}},
{"name": "Profile", "event_filter": {"step_name": "complete-profile"}},
{"name": "First Post", "event_filter": {"step_name": "first-post"}}
]
EOF
owlmetry funnels create --project-id <id> --name "Onboarding" --slug onboarding \
--steps-file /tmp/funnel-steps.json --format json
Structured Metrics
Use structured metrics instead of plain log events when you want aggregated statistics (averages, percentiles, error rates) rather than just a list of individual events. Metrics give you p50, p95, p99 latencies, success/failure rates, and trend data over time.
Decision: lifecycle vs single-shot:
- Lifecycle — when you're measuring something with a duration (start → end). Examples: image upload, API call, video encoding, onboarding flow. The SDK auto-tracks
duration_ms. - Single-shot — when you're recording a point-in-time value. Examples: app cold-start time, memory usage, items in cart at checkout.
The metric definition must exist on the server before the SDK emits events for that slug. Create it via CLI first.
Lifecycle operations (start → complete/fail/cancel)
let op = Owl.startOperation("photo-upload", attributes: ["format": "heic"])
// On success:
op.complete(attributes: ["size_bytes": "524288"])
// On failure:
op.fail(error: "timeout", attributes: ["retry_count": "3"])
// On cancellation:
op.cancel(attributes: ["reason": "user_cancelled"])
duration_ms and tracking_id (UUID) are auto-added.
Rules for lifecycle operations:
- Every
startOperation()must end with exactly one.complete(),.fail(), or.cancel(). An operation that starts but never ends creates orphaned metric data with no duration. .complete()— the operation succeeded and produced its intended result..fail(error:)— the operation attempted work but encountered an error..cancel()— the operation was intentionally stopped before completion (user cancelled, view disappeared, became irrelevant).- Don't start for no-ops — if the operation is skipped entirely (cache hit, dedup, precondition not met), don't call
startOperation()at all. Only start when actual work begins. - Don't track duration manually —
duration_msis auto-calculated from start to complete/fail/cancel. Never pass a manual duration attribute. - Long-lived operations — if the operation outlives the scope where it was started (e.g., recording that spans a view lifecycle), store the
OwlOperationhandle as a property. Cancel it on cleanup (.onDisappear,deinit) if it hasn't ended yet:
// Store handle for operations that span a lifecycle
@State private var recordingOp: OwlOperation?
func startRecording() {
recordingOp = Owl.startOperation("video-recording")
// ... begin recording
}
func stopRecording(url: URL) {
recordingOp?.complete(attributes: ["format": "mp4"])
recordingOp = nil
}
func onError(_ error: Error) {
recordingOp?.fail(error: error.localizedDescription)
recordingOp = nil
}
// Safety net: cancel if view disappears mid-operation
.onDisappear {
recordingOp?.cancel()
recordingOp = nil
}
Create the metric definition first:
owlmetry metrics create --project-id <id> --name "Photo Upload" --slug photo-upload --lifecycle --format json
Single-shot measurements
Owl.recordMetric("app-cold-start", attributes: ["screen": "home"])
Slug rules: lowercase letters, numbers, hyphens only. Invalid slugs are auto-corrected with a console warning.
User Properties
Attach custom key-value metadata to the current user. Properties are merged server-side — existing keys not in your call are preserved.
Owl.setUserProperties([
"plan": "premium",
"org": "acme",
])
Set a value to "" to delete a key. All values must be strings. Max 50 properties per user, 50-char keys, 200-char values.
Properties follow the current user identity. If the user is anonymous, properties are set on the anonymous user and merged into the real user on Owl.setUser().
Use for user-level data that changes infrequently (subscription status, plan tier, company). For event-specific data, use attributes on events instead.
RevenueCat integration prompt — copy-paste to set up subscription tracking:
Connect RevenueCat to my Owlmetry project so I can see paid vs free users:
1. Use `/owlmetry-cli` to add the RevenueCat integration with my RC V2 secret API key
(needs Customer information → Read only AND Project configuration → Read only at the section level, everything else No access).
2. Show me the webhook setup values from the output so I can paste them into RevenueCat.
3. After I confirm the webhook is live, run a bulk sync to backfill existing subscribers.
4. Add Owl.setUserProperties() calls in my RevenueCat Purchases delegate or
StoreKit transaction handler so the dashboard updates immediately when a user
subscribes, without waiting for RevenueCat's webhook.
Apple Search Ads Attribution
Owlmetry auto-captures Apple Search Ads attribution on Owl.configure() — no code required. On the first launch after install, the SDK calls AAAttribution.attributionToken() (iOS 14.3+) in a background task, submits the token to Owlmetry, and the server resolves it with Apple's public Attribution API. Once captured (attributed or not), the result is cached per-install so it runs exactly once.
On successful attribution the user picks up:
attribution_source = "apple_search_ads"(cross-network — future Meta/Google support writesmeta/google_adsinto the same key)asa_campaign_id,asa_ad_group_id,asa_keyword_id,asa_claim_type,asa_ad_id,asa_creative_set_id
When Apple's AdServices API returns its deliberate non-production fixture (same numeric ID across campaign, ad group, and ad — structurally impossible from real Apple data), the user gets attribution_source = "apple_test_install" with no asa_* fields. This fires for TestFlight builds, Xcode-deployed dev builds on real devices, and the iOS simulator (Apple Forum #66161). Filter these out of acquisition dashboards alongside organic installs (attribution_source IN ('none', 'apple_test_install')), or treat them as a separate badge to spot a developer's own install showing up.
An install Apple did not attribute gets attribution_source = "none" and nothing else.
Human-readable names (asa_campaign_name, asa_ad_group_name, asa_keyword, asa_ad_name) come from either the Apple Search Ads integration (first-party; Owlmetry resolves every attributed user via Apple's Campaign Management API — configured per-project in the dashboard with OAuth credentials) or the RevenueCat integration (RC resolves the same names server-side and exposes them as subscriber attributes; bulk sync enriches every attributed user in RC, while RC's webhook delivery only fires on subscription events so free users only get enrichment through sync). Both sources are per-field merged — they never overwrite each other or the numeric IDs the SDK writes.
Opt-out: set attributionEnabled: false when configuring:
try Owl.configure(
endpoint: "https://api.owlmetry.com",
apiKey: "owl_client_…",
attributionEnabled: false
)
Manual submission: apps that run their own token fetch can hand the token off to Owlmetry:
await Owl.sendAppleSearchAdsAttributionToken(myCapturedToken)
Normal apps should not need to call this — the auto-capture on configure() covers it.
Dev-only reset: call Owl.resetAppleSearchAdsAttributionCapture() to clear the per-install captured flag so the next Owl.configure() re-attempts capture — intended for development builds and UI tests, not production.
Privacy notes:
- AAAttribution is first-party and does not require App Tracking Transparency (no ATT prompt).
- No privacy manifest entries are required for token retrieval.
- Apple's attribution record may take up to ~24h to populate after install. The SDK retries across launches and gives up after 5 pending responses (writes
attribution_source = "none"). - In the iOS simulator
AAAttribution.attributionToken()throwsplatformNotSupported— setOWLMETRY_MOCK_ADSERVICES_TOKENin the scheme's environment (DEBUG only) to mock a token.
Debug via Owlmetry itself: every capture attempt emits an sdk:attribution_capture event so you can see success/fail from the dashboard without attaching a debugger. Attributes:
_outcome |
Level | Extra attributes | When |
|---|---|---|---|
success |
info | _attribution_source (apple_search_ads | none | apple_test_install) |
Apple returned a decisive answer |
pending |
info | _attempt, _max_attempts |
Apple 404 — record not ready, will retry next launch |
gave_up |
warn | _attempts |
Hit the 5-pending cap; wrote attribution_source = "none" |
| `token_fe |
…(truncated)