@bananalytics/react-native 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md ADDED
@@ -0,0 +1,46 @@
1
+ # Changelog
2
+
3
+ ## 0.2.0
4
+
5
+ A bug-fix release, but the minor version moves because two of the fixes change
6
+ what the SDK sends. Upgrading is strongly recommended: 0.1.x can lose events.
7
+
8
+ ### Fixed
9
+
10
+ - **A long offline queue was discarded instead of sent.** `flush()` put the
11
+ whole queue in one request. The queue holds up to 1000 events by default and
12
+ the server accepts 500, rejecting anything larger with a `400` — which is
13
+ treated as non-retryable and dropped. A device offline long enough to queue
14
+ more than 500 events lost all of them on reconnect, silently, and could not
15
+ recover: the persisted queue reloaded on restart and failed the same way.
16
+ Events are now sent in chunks of 500, oldest first and sequentially, so their
17
+ order survives. A failing chunk re-queues from itself onward.
18
+
19
+ - **Events the server rejected were reported as success.** A `200` does not mean
20
+ every event was stored — each one is validated and the rest are named in the
21
+ response body. That body was discarded, so an app could lose part of one event
22
+ type to a malformed name with nothing anywhere saying so. Rejections are now
23
+ logged with the server's reason.
24
+
25
+ - **No event was recorded when the app cold-started.** Lifecycle tracking only
26
+ ever saw `AppState` transitions, which a fresh launch does not produce — so
27
+ the first event of a launch was whatever the person happened to tap, and the
28
+ top of every funnel was short by everyone who opened the app and left.
29
+
30
+ - **The SDK sent a request to Google on every flush.** Without
31
+ `@react-native-community/netinfo` installed, the connectivity check fetched a
32
+ Google endpoint — and returned "online" whether that succeeded or failed, so
33
+ it decided nothing. Removed; a failed send was already handled by the retry
34
+ and the queue.
35
+
36
+ ### Changed
37
+
38
+ - `$app_opened` is emitted once per launch when `trackAppLifecycle` is on. If
39
+ you count events per session, expect one more.
40
+ - `optOut()` now discards anything still queued, in memory and on disk. It
41
+ previously only stopped new collection, so already-collected events went out
42
+ on the next flush — which is exactly the data somebody opting out means.
43
+
44
+ ## 0.1.1
45
+
46
+ - Initial published release.
package/README.md CHANGED
@@ -60,7 +60,7 @@ interface BananalyticsConfig {
60
60
  maxQueueSize?: number; // Max events in memory (default: 1000)
61
61
  maxRetries?: number; // Retry attempts (default: 3)
62
62
  debug?: boolean; // Enable console logging (default: false)
63
- trackAppLifecycle?: boolean; // Auto-track foreground/background (default: true)
63
+ trackAppLifecycle?: boolean; // Auto-track app open + foreground/background (default: true)
64
64
  trackScreens?: boolean; // Auto-track screen views (default: false)
65
65
  sessionTimeout?: number; // Session timeout in ms (default: 1800000)
66
66
  }
