diff --git a/README.md b/README.md
index fe58e95..f39b947 100644
--- a/README.md
+++ b/README.md
@@ -20,6 +20,22 @@ If you're targeting Android, you'll need to add the following permissions to you
```
+## Web Support
+
+This SDK also supports React Native Web!
+
+> [!NOTE]
+> This feature is disabled by default. To enable it, you need to pass the `enableWeb` option when initializing the SDK.
+
+```js
+Aptabase.init("", {
+ enableWeb: true,
+ appVersion: "1.0.0", // required on web — no native module provides it
+});
+```
+
+When enabled, the SDK will track events in web environments using the same behavior as the web SDKs. Which means that events will be sent immediately to the `/event` endpoint instead of grouped to the `/events` endpoint.
+
## Usage
First, you need to get your `App Key` from Aptabase, you can find it in the `Instructions` menu on the left side menu.
@@ -64,7 +80,9 @@ export function Counter() {
);
}
```
-To disable tracking events, you can call the `dispose` function. This will stop and deinitalize the SDK.
+
+To disable tracking events, you can call the `dispose` function. This will stop and deinitialize the SDK.
+
```js
import Aptabase from "@aptabase/react-native";
diff --git a/src/client.spec.ts b/src/client.spec.ts
index b2b79dd..4c4ead9 100644
--- a/src/client.spec.ts
+++ b/src/client.spec.ts
@@ -119,4 +119,79 @@ describe("AptabaseClient", () => {
expect(sessionId3).toBeDefined();
expect(sessionId3).not.toBe(sessionId1);
});
+
+ describe("Web tracking", () => {
+ const webEnv: EnvironmentInfo = {
+ ...env,
+ osName: "web",
+ osVersion: "web",
+ };
+
+ it("should not track events when web tracking is disabled", async () => {
+ const client = new AptabaseClient("A-DEV-000", webEnv);
+ client.trackEvent("test_event");
+ await client.flush();
+ expect(fetchMock.requests().length).toEqual(0);
+ });
+
+ it("should track events when web tracking is enabled", async () => {
+ const client = new AptabaseClient("A-DEV-000", webEnv, {
+ enableWeb: true,
+ });
+ client.trackEvent("test_event");
+ await client.flush();
+ expect(fetchMock.requests().length).toEqual(1);
+ const body = await fetchMock.requests().at(0)?.json();
+ expect(body.eventName).toEqual("test_event");
+ expect(body.systemProps.osName).toBeUndefined();
+ expect(body.systemProps.osVersion).toBeUndefined();
+ });
+
+ it("should use correct endpoint for web events", async () => {
+ const client = new AptabaseClient("A-DEV-000", webEnv, {
+ enableWeb: true,
+ });
+ client.trackEvent("test_event");
+ await client.flush();
+ const request = fetchMock.requests().at(0);
+ expect(request?.url).toContain("/api/v0/event");
+ });
+
+ it("should use correct endpoint for native events", async () => {
+ const client = new AptabaseClient("A-DEV-000", env);
+ client.trackEvent("test_event");
+ await client.flush();
+ const request = fetchMock.requests().at(0);
+ expect(request?.url).toContain("/api/v0/events");
+ });
+ });
+
+ describe("Native tracking", () => {
+ it("should track events on iOS", async () => {
+ const client = new AptabaseClient("A-DEV-000", env);
+ client.trackEvent("test_event");
+ await client.flush();
+ expect(fetchMock.requests().length).toEqual(1);
+ const body = await fetchMock.requests().at(0)?.json();
+ expect(body[0].eventName).toEqual("test_event");
+ expect(body[0].systemProps.osName).toEqual("iOS");
+ expect(body[0].systemProps.osVersion).toEqual("14.3");
+ });
+
+ it("should track events on Android", async () => {
+ const androidEnv: EnvironmentInfo = {
+ ...env,
+ osName: "Android",
+ osVersion: "13",
+ };
+ const client = new AptabaseClient("A-DEV-000", androidEnv);
+ client.trackEvent("test_event");
+ await client.flush();
+ expect(fetchMock.requests().length).toEqual(1);
+ const body = await fetchMock.requests().at(0)?.json();
+ expect(body[0].eventName).toEqual("test_event");
+ expect(body[0].systemProps.osName).toEqual("Android");
+ expect(body[0].systemProps.osVersion).toEqual("13");
+ });
+ });
});
diff --git a/src/client.ts b/src/client.ts
index 4ad5b55..c6c178c 100644
--- a/src/client.ts
+++ b/src/client.ts
@@ -1,12 +1,14 @@
-import type { Platform } from "react-native";
import type { AptabaseOptions } from "./types";
import type { EnvironmentInfo } from "./env";
-import { EventDispatcher } from "./dispatcher";
+import { NativeEventDispatcher, WebEventDispatcher } from "./dispatcher";
import { newSessionId } from "./session";
import { HOSTS, SESSION_TIMEOUT } from "./constants";
export class AptabaseClient {
- private readonly _dispatcher: EventDispatcher;
+ private readonly _dispatcher:
+ | WebEventDispatcher
+ | NativeEventDispatcher
+ | null;
private readonly _env: EnvironmentInfo;
private _sessionId = newSessionId();
private _lastTouched = new Date();
@@ -21,13 +23,27 @@ export class AptabaseClient {
this._env.appVersion = options.appVersion;
}
- this._dispatcher = new EventDispatcher(appKey, baseUrl, env);
+ const isWeb = this._env.osName === "web";
+ const isWebTrackingEnabled = isWeb && options?.enableWeb === true;
+
+ const shouldEnableTracking = !isWeb || isWebTrackingEnabled;
+ const dispatcher = shouldEnableTracking
+ ? isWeb
+ ? new WebEventDispatcher(appKey, baseUrl, env)
+ : new NativeEventDispatcher(appKey, baseUrl, env)
+ : null;
+
+ this._dispatcher = dispatcher;
}
public trackEvent(
eventName: string,
props?: Record
) {
+ if (!this._dispatcher) return;
+
+ const isWeb = this._env.osName === "web";
+
this._dispatcher.enqueue({
timestamp: new Date().toISOString(),
sessionId: this.evalSessionId(),
@@ -35,8 +51,8 @@ export class AptabaseClient {
systemProps: {
isDebug: this._env.isDebug,
locale: this._env.locale,
- osName: this._env.osName,
- osVersion: this._env.osVersion,
+ osName: isWeb ? undefined : this._env.osName,
+ osVersion: isWeb ? undefined : this._env.osVersion,
appVersion: this._env.appVersion,
appBuildNumber: this._env.appBuildNumber,
sdkVersion: this._env.sdkVersion,
@@ -46,6 +62,10 @@ export class AptabaseClient {
}
public startPolling(flushInterval: number) {
+ if (!(this._dispatcher instanceof NativeEventDispatcher)) {
+ return;
+ }
+
this.stopPolling();
this._flushTimer = setInterval(this.flush.bind(this), flushInterval);
@@ -59,6 +79,7 @@ export class AptabaseClient {
}
public flush(): Promise {
+ if (!this._dispatcher) return Promise.resolve();
return this._dispatcher.flush();
}
diff --git a/src/dispatcher.spec.ts b/src/dispatcher.spec.ts
index 345b7d6..9c00fde 100644
--- a/src/dispatcher.spec.ts
+++ b/src/dispatcher.spec.ts
@@ -1,7 +1,7 @@
import "vitest-fetch-mock";
-import { EventDispatcher } from "./dispatcher";
+import { NativeEventDispatcher, WebEventDispatcher } from "./dispatcher";
import { beforeEach, describe, expect, it } from "vitest";
-import { EnvironmentInfo } from "./env";
+import type { EnvironmentInfo } from "./env";
const env: EnvironmentInfo = {
isDebug: false,
@@ -32,11 +32,11 @@ const expectEventsCount = async (
expect(body.length).toEqual(expectedNumOfEvents);
};
-describe("EventDispatcher", () => {
- let dispatcher: EventDispatcher;
+describe("NativeEventDispatcher", () => {
+ let dispatcher: NativeEventDispatcher;
beforeEach(() => {
- dispatcher = new EventDispatcher(
+ dispatcher = new NativeEventDispatcher(
"A-DEV-000",
"https://localhost:3000",
env
@@ -138,3 +138,63 @@ describe("EventDispatcher", () => {
expectRequestCount(1);
});
});
+
+describe("WebEventDispatcher", () => {
+ let dispatcher: WebEventDispatcher;
+
+ beforeEach(() => {
+ dispatcher = new WebEventDispatcher(
+ "A-DEV-000",
+ "https://localhost:3000",
+ env
+ );
+ fetchMock.resetMocks();
+ });
+
+ it("should send event with correct headers", async () => {
+ dispatcher.enqueue(createEvent("app_started"));
+
+ const request = await fetchMock.requests().at(0);
+ expect(request).not.toBeUndefined();
+ expect(request?.url).toEqual("https://localhost:3000/api/v0/event");
+ expect(request?.headers.get("Content-Type")).toEqual("application/json");
+ expect(request?.headers.get("App-Key")).toEqual("A-DEV-000");
+ });
+
+ it("should dispatch single event", async () => {
+ fetchMock.mockResponseOnce("{}");
+
+ dispatcher.enqueue(createEvent("app_started"));
+
+ expectRequestCount(1);
+ const body = await fetchMock.requests().at(0)?.json();
+ expect(body.eventName).toEqual("app_started");
+ });
+
+ it("should dispatch multiple events individually", async () => {
+ fetchMock.mockResponseOnce("{}");
+ fetchMock.mockResponseOnce("{}");
+
+ dispatcher.enqueue([createEvent("app_started"), createEvent("app_exited")]);
+
+ expectRequestCount(2);
+ const body1 = await fetchMock.requests().at(0)?.json();
+ const body2 = await fetchMock.requests().at(1)?.json();
+ expect(body1.eventName).toEqual("app_started");
+ expect(body2.eventName).toEqual("app_exited");
+ });
+
+ it("should not retry requests that failed with 4xx", async () => {
+ fetchMock.mockResponseOnce("{}", { status: 400 });
+
+ dispatcher.enqueue(createEvent("hello_world"));
+
+ expectRequestCount(1);
+ const body = await fetchMock.requests().at(0)?.json();
+ expect(body.eventName).toEqual("hello_world");
+
+ dispatcher.enqueue(createEvent("hello_world"));
+
+ expectRequestCount(2);
+ });
+});
diff --git a/src/dispatcher.ts b/src/dispatcher.ts
index 96fc476..f29aee6 100644
--- a/src/dispatcher.ts
+++ b/src/dispatcher.ts
@@ -1,11 +1,11 @@
import type { Event } from "./types";
-import { EnvironmentInfo } from "./env";
+import type { EnvironmentInfo } from "./env";
-export class EventDispatcher {
- private _events: Event[] = [];
- private MAX_BATCH_SIZE = 25;
- private headers: Headers;
- private apiUrl: string;
+export abstract class EventDispatcher {
+ protected _events: Event[] = [];
+ protected MAX_BATCH_SIZE = 25;
+ protected headers: Headers;
+ protected apiUrl: string;
constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
this.apiUrl = `${baseUrl}/api/v0/events`;
@@ -16,14 +16,7 @@ export class EventDispatcher {
});
}
- public enqueue(evt: Event | Event[]) {
- if (Array.isArray(evt)) {
- this._events.push(...evt);
- return;
- }
-
- this._events.push(evt);
- }
+ public abstract enqueue(evt: Event | Event[]): void;
public async flush(): Promise {
if (this._events.length === 0) {
@@ -45,7 +38,7 @@ export class EventDispatcher {
}
}
- private async _sendEvents(events: Event[]): Promise {
+ protected async _sendEvents(events: Event[]): Promise {
try {
const res = await fetch(this.apiUrl, {
method: "POST",
@@ -54,7 +47,7 @@ export class EventDispatcher {
body: JSON.stringify(events),
});
- if (res.status < 300) {
+ if (res.ok) {
return Promise.resolve();
}
@@ -74,4 +67,71 @@ export class EventDispatcher {
throw e;
}
}
+
+ protected async _sendEvent(event: Event): Promise {
+ try {
+ const res = await fetch(this.apiUrl, {
+ method: "POST",
+ headers: this.headers,
+ credentials: "omit",
+ body: JSON.stringify(event),
+ });
+
+ if (res.ok) {
+ return Promise.resolve();
+ }
+
+ const reason = `${res.status} ${await res.text()}`;
+ if (res.status < 500) {
+ console.warn(
+ `Aptabase: Failed to send event because of ${reason}. Will not retry.`
+ );
+ return Promise.resolve();
+ }
+
+ throw new Error(reason);
+ } catch (e) {
+ console.error(`Aptabase: Failed to send event. Reason: ${e}`);
+ throw e;
+ }
+ }
+}
+
+export class WebEventDispatcher extends EventDispatcher {
+ constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
+ super(appKey, baseUrl, env);
+ this.apiUrl = `${baseUrl}/api/v0/event`;
+ this.headers = new Headers({
+ "Content-Type": "application/json",
+ "App-Key": appKey,
+ // No User-Agent header for web
+ });
+ }
+
+ public enqueue(evt: Event | Event[]): void {
+ if (Array.isArray(evt)) {
+ evt.forEach((event) => this.dispatchEvent(event));
+ } else {
+ this.dispatchEvent(evt);
+ }
+ }
+
+ private dispatchEvent(event: Event): void {
+ void this._sendEvent(event).catch(() => undefined);
+ }
+}
+
+export class NativeEventDispatcher extends EventDispatcher {
+ constructor(appKey: string, baseUrl: string, env: EnvironmentInfo) {
+ super(appKey, baseUrl, env);
+ this.apiUrl = `${baseUrl}/api/v0/events`;
+ }
+
+ public enqueue(evt: Event | Event[]): void {
+ if (Array.isArray(evt)) {
+ this._events.push(...evt);
+ } else {
+ this._events.push(evt);
+ }
+ }
}
diff --git a/src/env.ts b/src/env.ts
index 0e62d76..477a244 100644
--- a/src/env.ts
+++ b/src/env.ts
@@ -10,8 +10,8 @@ export interface EnvironmentInfo {
appVersion: string;
appBuildNumber: string;
sdkVersion: string;
- osName: string;
- osVersion: string;
+ osName: string | undefined;
+ osVersion: string | undefined;
}
export function getEnvironmentInfo(): EnvironmentInfo {
@@ -19,27 +19,31 @@ export function getEnvironmentInfo(): EnvironmentInfo {
const locale = "en-US";
- return {
+ const envInfo: EnvironmentInfo = {
appVersion: version.appVersion,
appBuildNumber: version.appBuildNumber,
isDebug: __DEV__,
locale,
- osName,
- osVersion,
+ osName: osName,
+ osVersion: osVersion,
sdkVersion,
};
-}
-function getOperatingSystem(): [string, string] {
- switch (Platform.OS) {
- case "android":
- return ["Android", Platform.constants.Release];
- case "ios":
- if (Platform.isPad) {
- return ["iPadOS", Platform.Version];
- }
- return ["iOS", Platform.Version];
- default:
- return ["", ""];
+ return envInfo;
+
+ function getOperatingSystem(): [string, string] {
+ switch (Platform.OS) {
+ case "android":
+ return ["Android", Platform.constants.Release];
+ case "ios":
+ if (Platform.isPad) {
+ return ["iPadOS", Platform.Version];
+ }
+ return ["iOS", Platform.Version];
+ case "web":
+ return ["web", ""];
+ default:
+ return ["", ""];
+ }
}
}
diff --git a/src/types.d.ts b/src/types.d.ts
index 910bc0a..11df99d 100644
--- a/src/types.d.ts
+++ b/src/types.d.ts
@@ -11,6 +11,9 @@ export type AptabaseOptions = {
// Override the default flush interval (in milliseconds)
flushInterval?: number;
+
+ // Enable tracking for web platform (disabled by default)
+ enableWeb?: boolean;
};
/**
@@ -23,8 +26,8 @@ export type Event = {
systemProps: {
isDebug: boolean;
locale: string;
- osName: string;
- osVersion: string;
+ osName: string | undefined;
+ osVersion: string | undefined;
appVersion: string;
appBuildNumber: string;
sdkVersion: string;
diff --git a/src/validate.spec.ts b/src/validate.spec.ts
index 36a5471..503eb20 100644
--- a/src/validate.spec.ts
+++ b/src/validate.spec.ts
@@ -21,7 +21,13 @@ describe("Validate", () => {
platform: "web" as const,
appKey: "A-DEV-000",
options: undefined,
- expected: [false, "This SDK is only supported on Android and iOS"],
+ expected: [true, ""],
+ },
+ {
+ platform: "windows" as const,
+ appKey: "A-DEV-000",
+ options: undefined,
+ expected: [false, "This SDK is only supported on Android, iOS and web"],
},
{
platform: "ios" as const,
diff --git a/src/validate.ts b/src/validate.ts
index e561586..668fe15 100644
--- a/src/validate.ts
+++ b/src/validate.ts
@@ -3,13 +3,15 @@ import { HOSTS } from "./constants";
import type { AptabaseOptions } from "./types";
+const SUPPORTED_PLATFORMS = ["android", "ios", "web"];
+
export function validate(
platform: typeof Platform.OS,
appKey: string,
options?: AptabaseOptions
): [boolean, string] {
- if (platform !== "android" && platform !== "ios") {
- return [false, "This SDK is only supported on Android and iOS"];
+ if (!SUPPORTED_PLATFORMS.includes(platform)) {
+ return [false, "This SDK is only supported on Android, iOS and web"];
}
const parts = appKey.split("-");
@@ -24,5 +26,12 @@ export function validate(
];
}
+ // If platform is web but web tracking is not enabled, log a warning
+ if (platform === "web" && !options?.enableWeb) {
+ console.warn(
+ "Aptabase: Web tracking is disabled by default. Set enableWeb: true in options to enable it."
+ );
+ }
+
return [true, ""];
}
diff --git a/src/version.ts b/src/version.ts
index a66663d..50b2965 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1,15 +1,23 @@
-import { NativeModules } from "react-native";
-
-const { RNAptabaseModule } = NativeModules;
+import { Platform, NativeModules } from "react-native";
type VersionObject = {
appVersion: string;
appBuildNumber: string;
};
-const Version: VersionObject = {
- appVersion: RNAptabaseModule?.appVersion?.toString() ?? "",
- appBuildNumber: RNAptabaseModule?.appBuildNumber?.toString() ?? "",
-};
+let Version: VersionObject;
+
+if (Platform.OS === "web") {
+ Version = {
+ appVersion: "", // can be overrided in AptabaseOptions
+ appBuildNumber: "",
+ };
+} else {
+ const { RNAptabaseModule } = NativeModules;
+ Version = {
+ appVersion: RNAptabaseModule?.appVersion?.toString() ?? "",
+ appBuildNumber: RNAptabaseModule?.appBuildNumber?.toString() ?? "",
+ };
+}
export default Version;