Documentation
Integrate Trackless Telemetry into your app. Configure once, then record events with typed methods.
AI-ready. Using an AI coding assistant? Point it at llms-full.txt and say "Add Trackless Telemetry with API key tl_your_key_here". That file contains critical rules (no wrapper classes; detail is a separate parameter, not a dot-suffix) that prevent the most common integration mistakes.
Installation
Add the package via Swift Package Manager:
// In Xcode: File > Add Package Dependencies...
// Repository URL:
https://github.com/trackless-telemetry/sdk-iosQuick Start
import TracklessTelemetry
// Configure once at app startup (environment auto-detected)
Trackless.configure(apiKey: "tl_your_api_key_here")
// Record typed events
Trackless.view("home")
Trackless.feature("export_clicked")Configuration
| Option | Type | Default | Description |
|---|---|---|---|
| apiKey | String | required | Your app API key (tl_ prefix) |
| endpoint | String | "https://api.tracklesstelemetry.com" | Ingest endpoint URL |
| environment | TracklessEnvironment? | auto-detected | .sandbox or .production (DEBUG auto-detects) |
| enabled | Bool | true | Set false to disable telemetry |
| onError | ((Error) -> Void)? | nil | Error callback for debugging |
| flushIntervalSeconds | TimeInterval | 60 | How often buffered events are sent (seconds) |
| debugLogging | Bool | false | Enable debug logging via os.Logger |
API Reference
Trackless.configure(apiKey:endpoint:environment:...)
Initialize the SDK. Call once at app startup. Environment is auto-detected.
Trackless.view(_ name: String, detail: String? = nil)
Record a view event with optional detail.
Trackless.feature(_ name: String, detail: String? = nil)
Record a feature usage event with optional detail.
Trackless.funnel(_ name: String, stepIndex: Int, step: String)
Record a step in a multi-step flow.
Trackless.performance(_ name: String, durationSeconds: Double)
Record a performance measurement in seconds.
Trackless.error(_ name: String, severity: TracklessErrorSeverity, code: String?)
Record an error occurrence.
setEnabled(_ enabled: Bool)
Toggle telemetry at runtime. When disabled, buffered data is discarded and calls no-op.
flush() async
Manually flush buffered events to the server. Automatic flushing occurs every 60 seconds and on app background.
destroy() async
Flush remaining events and permanently shut down the SDK.
Projects & Organization
A Trackless app represents one product or experience. A single API key works across all platforms — iOS, Android, and web — so you can analyze each platform independently using dashboard filters.
Create separate apps for different products or audiences. For example, your mobile app and its marketing website should be two separate Trackless apps — they serve different audiences, track different events, and you'd evaluate their analytics independently.
Event Naming Guide
All event fields are automatically normalized — lowercased, spaces and special characters replaced with underscores, trimmed, and truncated to 100 characters. You can pass natural strings and the SDK handles the rest.
// Names are auto-normalized
Trackless.feature("export_clicked") // already valid
Trackless.feature("Sign Up Button") // → "sign_up_button"
Trackless.feature("Export!Clicked") // → "export_clicked"
// Use detail for grouping — also auto-normalized
Trackless.feature("theme", "dark")
Trackless.error("crash", "error", "ERR_001") // code → "err_001"
// Avoid: user-specific or high-cardinality names
// Trackless.feature("user_123_clicked")
// Trackless.view("page_view_/users/abc123")Name vs. Detail
detail is a separate parameter. The dashboard stores name and detail as separate fields and renders the distribution of detail values as donut charts grouped by name. Concatenating the variant into the name loses that grouping.
Do
Trackless.feature("theme", "dark")
Trackless.view("settings", "notifications") Pass the variant as the detail argument.
Don't
Trackless.feature("theme_dark")
Trackless.feature("theme.dark")
Trackless.view("settings_notifications") Any form of concatenation flattens into opaque names. The dashboard can't know dark is a variant of theme.
Session reach counts per name, not per detail. The first feature() call for a given name within a session is flagged automatically, so the dashboard can report what share of sessions used it. A session that records theme as both dark and light adds one session to theme's reach, while each variant still accumulates its own count. Reach answers "how many sessions touched this?" — counts answer "how often, and which variant?"
Call Trackless Directly
Trackless is a thread-safe static singleton. Call it directly from your views, handlers, and components — don't create an Analytics / AnalyticsService / useAnalytics wrapper. Wrappers add indirection with no benefit, hide the typed API from IDE autocomplete, and make SDK upgrades harder. If you need to silence events in tests, call Trackless.setEnabled(false).
Each app is limited to 500 unique event tuples per day (configurable). PII (emails, phone numbers, SSNs) is automatically stripped from all fields.
Ingest API Reference
You can also send events directly to the ingest endpoint without using an SDK.
POST https://api.tracklesstelemetry.com/
Content-Type: application/json
X-Api-Key: tl_your_api_key_herePayload Structure
One payload is a date, a shared device context, and a batch of typed events. The context is sent once per batch rather than once per event.
| Field | Type | Required | Description |
|---|---|---|---|
| date | String | Yes | ISO date (YYYY-MM-DD), within last 7 days |
| environment | String | No | sandbox or production (default: production) |
| context | Object | Yes | Device and app context — see below. The object itself is required; every field inside it is optional, so {} is accepted. Omitting context entirely is rejected. |
| events | Array | Yes | 1–100 typed events — see below |
Context Fields
All optional, all coarse by design. Nothing here identifies a device or a person. Unknown keys are ignored silently rather than rejected — check spelling, because a misspelled field is dropped without an error.
| Field | Type | Constraints |
|---|---|---|
| appVersion | String | Your app's version string |
| buildNumber | String | Your app's build identifier |
| platform | String | ios, android, web |
| osVersion | String | Major version only ("17", not "17.4.1") — the SDKs send major only |
| deviceClass | String | phone, tablet, desktop, watch, tv, unknown |
| region | String | ISO 3166-1 alpha-2, uppercase ("US") — from the system locale, never from IP |
| language | String | Two- or three-letter lowercase code ("en") |
| browser | String | chrome, safari, firefox, edge, bot, other (web) |
| os | String | macos, windows, linux, android, ios, other |
| sdkVersion | String | Platform and version, e.g. "ios/0.4.1" |
| distributionChannel | String | e.g. testflight, app_store, play_store; hostname on web |
| daysSinceInstall | Integer | Bucketed server-side; never stored as an exact age. Mobile only — there is no install timestamp to read on web |
locale is a deprecated back-compat path for older SDK payloads. It is never stored: the endpoint reads the country half of it into region and drops the rest. Send region and language directly instead — a malformed locale rejects the whole request, while a malformed region is simply dropped.
Event Fields
type and name are always required. The rest are optional and are recorded only for the types listed.
| Field | Type | Applies to | Constraints |
|---|---|---|---|
| type | String | all | session, view, feature, funnel, performance, error — those six and no others |
| name | String | all | 1–100 chars, ^[a-z0-9_-]+(\.[a-z0-9_-]+)*$ — lowercase only, dots may separate segments but cannot lead, trail, or repeat |
| count | Integer | all | Optional, defaults to 1. Max 1,000,000. Rejected above 1 for session and funnel — those are once-per-occurrence by definition |
| detail | String | view, feature | ≤100 chars, same character rules as name. A separate field, not a dot-suffix on the name |
| firstUses | Integer | feature | Once-per-session first-use marker, 1 ≤ firstUses ≤ count. Rejected on other types |
| firstOccurrences | Integer | error | Once-per-session first-occurrence marker, 1 ≤ firstOccurrences ≤ count. Rejected on other types |
| step | String | funnel | Step name, same character rules as name |
| stepIndex | Integer | funnel | Zero-based position in the sequence |
| duration | Number | performance | Seconds, ≥ 0. Mutually exclusive with durations |
| durations | Array | performance | 1–1,000 seconds values from client-side rollup. Mutually exclusive with duration; rejected on other types |
| threshold | Number | performance | Seconds, > 0. Creates a separate row per name/threshold pair; rejected on other types |
| severity | String | error | debug, info, warning, error, fatal |
| code | String | error | ≤100 chars, same character rules as name (e.g. "e503") |
Limits
| Limit | Value |
|---|---|
| Events per request | 100 |
| Request body size | 50 KB — the SDKs split larger batches across requests |
| count per event | 1,000,000 |
| Event date age | 7 days |
| Name and field length | 100 characters |
Response
{ "accepted": 3, "rejected": 1, "errors": [{ "index": 2, "reason": "Invalid payload" }] }reason is deliberately generic. Error responses never disclose which rule was broken, how close the app is to a rate limit or cardinality budget, or anything about the plan.
Example
curl -X POST https://api.tracklesstelemetry.com/ \
-H "Content-Type: application/json" \
-H "X-Api-Key: tl_your_api_key_here" \
-d '{
"date": "2026-08-21",
"environment": "production",
"context": {
"appVersion": "2.4.0",
"platform": "ios",
"osVersion": "17",
"deviceClass": "phone",
"region": "US",
"language": "en",
"sdkVersion": "ios/0.4.1",
"distributionChannel": "app_store",
"daysSinceInstall": 12
},
"events": [
{ "type": "session", "name": "start" },
{ "type": "view", "name": "calculator", "count": 3 },
{ "type": "feature", "name": "export_pdf", "detail": "a4", "count": 2, "firstUses": 1 },
{ "type": "funnel", "name": "checkout", "step": "payment_method", "stepIndex": 2 },
{ "type": "performance", "name": "cold_start", "durations": [0.84, 1.02], "threshold": 1.0 },
{ "type": "error", "name": "upload_failed", "severity": "error", "code": "e503", "count": 2, "firstOccurrences": 1 }
]
}'Agent context
Everything above this section is the first step of a three-step workflow: point your coding agent at these guides and instrumentation is its job, not your afternoon. The second step is shipping — production usage accumulates as aggregate counts while your app is simply out there. Agent context is the third step: the handover that puts those counts in front of the agent that wrote the integration.
The dashboard answers a fixed set of questions well. It cannot answer the ones nobody anticipated, because those need things Trackless does not know and should never be told: what your app is for, what shipped on Tuesday, which number matters to your business this quarter, what decision is pending. Your agent knows all of it. So Trackless does the half a vendor can do — count honestly, put every figure on a stated time axis beside the traffic it happened in, and mark every place the data runs out — and leaves the deciding to the half that knows your app.
Copy for your agent, on any app's Agent context page in the dashboard, produces a context pack: one app's counts for the window and the slice you chose, together with the instructions that tell an agent how to read them. Download, beside it, saves the same payload — the reading instructions followed by the pack — as a markdown file, for agents you hand a file rather than a clipboard. A pack never travels without its instructions, whichever control you press. Paste or attach it into whatever agent you already use; the agent that reads it is the agent that edits your code, so what it recommends lands as diffs, ships in your next version, and shows up in your next pack.
What a pack carries
- Every count on a named time axis — daily, weekly or monthly, chosen from the range — beside the session totals for the same buckets, which is what makes any of it readable.
- An explicit mark wherever something could not be measured, so "this was zero" and "we could not see this" are written differently rather than left to inference.
- The filters you set, and the ones you left open with their session totals — so your agent knows the shape of what it is not looking at.
- What was left out, and how much of the app it was.
- A plain statement, carried as data, of what this kind of storage structurally cannot say.
The range means what it says: pick a fortnight, get a fortnight. Nothing is read until you ask for it — set your filters, then press Generate. Change a filter afterwards and the pack on screen is marked stale rather than quietly replaced, because numbers rearranging themselves while you are reading them is exactly what this is meant to avoid.
We never call a model
Not on the server, not in the dashboard, nowhere in this feature. A pack is something you pull — a button you press — and paste into an agent you run and pay for. No telemetry reaches any model vendor from us.
The other half of that, which is yours to weigh: once you paste a pack, your app's aggregate counts sit with whatever service you pasted them into, under that service's terms rather than ours. A pack holds no personal data — there is none to hold — but it is still your data leaving your control, by your own action.
How a pack asks to be read
Five rules travel with every pack. They are the difference between a pack and a CSV export, which is what produces "errors tripled" off two errors becoming six.
- A number never travels alone. Every count arrives beside the traffic it happened in. Six errors in a week of twelve hundred sessions is a finding; "six errors" on its own is not.
- Blank means unknown. It never means zero. "We could not measure this" and "this was zero" are opposite findings, and the second is the one a model will guess. A pack writes them differently.
- A small number stays a small number. Two errors becoming six is a tripling, and it is also six errors, which cannot tell a real regression from a quiet week. A pack hands over both counts and the traffic behind them, and the instructions say not to quote the multiple.
- How widely a feature is reached and how heavily it is used are different questions, and a feature can be unmeasured on one and not the other. A pack reports both separately and marks what it could not place, so "not measured" never reads as "nobody used it".
- What was left out is disclosed, with its weight. A busy app has a long tail that will not fit, so a pack keeps the largest and says how many it dropped and how much of the app they were. "412 more features" tells you nothing; "412 more, 9,840 uses between them" tells you whether what you are holding is most of your app or a third of it.
Availability
Every plan, Free included. There is no tier gate — if you can see the app, you can copy its pack. One limit applies, and it is a cost control rather than a product boundary: ten packs per account per minute, which is several apps plus a retry and a refresh inside the same minute. Nothing lifts it, because nothing needs to.
What it is not
Not a chatbot and not an assistant. It answers nothing itself, and it never learns what your app is for — that is your agent's job, and telling us would be the wrong architecture. The pack does not grade or prioritise anything either: deciding what matters needs the context you have and we do not, which is the whole reason a pack is pasted into your agent rather than read off a chart. The feature verdicts you see in the dashboard are a different thing, and they stay there — built from reach and trend alone, which is as much as a vendor can know, they never travel in a pack. Not a public API and not a second API key: it rides the dashboard sign-in you already have. And it cannot be made to yield unique people, attribution, or per-session paths — none of that is collected, so none of it can be packaged, and a pack says so as data, so an agent cannot quietly invent it. A pack can be narrowed as a whole — the filters you set travel with it, and every number in it respects them — but no series inside it is split by platform, region, device class or version, and no figure crossing two dimensions you left open can be built from one.
Privacy Guarantees
All Trackless SDKs collect no user identifiers, device identifiers, session identifiers, or advertising identifiers. All data is aggregated in memory and sent as pre-aggregated event batches. An optional coarse locale code is derived from the device's system locale setting (not from IP addresses). The SDKs are designed to make it extremely impractical to identify or track individual users.
- Zero cookies, zero localStorage, zero client-side persistence (web)
- Zero device filesystem writes (iOS, Android)
- No IDFA, GAID, SSAID, or any advertising identifiers
- IP addresses never read, parsed, or processed by application code
- Cardinality budgets prevent high-cardinality abuse
- PII auto-stripping removes emails, phone numbers, and SSNs from all event fields
Sign in to the dashboard for a full integration guide.
Go to Dashboard