sng-nexus
v1.0.12
Published
React Native client SDK for the Nexus event tracking platform. Collects events from mobile apps and sends them to the Nexus API with automatic offline queuing and retry.
Readme
sng-nexus
React Native client SDK for the Nexus event tracking platform. Collects events from mobile apps and sends them to the Nexus API with automatic offline queuing and retry.
Installation
pnpm add sng-nexus
# Peer dependencies (required)
pnpm add @react-native-async-storage/async-storage @react-native-community/netinfo
# Optional: iOS ad tracking status for Meta CAPI (not for Kids category apps)
pnpm add react-native-tracking-transparency
# Optional: iOS device model for Meta CAPI extinfo
pnpm add react-native-device-info
For iOS, run npx pod-install after installing.
Kids apps: Do not install
react-native-tracking-transparency. Apple rejects Kids category apps that use ATT. The SDK will automatically sendadvertiser_tracking_enabled: 0when the package is not installed.
Optional dependencies
| Package | Purpose | Fallback |
|---|---|---|
| react-native-tracking-transparency | iOS ATT status for advertiser_tracking_enabled | Defaults to 0 |
| react-native-device-info | iOS device model (e.g., iPhone15,2), app package/version | Empty string (Android uses built-in PlatformConstants.Model) |
Google Play Install Referrer (Android)
The SDK auto-detects a native module named RNInstallReferrer to fetch UTM attribution from the Google Play Install Referrer API. No third-party npm package is needed — provide the native module via an Expo config plugin (see Expo Config Plugin below) or a custom native module. If no native module is found, the SDK silently skips referrer fetching. You can also call NexusClient.setInstallReferrer(referrerString) manually.
Quick Start
import { NexusClient, NexusEvents } from 'sng-nexus';
// Initialize once at app startup
await NexusClient.init({
endpoint: 'https://nexus.yourserver.com',
apiKey: 'myapp:your-secret',
debug: __DEV__,
});
// Identify user after login
NexusClient.identify({ userId: 'u_123' });
// Track events
NexusClient.track(NexusEvents.PURCHASE, {
value: 4.99,
currency: 'USD',
email: '[email protected]',
});
// Reset identity on logout
NexusClient.resetIdentity();
// Force flush before app backgrounds
await NexusClient.flush();
// Shut down (cleanup timers and listeners)
await NexusClient.shutdown();API
NexusClient.init(config)
Initializes the SDK. Must be called before any other method. Loads any persisted events from a previous session and starts the flush timer and network listener. On first-ever launch, automatically sends an install event (used by Reddit App Install campaigns).
Config options:
| Option | Type | Default | Description |
|---|---|---|---|
| endpoint | string | required | Nexus server base URL (e.g., https://nexus.example.com) |
| apiKey | string | required | API key in appname:secret format |
| flushInterval | number | 30 | Seconds between automatic flush attempts |
| maxQueueSize | number | 1000 | Max events stored in offline queue. Oldest dropped when exceeded |
| maxRetries | number | 5 | Max retry attempts per event before it's dropped |
| debug | boolean | false | Log SDK activity to console |
NexusClient.identify(identity)
Sets the user identity. Persisted in memory and attached to all subsequent track() calls.
NexusClient.identify({ userId: 'u_123' });
NexusClient.identify({ anonymousId: 'anon_456' });
NexusClient.identify({ userId: 'u_123', anonymousId: 'anon_456' });NexusEvents
Standard event name constants mapped to both Meta and Reddit CAPI. Use these for type safety and correct platform mapping:
import { NexusClient, NexusEvents } from 'sng-nexus';
NexusClient.track(NexusEvents.PURCHASE, { value: 4.99, currency: 'USD' });
NexusClient.track(NexusEvents.SIGN_UP, { email: '[email protected]' });| Constant | Value | Meta | Reddit |
|---|---|---|---|
| INSTALL | install | install (custom) | INSTALL |
| PURCHASE | purchase | Purchase | PURCHASE |
| LEAD | lead | Lead | LEAD |
| SIGN_UP | sign_up | CompleteRegistration | SIGN_UP |
| ADD_TO_CART | add_to_cart | AddToCart | ADD_TO_CART |
| VIEW_CONTENT | view_content | ViewContent | VIEW_CONTENT |
| SEARCH | search | Search | SEARCH |
| ADD_TO_WISHLIST | add_to_wishlist | AddToWishlist | ADD_TO_WISHLIST |
| PAGE_VISIT | page_visit | PageView | PAGE_VISIT |
| GAME_ENDED | game_ended | game_ended (custom) | CUSTOM |
Custom event names are also supported — they pass through as-is to Meta and map to CUSTOM on Reddit.
NexusClient.track(eventName, properties?, context?)
Queues an event for delivery. Returns immediately (fire-and-forget).
eventName— required, 1-256 characters (useNexusEvents.*constants or custom strings)properties— optional key-value data (e.g.,{ value: 4.99, currency: 'USD' })context— optional event context (e.g.,{ locale: 'en_US' })
The SDK automatically adds:
timestamp— current Unix time in secondscontext.platform—'ios'or'android'viaPlatform.OScontext.os_version— OS version (e.g.,"18.0")context.device_model— device model (requiresreact-native-device-infoon iOS)context.device_manufacturer— device manufacturer (e.g.,"Samsung","Apple")context.locale— device locale (e.g.,"en_US")context.screen_width/context.screen_height— screen dimensions in pointscontext.screen_density— pixel ratio (e.g.,"3.00")context.app_package— bundle ID / package name (requiresreact-native-device-info)context.app_version— app version string (requiresreact-native-device-info)user_id/anonymous_id— from the current identity (persisted across sessions)properties.advertiser_tracking_enabled—0or1based on iOS ATT status (always1on Android, defaults to0ifreact-native-tracking-transparencyis not installed)properties.utm_source/properties.utm_medium/properties.utm_campaign— from Google Play Install Referrer (Android only)properties.gclid— Google Ads click ID from Install Referrerproperties.rdt_cid— Reddit click ID from Install Referrer
The server further enriches with event_id, context.ip, and context.user_agent. The worker maps device context fields to Meta's extinfo array format.
Destination-specific properties
To trigger conversions in Meta or Reddit, include these in properties:
| Property | Type | Purpose |
|---|---|---|
| value | number | Conversion value |
| currency | string | ISO currency code (e.g., USD) |
| email | string | User email (sent raw; server hashes for Meta/Reddit) |
| phone | string | User phone (sent raw; server hashes for Meta/Reddit) |
| rdt_cid / click_id | string | Reddit click ID for CAPI attribution |
| fbc | string | Meta click ID |
| fbp | string | Meta browser ID |
NexusClient.handleDeepLink(url)
Extracts fbclid from a deep link URL and constructs a Meta Click ID (fbc) in the format fb.1.{timestamp}.{fbclid}. The fbc value is persisted to AsyncStorage and automatically attached to all subsequent track() calls. Works on both iOS and Android.
Call this when the app is opened via a deep link (e.g., from a Facebook ad click):
import { Linking } from 'react-native';
import { NexusClient } from 'sng-nexus';
// Handle deep link that launched the app
const url = await Linking.getInitialURL();
if (url) NexusClient.handleDeepLink(url);
// Handle deep links while the app is running
Linking.addEventListener('url', ({ url }) => {
NexusClient.handleDeepLink(url);
});If the URL does not contain an fbclid parameter, the call is a no-op.
NexusClient.setInstallReferrer(referrer)
Manually set the Google Play Install Referrer string. Parses and persists utm_source, utm_medium, utm_campaign, gclid, rdt_cid, and fbclid. All subsequent track() calls automatically include these as properties.
Use this if you fetch the referrer from your own native code instead of relying on the SDK's automatic RNInstallReferrer native module detection.
// Example: passing referrer from a native Android module
const referrer = await MyNativeModule.getInstallReferrer();
await NexusClient.setInstallReferrer(referrer);
// referrer format: "utm_source=google&utm_medium=cpc&utm_campaign=spring&gclid=abc123"NexusClient.isFromAd()
Returns true if the install was attributed to a paid ad. Checks (in order):
fbcpresent (Meta deep link click)gclidpresent (Google Ads click from Install Referrer)utm_mediumiscpcorcpmutm_sourcematches a known ad platform (google,facebook,fb,instagram,ig,meta,reddit,tiktok,tiktok_int)
if (NexusClient.isFromAd()) {
// User arrived via paid campaign — show different onboarding, etc.
}Google Play Install Referrer
On Android, the SDK automatically captures attribution data from the Google Play Install Referrer API when a native module named RNInstallReferrer is available. This provides:
| Parameter | Description | Example |
|---|---|---|
| utm_source | Campaign source | google, facebook |
| utm_medium | Campaign medium | cpc, cpm, organic |
| utm_campaign | Campaign name | spring_sale_2024 |
| gclid | Google Ads click ID | EAIaIQ... |
| rdt_cid | Reddit click ID | abc123... |
| fbclid | Facebook click ID (converted to fbc) | IwAR3... |
These values are fetched once on first launch, persisted to AsyncStorage, and automatically injected into every track() call as event properties. The install event is deferred until the referrer is fetched so it includes full attribution data.
The SDK looks for a native module named RNInstallReferrer with a getReferrer() method that returns { installReferrer: string }. Provide this via an Expo config plugin (see below) or a custom native module.
Alternative (manual): Call NexusClient.setInstallReferrer(referrerString) from your own native code.
Expo Config Plugin for Install Referrer
Create a config plugin that embeds the native module directly in your app — no third-party npm package needed:
// plugins/withInstallReferrer.js
const { withAppBuildGradle, withDangerousMod, withMainApplication } = require('expo/config-plugins');
const fs = require('fs');
const path = require('path');
// Adjust the package name to match your app
const JAVA_PACKAGE = 'com.yourapp.installreferrer';
const JAVA_DIR = `app/src/main/java/${JAVA_PACKAGE.replace(/\./g, '/')}`;
const MODULE_JAVA = `
package ${JAVA_PACKAGE};
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.bridge.ReactContextBaseJavaModule;
import com.facebook.react.bridge.ReactMethod;
import com.facebook.react.bridge.Promise;
import com.facebook.react.bridge.WritableMap;
import com.facebook.react.bridge.WritableNativeMap;
import android.os.RemoteException;
import com.android.installreferrer.api.InstallReferrerClient;
import com.android.installreferrer.api.InstallReferrerStateListener;
import com.android.installreferrer.api.ReferrerDetails;
public class RNInstallReferrerModule extends ReactContextBaseJavaModule {
private final ReactApplicationContext reactContext;
public RNInstallReferrerModule(ReactApplicationContext reactContext) {
super(reactContext);
this.reactContext = reactContext;
}
@Override public String getName() { return "RNInstallReferrer"; }
@ReactMethod
public void getReferrer(final Promise promise) {
InstallReferrerClient client = InstallReferrerClient.newBuilder(reactContext).build();
client.startConnection(new InstallReferrerStateListener() {
@Override public void onInstallReferrerSetupFinished(int responseCode) {
WritableMap result = new WritableNativeMap();
if (responseCode == InstallReferrerClient.InstallReferrerResponse.OK) {
try {
ReferrerDetails d = client.getInstallReferrer();
result.putString("installReferrer", d.getInstallReferrer());
result.putString("installTimestamp", String.valueOf(d.getInstallBeginTimestampSeconds()));
result.putString("clickTimestamp", String.valueOf(d.getReferrerClickTimestampSeconds()));
client.endConnection();
} catch (RemoteException e) { result.putString("error", e.getMessage()); }
} else {
result.putString("message", "RESPONSE_CODE_" + responseCode);
}
promise.resolve(result);
}
@Override public void onInstallReferrerServiceDisconnected() {}
});
}
}
`.trim();
const PACKAGE_JAVA = `
package ${JAVA_PACKAGE};
import com.facebook.react.ReactPackage;
import com.facebook.react.bridge.NativeModule;
import com.facebook.react.bridge.ReactApplicationContext;
import com.facebook.react.uimanager.ViewManager;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
public class RNInstallReferrerPackage implements ReactPackage {
@Override public List<NativeModule> createNativeModules(ReactApplicationContext ctx) {
List<NativeModule> m = new ArrayList<>();
m.add(new RNInstallReferrerModule(ctx));
return m;
}
@Override public List<ViewManager> createViewManagers(ReactApplicationContext ctx) {
return Collections.emptyList();
}
}
`.trim();
function withInstallReferrer(config) {
// Write Java source files
config = withDangerousMod(config, ['android', (config) => {
const dir = path.join(config.modRequest.platformProjectRoot, JAVA_DIR);
fs.mkdirSync(dir, { recursive: true });
fs.writeFileSync(path.join(dir, 'RNInstallReferrerModule.java'), MODULE_JAVA + '\\n');
fs.writeFileSync(path.join(dir, 'RNInstallReferrerPackage.java'), PACKAGE_JAVA + '\\n');
return config;
}]);
// Add gradle dependency
config = withAppBuildGradle(config, (config) => {
let c = config.modResults.contents;
if (!c.includes('installreferrer')) {
c = c.replace(/dependencies\\s*\\{/, "dependencies {\\n implementation 'com.android.installreferrer:installreferrer:2.2'");
}
config.modResults.contents = c;
return config;
});
// Register in MainApplication (Kotlin — Expo SDK 55+)
config = withMainApplication(config, (config) => {
let c = config.modResults.contents;
if (!c.includes('RNInstallReferrerPackage')) {
c = c.replace(/^(package .+)$/m, \`$1\\n\\nimport ${JAVA_PACKAGE}.RNInstallReferrerPackage\`);
c = c.replace(/(PackageList\\(this\\)\\.packages\\.apply\\s*\\{)/, '$1\\n add(RNInstallReferrerPackage())');
}
config.modResults.contents = c;
return config;
});
// ProGuard keep rule
config = withDangerousMod(config, ['android', (config) => {
const p = path.join(config.modRequest.platformProjectRoot, 'app', 'proguard-rules.pro');
let c = fs.readFileSync(p, 'utf-8');
if (!c.includes('installreferrer')) {
c += '\\n# Google Play Install Referrer API\\n-keep public class com.android.installreferrer.** { *; }\\n';
fs.writeFileSync(p, c);
}
return config;
}]);
return config;
}
module.exports = withInstallReferrer;Register in app.json:
{
"expo": {
"plugins": ["./plugins/withInstallReferrer"]
}
}Bare React Native (no Expo): Copy the Java files into your project manually, add
implementation 'com.android.installreferrer:installreferrer:2.2'toapp/build.gradle, registerRNInstallReferrerPackageinMainApplication, and add the ProGuard keep rule.
refreshATTStatus()
Re-reads the iOS ATT tracking status. Call this after presenting the ATT prompt so subsequent track() calls reflect the user's choice. On Android this is a no-op.
import { requestTrackingPermission } from 'react-native-tracking-transparency';
import { refreshATTStatus } from 'sng-nexus';
const status = await requestTrackingPermission();
await refreshATTStatus();NexusClient.flush()
Forces an immediate flush of all queued events. Returns a promise that resolves when the flush cycle completes. Skips silently if offline or already flushing.
NexusClient.resetIdentity()
Clears the current user identity. Call on logout.
NexusClient.shutdown()
Stops the flush timer and network listener, persists the queue, and marks the client as uninitialized. Call when the app is terminating.
Offline Queue & Retry Behavior
Events are persisted to AsyncStorage under the key @nexus:event_queue. This means events survive app kills, crashes, and reboots. The install event flag is stored under @nexus:install_sent to ensure it fires only once per device.
Flush triggers
A flush is attempted when:
- The flush timer fires (every
flushIntervalseconds) - The device transitions from offline to online
flush()is called manually- The queue reaches 10 events (batch threshold)
Retry strategy
| Scenario | Behavior |
|---|---|
| Network offline | Events stay in queue, flushed on reconnect |
| Server 5xx | Retry with exponential backoff (1s, 2s, 4s, 8s, 16s... capped at 30s) + jitter |
| Server 4xx (validation/auth error) | Event dropped immediately, no retry |
| App killed | Events persist in AsyncStorage, flushed on next init() |
| Max retries exceeded | Event dropped from queue |
| Queue full | Oldest events dropped to make room for new ones |
Backoff formula
delay = min(1000ms * 2^attempt, 30000ms) + random jitter (0-25%)Architecture
src/
├── index.ts — Public exports
├── client.ts — NexusClient singleton (init, track, identify, flush, shutdown, isFromAd)
├── types.ts — TypeScript interfaces (NexusConfig, TrackPayload, etc.)
├── queue.ts — AsyncStorage-backed persistent event queue
├── network.ts — NetInfo connectivity monitor with reconnect callback
├── retry.ts — Exponential backoff delay calculator
├── att.ts — iOS ATT status reader (optional react-native-tracking-transparency)
├── device.ts — Device info collector for Meta CAPI extinfo (optional react-native-device-info)
└── referrer.ts — Google Play Install Referrer parser + UTM attribution (via RNInstallReferrer native module)Data flow
track() → EventQueue (in-memory + AsyncStorage)
↓ flush trigger
fetch POST /api/track
↓ success
remove from queue
↓ 5xx failure
increment retry, keep in queue
↓ 4xx failure
drop from queue (bad request)Integration Example
App.tsx (Expo / bare RN)
import { useEffect } from 'react';
import { AppState, Linking } from 'react-native';
import { NexusClient } from 'sng-nexus';
export default function App() {
useEffect(() => {
async function bootstrap() {
await NexusClient.init({
endpoint: 'https://nexus.yourserver.com',
apiKey: 'myapp:your-secret',
debug: __DEV__,
});
// Capture fbc from the deep link that launched the app
const initialUrl = await Linking.getInitialURL();
if (initialUrl) NexusClient.handleDeepLink(initialUrl);
}
bootstrap();
// Capture fbc from deep links while the app is running
const linkSub = Linking.addEventListener('url', ({ url }) => {
NexusClient.handleDeepLink(url);
});
const appStateSub = AppState.addEventListener('change', (state) => {
if (state === 'background') {
NexusClient.flush();
}
});
return () => {
linkSub.remove();
appStateSub.remove();
NexusClient.shutdown();
};
}, []);
return <MainNavigator />;
}After login
NexusClient.identify({ userId: user.id });Tracking events
import { NexusClient, NexusEvents } from 'sng-nexus';
// Standard event with Meta/Reddit conversion data
NexusClient.track(NexusEvents.PURCHASE, {
value: 4.99,
currency: 'USD',
email: user.email,
});
// Custom event (passes as-is to Meta, CUSTOM on Reddit)
NexusClient.track('level_complete', { level: 5, score: 100 });On logout
NexusClient.resetIdentity();Development
# From the monorepo root
pnpm --filter sng-nexus build # Compile TypeScript
pnpm --filter sng-nexus clean # Remove dist/The package uses the monorepo's shared tsconfig.base.json and builds with tsc to dist/.
