Skip to content

Commit

Permalink
batch events support
Browse files Browse the repository at this point in the history
  • Loading branch information
goenning committed Aug 18, 2023
1 parent e583d64 commit 018f938
Show file tree
Hide file tree
Showing 8 changed files with 1,105 additions and 439 deletions.
1,294 changes: 883 additions & 411 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 6 additions & 3 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,8 @@
"homepage": "https://github.com/aptabase/aptabase-react-native",
"license": "MIT",
"scripts": {
"build": "vite build"
"build": "vite build",
"test": "vitest ."
},
"files": [
"README.md",
Expand All @@ -38,8 +39,10 @@
],
"devDependencies": {
"@rollup/plugin-replace": "5.0.2",
"vite": "4.3.9",
"vite-plugin-dts": "2.3.0"
"vite": "4.4.9",
"vite-plugin-dts": "3.5.2",
"vitest": "0.34.2",
"vitest-fetch-mock": "0.2.2"
},
"peerDependencies": {
"react": "*",
Expand Down
6 changes: 6 additions & 0 deletions setupVitest.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import createFetchMock from "vitest-fetch-mock";
import { vi } from "vitest";

const fetchMocker = createFetchMock(vi);

fetchMocker.enableMocks();
106 changes: 106 additions & 0 deletions src/dispatcher.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
import "vitest-fetch-mock";
import { EventDispatcher } from "./dispatcher";
import { beforeEach, describe, expect, it } from "vitest";

const createEvent = (eventName: string) => ({
timestamp: new Date().toISOString(),
sessionId: "123",
eventName,
systemProps: {
isDebug: false,
locale: "en-US",
osName: "iOS",
osVersion: "14.3",
appVersion: "1.0.0",
sdkVersion: "1.0.0",
},
});

const expectRequestCount = (count: number) => {
expect(fetchMock.requests().length).toEqual(count);
};

const expectEventsCount = async (
requestIndex: number,
expectedNumOfEvents: number
) => {
const body = await fetchMock.requests()[requestIndex].json();
expect(body.length).toEqual(expectedNumOfEvents);
};

describe("EventDispatcher", () => {
let dispatcher: EventDispatcher;

beforeEach(() => {
dispatcher = new EventDispatcher("https://localhost:3000", "A-DEV-000");
fetchMock.resetMocks();
});

it("should not send a request if queue is empty", async () => {
await dispatcher.flush();

expectRequestCount(0);
});

it("should dispatch single event", async () => {
fetchMock.mockResponseOnce("{}");

dispatcher.enqueue(createEvent("app_started"));
await dispatcher.flush();

expectRequestCount(1);
await expectEventsCount(0, 1);
});

it("should not dispatch event if it's already been sent", async () => {
fetchMock.mockResponseOnce("{}");

dispatcher.enqueue(createEvent("app_started"));
await dispatcher.flush();
expectRequestCount(1);

await dispatcher.flush();
expectRequestCount(1);
});

it("should dispatch multiple events", async () => {
fetchMock.mockResponseOnce("{}");

dispatcher.enqueue(createEvent("app_started"));
dispatcher.enqueue(createEvent("app_exited"));
await dispatcher.flush();

expectRequestCount(1);
await expectEventsCount(0, 2);
});

it("should send many events in chunks of 25 items", async () => {
fetchMock.mockResponseOnce("{}");

for (let i = 0; i < 60; i++) {
dispatcher.enqueue(createEvent("hello_world"));
}
await dispatcher.flush();

expectRequestCount(3);
await expectEventsCount(0, 25);
await expectEventsCount(1, 25);
await expectEventsCount(2, 10);
});

it("should retry failed requests in a subsequent flush", async () => {
fetchMock.mockResponseOnce("{}", { status: 500 });

dispatcher.enqueue(createEvent("hello_world"));
await dispatcher.flush();

expectRequestCount(1);
await expectEventsCount(0, 1);

fetchMock.mockResponseOnce("{}", { status: 200 });
await dispatcher.flush();

expectRequestCount(2);
await expectEventsCount(1, 1);
});
});
75 changes: 75 additions & 0 deletions src/dispatcher.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
export type Event = {
timestamp: string;
sessionId: string;
eventName: string;
systemProps: {
isDebug: boolean;
locale: string;
osName: string;
osVersion: string;
appVersion: string;
sdkVersion: string;
};
props?: Record<string, string | number | boolean>;
};

export class EventDispatcher {
private _events: Event[] = [];
private MAX_BATCH_SIZE = 25;

constructor(private apiUrl: string, private appKey: string) {}

public enqueue(evt: Event | Event[]) {
if (Array.isArray(evt)) {
this._events.push(...evt);
return;
}

this._events.push(evt);
}

public async flush(): Promise<void> {
if (this._events.length === 0) {
return Promise.resolve();
}

let failedEvents: Event[] = [];
do {
const eventsToSend = this._events.splice(0, this.MAX_BATCH_SIZE);
try {
await this._sendEvents(eventsToSend);
} catch {
failedEvents = [...failedEvents, ...eventsToSend];
}
} while (this._events.length > 0);

if (failedEvents.length > 0) {
this.enqueue(failedEvents);
}
}

private async _sendEvents(events: Event[]): Promise<void> {
const res = await fetch(this.apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"App-Key": this.appKey,
},
credentials: "omit",
body: JSON.stringify(events),
});

if (res.status >= 300) {
const reason = await res.text();
console.warn(
`Aptabase: Failed to send ${events.length} events. Reason: ${res.status} ${reason}`
);
}

if (res.status >= 500) {
throw new Error("Failed to send events");
}

return Promise.resolve();
}
}
4 changes: 2 additions & 2 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@ export interface EnvironmentInfo {
appVersion: string;
appBuildNumber: string;
sdkVersion: string;
osName: String;
osVersion: String;
osName: string;
osVersion: string;
}

