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;