@@ -73,19 +73,34 @@ interface BananalyticsConfig {
73
73
  | `Bananalytics.init(config)` | Initialize the SDK |
74
74
  | `Bananalytics.track(event, properties?)` | Track a custom event |
75
75
  | `Bananalytics.screen(name, properties?)` | Track a screen view |
76
- | `Bananalytics.identify(userId, traits?)` | Identify the current user |
76
+ | `Bananalytics.trackRevenue(amount, currency?, properties?, event?)` | Track a purchase. Adds `revenue` and `currency` to the properties, which the server reads into its own columns |
77
+ | `Bananalytics.identify(userId, traits?)` | Identify the current user. Events sent before this are attributed to the same person afterwards |
77
78
  | `Bananalytics.reset()` | Clear identity and generate new anonymous ID |
78
79
  | `Bananalytics.optIn()` | Resume tracking |
79
- | `Bananalytics.optOut()` | Stop all tracking |
80
+ | `Bananalytics.optOut()` | Stop tracking and discard anything still queued |
80
81
  | `Bananalytics.flush()` | Manually flush queued events |
81
82
 
83
+ Any event can carry revenue — adding `revenue` and `currency` to the properties
84
+ of your own purchase event does the same thing as `trackRevenue`.
85
+
86
+ ## Automatically captured
87
+
88
+ With `trackAppLifecycle` on (the default):
89
+
90
+ | Event | When |
91
+ |---|---|
92
+ | `$app_opened` | The app starts. A cold start is not a foreground transition, so it needs its own event |
93
+ | `$app_foreground` | The app returns from the background |
94
+ | `$app_background` | The app goes to the background. The queue is persisted and flushed here |
95
+ | `$session_start` / `$session_end` | A session begins or times out (`sessionTimeout`, default 30 min) |
96
+
82
97
  ## Features
83
98
 
84
- - Automatic event batching and flushing
85
- - Offline persistence with AsyncStorage
99
+ - Automatic event batching and flushing, split into requests the server accepts
100
+ - Offline persistence with AsyncStorage, order preserved across restarts
86
101
  - Exponential backoff retry on network failures
87
102
  - Session tracking with configurable timeout
88
- - Privacy controls (opt-in/opt-out)
103
+ - Privacy controls (opt-in/opt-out), and no requests to anyone but your server
89
104
  - Zero uncaught exceptions (host app stability is sacred)
90
105
 
91
106
  ## License
@@ -67,12 +67,13 @@ class SessionManager {
67
67
  }
68
68
  /** Ends the current session. */
69
69
  endSession() {
70
- if (this.session) {
71
- this.logger.debug('Session ended', this.session.id);
72
- if (this.onSessionEnd) {
73
- this.onSessionEnd(this.session);
74
- }
75
- this.session = null;
70
+ if (!this.session)
71
+ return;
72
+ const ended = this.session;
73
+ this.session = null;
74
+ this.logger.debug('Session ended', ended.id);
75
+ if (this.onSessionEnd) {
76
+ this.onSessionEnd(ended);
76
77
  }
77
78
  }
78
79
  startNewSession(timestamp) {
@@ -58,6 +58,24 @@ export declare class BananalyticsClient {
58
58
  * ```
59
59
  */
60
60
  screen(screenName: string, properties?: Properties): void;
61
+ /**
62
+ * Tracks a purchase or any other event that earned money.
63
+ *
64
+ * The server reads `revenue` and `currency` out of the properties of *any*
65
+ * event, so an app that already tracks its own purchase event can simply add
66
+ * those two properties instead of switching to this method.
67
+ *
68
+ * @param amount - The monetary value. Negative amounts record refunds.
69
+ * @param currency - ISO 4217 code, e.g. 'USD' or 'EUR'
70
+ * @param properties - Optional extra properties, such as the product ID
71
+ * @param eventName - Event name to record it under
72
+ *
73
+ * @example
74
+ * ```ts
75
+ * client.trackRevenue(9.99, 'EUR', { product_id: 'pro_monthly' });
76
+ * ```
77
+ */
78
+ trackRevenue(amount: number, currency?: string, properties?: Properties, eventName?: string): void;
61
79
  /**
62
80
  * Identifies the current user.
63
81
  *
@@ -85,6 +103,11 @@ export declare class BananalyticsClient {
85
103
  optIn(): void;
86
104
  /**
87
105
  * Opts the user out of analytics tracking. Stops all event collection.
106
+ *
107
+ * Anything already queued is discarded rather than sent. Somebody opting out
108
+ * means the data collected up to that moment too — it is still sitting on
109
+ * their device, and a queue that keeps draining afterwards would deliver
110
+ * exactly what they asked not to be delivered.
88
111
  */
89
112
  optOut(): void;
90
113
  /**
@@ -83,6 +83,17 @@ class BananalyticsClient {
83
83
  }
84
84
  this.batcher.start();
85
85
  this.initialized = true;
86
+ // A cold start is not an AppState transition, so the lifecycle tracker
87
+ // never sees it — it only ever reports coming *back* from the background.
88
+ // Without this the first event of a fresh launch is whatever the person
89
+ // happened to tap, and the top of every funnel is short by everyone who
90
+ // opened the app and left again.
91
+ //
92
+ // Fired after identity and session have been restored, so it carries the
93
+ // same anonymous ID as the rest of the launch rather than a fresh one.
94
+ if (this.config.trackAppLifecycle) {
95
+ this.track('$app_opened');
96
+ }
86
97
  this.logger.debug('Bananalytics SDK initialized');
87
98
  }
88
99
  catch (err) {
@@ -135,6 +146,30 @@ class BananalyticsClient {
135
146
  this.logger.error('Failed to track screen', err);
136
147
  }
137
148
  }
149
+ /**
150
+ * Tracks a purchase or any other event that earned money.
151
+ *
152
+ * The server reads `revenue` and `currency` out of the properties of *any*
153
+ * event, so an app that already tracks its own purchase event can simply add
154
+ * those two properties instead of switching to this method.
155
+ *
156
+ * @param amount - The monetary value. Negative amounts record refunds.
157
+ * @param currency - ISO 4217 code, e.g. 'USD' or 'EUR'
158
+ * @param properties - Optional extra properties, such as the product ID
159
+ * @param eventName - Event name to record it under
160
+ *
161
+ * @example
162
+ * ```ts
163
+ * client.trackRevenue(9.99, 'EUR', { product_id: 'pro_monthly' });
164
+ * ```
165
+ */
166
+ trackRevenue(amount, currency = 'USD', properties, eventName = '$purchase') {
167
+ if (!Number.isFinite(amount)) {
168
+ this.logger.error('Revenue amount must be a finite number', amount);
169
+ return;
170
+ }
171
+ this.track(eventName, { ...properties, revenue: amount, currency });
172
+ }
138
173
  /**
139
174
  * Identifies the current user.
140
175
  *
@@ -193,11 +228,20 @@ class BananalyticsClient {
193
228
  }
194
229
  /**
195
230
  * Opts the user out of analytics tracking. Stops all event collection.
231
+ *
232
+ * Anything already queued is discarded rather than sent. Somebody opting out
233
+ * means the data collected up to that moment too — it is still sitting on
234
+ * their device, and a queue that keeps draining afterwards would deliver
235
+ * exactly what they asked not to be delivered.
196
236
  */
197
237
  optOut() {
198
238
  this.consent.optOut().catch((err) => {
199
239
  this.logger.error('Failed to opt out', err);
200
240
  });
241
+ this.queue.clear();
242
+ this.persister.clearQueue().catch((err) => {
243
+ this.logger.error('Failed to clear persisted queue on opt-out', err);
244
+ });
201
245
  }
202
246
  /**
203
247
  * Manually flushes all queued events to the backend.
package/dist/index.d.ts CHANGED
@@ -43,6 +43,20 @@ export declare const Bananalytics: {
43
43
  * @param properties - Optional screen properties
44
44
  */
45
45
  screen(screenName: string, properties?: Properties): void;
46
+ /**
47
+ * Tracks a purchase or any other event that earned money.
48
+ *
49
+ * @param amount - The monetary value. Negative amounts record refunds.
50
+ * @param currency - ISO 4217 code, e.g. 'USD' or 'EUR'
51
+ * @param properties - Optional extra properties, such as the product ID
52
+ * @param eventName - Event name to record it under
53
+ *
54
+ * @example
55
+ * ```ts
56
+ * Bananalytics.trackRevenue(9.99, 'EUR', { product_id: 'pro_monthly' });
57
+ * ```
58
+ */
59
+ trackRevenue(amount: number, currency?: string, properties?: Properties, eventName?: string): void;
46
60
  /**
47
61
  * Identifies the current user.
48
62
  *
package/dist/index.js CHANGED
@@ -69,6 +69,22 @@ exports.Bananalytics = {
69
69
  screen(screenName, properties) {
70
70
  instance?.screen(screenName, properties);
71
71
  },
72
+ /**
73
+ * Tracks a purchase or any other event that earned money.
74
+ *
75
+ * @param amount - The monetary value. Negative amounts record refunds.
76
+ * @param currency - ISO 4217 code, e.g. 'USD' or 'EUR'
77
+ * @param properties - Optional extra properties, such as the product ID
78
+ * @param eventName - Event name to record it under
79
+ *
80
+ * @example
81
+ * ```ts
82
+ * Bananalytics.trackRevenue(9.99, 'EUR', { product_id: 'pro_monthly' });
83
+ * ```
84
+ */
85
+ trackRevenue(amount, currency, properties, eventName) {
86
+ instance?.trackRevenue(amount, currency, properties, eventName);
87
+ },
72
88
  /**
73
89
  * Identifies the current user.
74
90
  *
@@ -3,6 +3,16 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.Batcher = void 0;
4
4
  const network_1 = require("../utils/network");
5
5
  const retry_1 = require("./retry");
6
+ /**
7
+ * Most events the server accepts in a single request.
8
+ *
9
+ * Mirrors `domain.MaxBatchSize`. Going over it does not get the batch trimmed,
10
+ * it gets the whole request rejected with a 400 — and a 400 is deliberately not
11
+ * retried, so an oversized flush does not fail, it deletes. The default queue
12
+ * holds up to 1000 events, which a device offline for an afternoon reaches
13
+ * easily: precisely the case the queue exists for.
14
+ */
15
+ const MAX_BATCH_SIZE = 500;
6
16
  /**
7
17
  * Manages automatic batching and flushing of events.
8
18
  * Flushes on a timer interval or when the queue reaches the threshold.
@@ -76,12 +86,24 @@ class Batcher {
76
86
  this.flushing = true;
77
87
  const events = this.queue.flush();
78
88
  try {
79
- await (0, retry_1.withRetry)(() => this.transport.send(events), this.maxRetries, this.logger);
80
- this.logger.debug(`Flushed ${events.length} events`);
81
- }
82
- catch (err) {
83
- this.logger.error('Flush failed after retries, re-queueing events', err);
84
- this.queue.unshift(events);
89
+ // Oldest first, one request at a time. Sending them in parallel would be
90
+ // faster and would also let a later batch land before an earlier one,
91
+ // which is the ordering the persisted queue exists to preserve.
92
+ for (let sent = 0; sent < events.length; sent += MAX_BATCH_SIZE) {
93
+ const chunk = events.slice(sent, sent + MAX_BATCH_SIZE);
94
+ try {
95
+ await (0, retry_1.withRetry)(() => this.transport.send(chunk), this.maxRetries, this.logger);
96
+ this.logger.debug(`Flushed ${chunk.length} events`);
97
+ }
98
+ catch (err) {
99
+ // Put back this chunk and everything after it, then stop: whatever
100
+ // stopped this request will stop the next one too, and the events
101
+ // already accepted must not be sent twice.
102
+ this.logger.error('Flush failed after retries, re-queueing events', err);
103
+ this.queue.unshift(events.slice(sent));
104
+ return;
105
+ }
106
+ }
85
107
  }
86
108
  finally {
87
109
  this.flushing = false;
@@ -20,4 +20,12 @@ export declare class Transport {
20
20
  * ```
21
21
  */
22
22
  send(events: EventPayload[]): Promise<void>;
23
+ /**
24
+ * Warns about events the server accepted the request for but did not store.
25
+ *
26
+ * Deliberately never throws: the request succeeded, the good events are
27
+ * saved, and re-sending the batch would only duplicate them. An unreadable
28
+ * body is not worth turning a successful flush into a failure either.
29
+ */
30
+ private reportRejected;
23
31
  }
@@ -38,7 +38,12 @@ class Transport {
38
38
  if (!response.ok) {
39
39
  throw new errors_1.NetworkError(`Ingestion failed with status ${response.status}`, response.status);
40
40
  }
41
- this.logger.debug(`Successfully sent ${events.length} events`);
41
+ // A 200 does not mean every event was stored. The server validates each
42
+ // one and keeps the good ones, reporting the rest in the body — usually a
43
+ // malformed event name or a property it could not accept. Dropping that
44
+ // detail on the floor is how an app ends up missing a third of one event
45
+ // type with nothing anywhere saying so.
46
+ await this.reportRejected(response, events.length);
42
47
  }
43
48
  catch (err) {
44
49
  if (err instanceof errors_1.NetworkError) {
@@ -47,5 +52,26 @@ class Transport {
47
52
  throw new errors_1.NetworkError(`Network request failed: ${err instanceof Error ? err.message : String(err)}`);
48
53
  }
49
54
  }
55
+ /**
56
+ * Warns about events the server accepted the request for but did not store.
57
+ *
58
+ * Deliberately never throws: the request succeeded, the good events are
59
+ * saved, and re-sending the batch would only duplicate them. An unreadable
60
+ * body is not worth turning a successful flush into a failure either.
61
+ */
62
+ async reportRejected(response, sent) {
63
+ try {
64
+ const body = (await response.json());
65
+ if (body.rejected && body.rejected > 0) {
66
+ this.logger.warn(`Server rejected ${body.rejected} of ${sent} events: ` +
67
+ (body.errors?.join('; ') ?? 'no reason given'));
68
+ return;
69
+ }
70
+ this.logger.debug(`Successfully sent ${body.accepted ?? sent} events`);
71
+ }
72
+ catch {
73
+ this.logger.debug(`Successfully sent ${sent} events`);
74
+ }
75
+ }
50
76
  }
51
77
  exports.Transport = Transport;
@@ -1,7 +1,17 @@
1
1
  /**
2
2
  * Checks if the device currently has network connectivity.
3
- * Uses React Native's NetInfo if available, falls back to assuming online.
4
3
  *
5
- * @returns true if connected, false if offline
4
+ * Uses React Native's NetInfo when the host app has it. Without it there is no
5
+ * way to ask, so the answer is yes: a failed send is already handled — it
6
+ * retries with backoff and the events stay queued — whereas wrongly deciding
7
+ * the device is offline would stop a flush that would have worked.
8
+ *
9
+ * There used to be a fallback here that fetched a Google endpoint to test the
10
+ * connection. It was removed: it returned true whether it succeeded or failed,
11
+ * so it changed no outcome, and it sent a request to a third party on every
12
+ * flush interval — from a self-hosted, privacy-first SDK whose whole point is
13
+ * that the data goes to your server and nowhere else.
14
+ *
15
+ * @returns true if connected, false if NetInfo says otherwise
6
16
  */
7
17
  export declare function isOnline(): Promise<boolean>;
@@ -3,31 +3,28 @@ Object.defineProperty(exports, "__esModule", { value: true });
3
3
  exports.isOnline = isOnline;
4
4
  /**
5
5
  * Checks if the device currently has network connectivity.
6
- * Uses React Native's NetInfo if available, falls back to assuming online.
7
6
  *
8
- * @returns true if connected, false if offline
7
+ * Uses React Native's NetInfo when the host app has it. Without it there is no
8
+ * way to ask, so the answer is yes: a failed send is already handled — it
9
+ * retries with backoff and the events stay queued — whereas wrongly deciding
10
+ * the device is offline would stop a flush that would have worked.
11
+ *
12
+ * There used to be a fallback here that fetched a Google endpoint to test the
13
+ * connection. It was removed: it returned true whether it succeeded or failed,
14
+ * so it changed no outcome, and it sent a request to a third party on every
15
+ * flush interval — from a self-hosted, privacy-first SDK whose whole point is
16
+ * that the data goes to your server and nowhere else.
17
+ *
18
+ * @returns true if connected, false if NetInfo says otherwise
9
19
  */
10
20
  async function isOnline() {
11
21
  try {
12
- // Try React Native NetInfo (requires @react-native-community/netinfo)
22
+ // Requires @react-native-community/netinfo in the host app.
13
23
  const NetInfo = require('@react-native-community/netinfo');
14
24
  const state = await NetInfo.fetch();
15
25
  return state.isConnected ?? true;
16
26
  }
17
27
  catch {
18
- // NetInfo not installed — try fetch-based check
19
- try {
20
- const controller = new AbortController();
21
- const timeout = setTimeout(() => controller.abort(), 3000);
22
- await fetch('https://clients3.google.com/generate_204', {
23
- method: 'HEAD',
24
- signal: controller.signal,
25
- });
26
- clearTimeout(timeout);
27
- return true;
28
- }
29
- catch {
30
- return true; // Assume online if we can't determine — better to try and fail
31
- }
28
+ return true;
32
29
  }
33
30
  }
@@ -51,7 +51,11 @@ function validateProperties(properties) {
51
51
  if (key.length > MAX_PROPERTY_KEY_LENGTH) {
52
52
  return `property key "${key}" exceeds ${MAX_PROPERTY_KEY_LENGTH} character limit`;
53
53
  }
54
- const serialized = JSON.stringify(properties[key]);
54
+ const value = properties[key];
55
+ if (value === undefined || value === null) {
56
+ continue;
57
+ }
58
+ const serialized = JSON.stringify(value);
55
59
  if (serialized.length > MAX_PROPERTY_VALUE_SIZE) {
56
60
  return `property value for key "${key}" exceeds ${MAX_PROPERTY_VALUE_SIZE} byte limit: got ${serialized.length}`;
57
61
  }
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@bananalytics/react-native",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Self-hosted, privacy-first analytics SDK for React Native and Expo apps. Funnels, retention, sessions, geography — your server, your data.",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
7
7
  "files": [
8
8
  "dist",
9
+ "CHANGELOG.md",
9
10
  "README.md",
10
11
  "LICENSE"
11
12
  ],