export function getEnvironmentInfo(): EnvironmentInfo {
Expand Down
45 changes: 22 additions & 23 deletions src/index.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { newSessionId } from "./session";
import { EnvironmentInfo, getEnvironmentInfo } from "./env";
import { Platform } from "react-native";
import { EventDispatcher } from "./dispatcher";

/**
* Custom initialization parameters for Aptabase SDK.
Expand All @@ -12,15 +13,23 @@ export type AptabaseOptions = {

// Custom appVersion to override the default
appVersion?: string;

// Override the default flush interval (in milliseconds)
flushInterval?: string;
};

// Session expires after 1 hour of inactivity
const SESSION_TIMEOUT = 1 * 60 * 60;

const RELEASE_FLUSH_INTERVAL = 60 * 1000;
const DEBUG_FLUSH_INTERVAL = 2 * 1000;

let _sessionId = newSessionId();
let _lastTouched = new Date();
let _appKey = "";
let _apiUrl = "";
let _env: EnvironmentInfo | undefined;
let _dispatcher: EventDispatcher | undefined;

const _hosts: { [region: string]: string } = {
US: "https://us.aptabase.com",
Expand Down Expand Up @@ -54,14 +63,13 @@ function getBaseUrl(
export function init(appKey: string, options?: AptabaseOptions) {
_appKey = appKey;

if (Platform.OS !== 'android' && Platform.OS !== 'ios') {
if (Platform.OS !== "android" && Platform.OS !== "ios") {
console.warn(
"Aptabase: This SDK is only supported on Android and iOS. Tracking will be disabled."
);
return;
}


const parts = appKey.split("-");
if (parts.length !== 3 || _hosts[parts[1]] === undefined) {
console.warn(
Expand All @@ -71,12 +79,21 @@ export function init(appKey: string, options?: AptabaseOptions) {
}

const baseUrl = getBaseUrl(parts[1], options);
_apiUrl = `${baseUrl}/api/v0/event`;
_apiUrl = `${baseUrl}/api/v0/events`;
_env = getEnvironmentInfo();

if (options?.appVersion) {
_env.appVersion = options.appVersion;
}

_dispatcher = new EventDispatcher(_apiUrl, _appKey);

const flushInterval =
options?.flushInterval ?? _env.isDebug
? DEBUG_FLUSH_INTERVAL
: RELEASE_FLUSH_INTERVAL;

setInterval(_dispatcher.flush.bind(_dispatcher), flushInterval);
}

/**
Expand All @@ -88,7 +105,7 @@ export function trackEvent(
eventName: string,
props?: Record<string, string | number | boolean>
) {
if (!_appKey || !_env) return;
if (!_dispatcher || !_env) return;

let now = new Date();
const diffInMs = now.getTime() - _lastTouched.getTime();
Expand All @@ -98,7 +115,7 @@ export function trackEvent(
}
_lastTouched = now;

const body = JSON.stringify({
_dispatcher.enqueue({
timestamp: new Date().toISOString(),
sessionId: _sessionId,
eventName: eventName,
Expand All @@ -112,22 +129,4 @@ export function trackEvent(
},
props: props,
});

fetch(_apiUrl, {
method: "POST",
headers: {
"Content-Type": "application/json",
"App-Key": _appKey,
},
credentials: "omit",
body,
})
.then((res) => {
if (res.status >= 300) {
console.warn(
`Aptabase: Failed to send event "${eventName}": ${res.status} ${res.statusText}`
);
}
})
.catch(console.error);
}
5 changes: 5 additions & 0 deletions vite.config.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,11 @@ export default defineConfig({
external: ["react", "react-native"],
},
},
test: {
setupFiles: [
"./setupVitest.ts"
]
},
plugins: [
dts(),
replace({
Expand Down

0 comments on commit 018f938

Please sign in to comment.