DocsRecipes

Recipes

Native mobile instrumentation

5 min readReviewed July 2026

Mobile applications cannot keep a GraphJSON workspace API key secret. A user can inspect the binary, device storage, and network traffic.

Every mobile implementation should send a small public event envelope to an endpoint you control:

iOS, Android, or React Native app
        │
        │ public first-party event contract
        ▼
your mobile collection endpoint
  ├─ authenticate or rate-limit
  ├─ allowlist events and properties
  ├─ derive trusted account fields
  ├─ attach the GraphJSON API key
  └─ deliver to GraphJSON

The examples below are reference patterns, not official GraphJSON SDKs. Adapt persistence, background execution, and consent behavior to the supported operating-system versions in your application.

Use one portable envelope#

{
  "event": "report_exported",
  "event_id": "evt_01J...",
  "occurred_at": 1785081600,
  "anonymous_id": "anon_01J...",
  "user_id": "usr_42",
  "session_id": "ses_01J...",
  "app_version": "4.8.0",
  "build_number": "842",
  "platform": "ios",
  "schema_version": 1,
  "properties": {
    "report_type": "retention",
    "row_count": 18420
  }
}

Rules:

  • event_id is created once and survives retries
  • occurred_at is whole Unix seconds from the device occurrence time
  • IDs are opaque and contain no email address
  • event names and properties are allowlisted
  • app version and build are attached centrally
  • the queue stores the original envelope

Your server converts the envelope into the GraphJSON logging request and uses occurred_at as the request timestamp.

Classify events by durability#

Class Examples Delivery policy
Critical analytical fact Purchase confirmed, subscription changed Server-originated where possible; durable ledger or outbox
Important product event Onboarding completed, report exported Durable local queue, bounded retry
Best-effort interaction Screen viewed, tooltip opened Bounded queue; may expire deliberately
Diagnostic Queue dropped events, sync failed Separate controlled event or operational log

A client-side tap is not proof that a payment, upload, or subscription succeeded. Emit authoritative facts from the server that completed them.

Persist before sending#

A durable queue record should contain:

event_id
serialized envelope
created_at
attempt_count
next_attempt_at
expires_at
last_error_class

Required operations:

enqueue atomically
claim a bounded batch
mark attempt
delete only after accepted
schedule retry
expire permitted low-value events
count drops

Do not use an unbounded array in preferences storage. Use the platform database already approved by the application, such as SQLite or an equivalent durable store.

iOS transport pattern#

import Foundation

struct AnalyticsEnvelope: Codable {
    let event: String
    let eventId: String
    let occurredAt: Int
    let anonymousId: String
    let userId: String?
    let sessionId: String
    let appVersion: String
    let buildNumber: String
    let platform: String
    let schemaVersion: Int
    let properties: [String: JSONValue]

    enum CodingKeys: String, CodingKey {
        case event
        case eventId = "event_id"
        case occurredAt = "occurred_at"
        case anonymousId = "anonymous_id"
        case userId = "user_id"
        case sessionId = "session_id"
        case appVersion = "app_version"
        case buildNumber = "build_number"
        case platform
        case schemaVersion = "schema_version"
        case properties
    }
}

enum JSONValue: Codable {
    case string(String)
    case number(Double)
    case bool(Bool)

    init(from decoder: Decoder) throws {
        let container = try decoder.singleValueContainer()
        if let value = try? container.decode(Bool.self) {
            self = .bool(value)
        } else if let value = try? container.decode(Double.self) {
            self = .number(value)
        } else {
            self = .string(try container.decode(String.self))
        }
    }

    func encode(to encoder: Encoder) throws {
        var container = encoder.singleValueContainer()
        switch self {
        case .string(let value): try container.encode(value)
        case .number(let value): try container.encode(value)
        case .bool(let value): try container.encode(value)
        }
    }
}

Transport one claimed batch to your endpoint:

func sendBatch(_ events: [AnalyticsEnvelope]) async throws {
    var request = URLRequest(
        url: URL(string: "https://app.example.com/api/mobile-events")!
    )
    request.httpMethod = "POST"
    request.setValue("application/json", forHTTPHeaderField: "Content-Type")
    request.httpBody = try JSONEncoder().encode(["events": events])

    let (_, response) = try await URLSession.shared.data(for: request)
    guard let http = response as? HTTPURLResponse,
          (200..<300).contains(http.statusCode) else {
        throw AnalyticsError.deliveryRejected
    }
}

Delete queue records only after the endpoint returns success. Use an OS-supported background task to request flush time, but assume the operating system can suspend or terminate the app before it runs.

Do not wait for analytics delivery before completing navigation.

Android transport pattern#

Define the envelope:

@kotlinx.serialization.Serializable
data class AnalyticsEnvelope(
    val event: String,
    @kotlinx.serialization.SerialName("event_id")
    val eventId: String,
    @kotlinx.serialization.SerialName("occurred_at")
    val occurredAt: Long,
    @kotlinx.serialization.SerialName("anonymous_id")
    val anonymousId: String,
    @kotlinx.serialization.SerialName("user_id")
    val userId: String? = null,
    @kotlinx.serialization.SerialName("session_id")
    val sessionId: String,
    @kotlinx.serialization.SerialName("app_version")
    val appVersion: String,
    @kotlinx.serialization.SerialName("build_number")
    val buildNumber: String,
    val platform: String = "android",
    @kotlinx.serialization.SerialName("schema_version")
    val schemaVersion: Int = 1,
    val properties: Map<String, String>
)

Use a durable database table with a unique index on event_id. Schedule a constrained background worker:

val request = OneTimeWorkRequestBuilder<AnalyticsFlushWorker>()
    .setConstraints(
        Constraints.Builder()
            .setRequiredNetworkType(NetworkType.CONNECTED)
            .build()
    )
    .setBackoffCriteria(
        BackoffPolicy.EXPONENTIAL,
        30,
        TimeUnit.SECONDS
    )
    .build()

WorkManager.getInstance(context)
    .enqueueUniqueWork(
        "analytics-flush",
        ExistingWorkPolicy.KEEP,
        request
    )

The worker:

  1. claims a bounded batch
  2. posts it to the first-party endpoint
  3. deletes accepted rows
  4. returns Result.retry() for transient failures
  5. quarantines permanent validation failures

Do not schedule one background job per event.

React Native transport pattern#

Create the envelope centrally:

type AnalyticsEnvelope = {
  event: string;
  event_id: string;
  occurred_at: number;
  anonymous_id: string;
  user_id?: string;
  session_id: string;
  app_version: string;
  build_number: string;
  platform: "ios" | "android";
  schema_version: 1;
  properties: Record<string, string | number | boolean>;
};

function createEvent(
  event: string,
  properties: AnalyticsEnvelope["properties"]
): AnalyticsEnvelope {
  return {
    event,
    event_id: createUuid(),
    occurred_at: Math.floor(Date.now() / 1000),
    anonymous_id: getAnonymousId(),
    user_id: getSignedInUserId(),
    session_id: getSessionId(),
    app_version: getAppVersion(),
    build_number: getBuildNumber(),
    platform: Platform.OS,
    schema_version: 1,
    properties
  };
}

In this example, createUuid() comes from your app's approved cryptographically secure UUID library. Do not generate event IDs with a timestamp or an incrementing counter.

Persist the queue through an approved durable storage library. Protect updates with a single queue module so simultaneous app events do not overwrite each other.

Flush:

async function deliver(events: AnalyticsEnvelope[]) {
  const response = await fetch(
    "https://app.example.com/api/mobile-events",
    {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ events })
    }
  );

  if (!response.ok) {
    throw new Error(`analytics delivery failed: ${response.status}`);
  }
}

An AppState transition can request a flush, but it does not guarantee completion. Persist first.

Build the first-party endpoint#

The endpoint accepts the portable envelope, not arbitrary GraphJSON requests:

const allowedEvents = new Set([
  "screen_viewed",
  "onboarding_completed",
  "report_exported"
]);

function validate(input: any) {
  if (!allowedEvents.has(input.event)) {
    throw new Error("unsupported event");
  }
  if (typeof input.event_id !== "string" || input.event_id.length > 100) {
    throw new Error("invalid event_id");
  }
  if (!Number.isInteger(input.occurred_at)) {
    throw new Error("invalid occurred_at");
  }
}

The server should:

  • cap body and batch size
  • authenticate signed-in users where possible
  • rate-limit anonymous devices
  • derive trusted user_id and account_id
  • overwrite platform fields that can be derived from trusted context
  • remove forbidden properties
  • send at most 50 events per GraphJSON bulk request
  • return one clear accepted/rejected result

Never accept a client-selected GraphJSON collection or API key.

Handle clock errors#

Device clocks can be wrong.

Record:

{
  "occurred_at": 1785081600,
  "client_sent_at": 1785081660
}

At the server, compare receipt time with client time. Define a safety policy for implausibly old or future timestamps:

  • reject
  • quarantine
  • or clamp with an explicit timestamp_corrected field

Do not silently rewrite every offline event to receipt time.

Centralize consent:

consent denied
  → do not enqueue
  → remove locally queued events no longer permitted
  → clear analytics identity where policy requires it

consent granted
  → begin new permitted collection

Do not collect first and decide whether to send later if policy forbids the collection itself.

Identity transitions#

Before sign-in:

{
  "anonymous_id": "anon_01J...",
  "user_id": null
}

At sign-in or signup:

{
  "event": "signup_completed",
  "anonymous_id": "anon_01J...",
  "user_id": "usr_42"
}

GraphJSON does not merge identities. Your event contract and SQL decide whether pre-login history joins to the authenticated person.

On sign-out, decide whether the anonymous ID is retained, rotated, or cleared. Make the rule consistent across platforms.

Release-aware analytics#

Attach:

  • app version
  • build number
  • platform
  • schema version
  • feature or experiment assignment where relevant

Mobile versions can remain active for months. Do not assume a schema migration reaches every device immediately.

Write queries that support the active schema window, and use Release impact analytics to compare behavior and failure rates by release.

Test matrix#

For every platform:

  • clean install
  • upgrade from the oldest supported version
  • offline launch and later reconnect
  • app backgrounded during delivery
  • process terminated with queued events
  • duplicate retry
  • expired low-value event
  • malformed property
  • consent denied, granted, and withdrawn
  • anonymous-to-authenticated transition
  • device clock far ahead and behind
  • server returns 400, 429, and 500

Verify:

  • same event_id after retry
  • original timestamp after offline delivery
  • no API key in binary or network request
  • no disallowed event enters the queue
  • accepted rows disappear from local storage
  • permanent failures do not retry forever

Launch checklist#

  • GraphJSON key exists only on the server.
  • Public event contract is allowlisted and versioned.
  • Important events enter durable local storage.
  • Queue is bounded by age and size.
  • Event IDs and timestamps survive retries.
  • Background flushing is opportunistic, not assumed.
  • Critical business facts are server-originated.
  • Consent controls collection and persistence.
  • Identity transitions are explicit.
  • Version and build are attached centrally.
  • All supported platforms pass the offline and upgrade matrix.

Continue with Collect browser and mobile events safely, Reliable event delivery, and Environments and instrumentation testing.

Need a hand?

Tell us what you’re building and we’ll point you in the right direction.

Contact support