Files
stats/packages/sdk/index.ts
Carl-Gerhard Lindesvärd 4ddafcad07 add native and web sdks
2023-11-06 08:52:58 +01:00

344 lines
8.4 KiB
TypeScript

import type {
EventPayload,
MixanErrorResponse,
ProfilePayload,
} from '@mixan/types';
export interface NewMixanOptions {
url: string;
clientId: string;
clientSecret: string;
batchInterval?: number;
maxBatchSize?: number;
sessionTimeout?: number;
session?: boolean;
verbose?: boolean;
setItem: (key: string, profileId: string) => void;
getItem: (key: string) => string | null;
removeItem: (key: string) => void;
trackIp?: boolean;
}
export type MixanOptions = Required<NewMixanOptions>;
class Fetcher {
private url: string;
private clientId: string;
private clientSecret: string;
private logger: (...args: any[]) => void;
constructor(options: MixanOptions) {
this.url = options.url;
this.clientId = options.clientId;
this.clientSecret = options.clientSecret;
this.logger = options.verbose ? console.log : () => {};
}
post<PostData, PostResponse>(
path: string,
data?: PostData,
options?: RequestInit
): Promise<PostResponse | null> {
const url = `${this.url}${path}`;
this.logger(`Mixan request: ${url}`, JSON.stringify(data, null, 2));
return fetch(url, {
headers: {
['mixan-client-id']: this.clientId,
['mixan-client-secret']: this.clientSecret,
'Content-Type': 'application/json',
},
method: 'POST',
body: JSON.stringify(data ?? {}),
keepalive: true,
...(options ?? {}),
})
.then(async (res) => {
const response = (await res.json()) as
| MixanErrorResponse
| PostResponse;
if (!response) {
return null;
}
if (
typeof response === 'object' &&
'status' in response &&
response.status === 'error'
) {
this.logger(
`Mixan request failed: [${options?.method ?? 'POST'}] ${url}`,
JSON.stringify(response, null, 2)
);
return null;
}
return response as PostResponse;
})
.catch(() => {
this.logger(
`Mixan request failed: [${options?.method ?? 'POST'}] ${url}`
);
return null;
});
}
}
class Batcher<T> {
queue: T[] = [];
timer?: ReturnType<typeof setTimeout>;
callback: (queue: T[]) => void;
maxBatchSize: number;
batchInterval: number;
constructor(options: MixanOptions, callback: (queue: T[]) => void) {
this.callback = callback;
this.maxBatchSize = options.maxBatchSize;
this.batchInterval = options.batchInterval;
}
add(payload: T) {
this.queue.push(payload);
this.flush();
}
flush() {
if (this.timer) {
clearTimeout(this.timer);
}
if (this.queue.length === 0) {
return;
}
if (this.queue.length >= this.maxBatchSize) {
this.send();
return;
}
this.timer = setTimeout(this.send.bind(this), this.batchInterval);
}
send() {
if (this.timer) {
clearTimeout(this.timer);
}
if (this.queue.length > 0) {
this.callback(this.queue);
this.queue = [];
}
}
}
export class Mixan {
private fetch: Fetcher;
private eventBatcher: Batcher<EventPayload>;
private profileId?: string;
private options: MixanOptions;
private logger: (...args: any[]) => void;
private globalProperties: Record<string, unknown> = {};
private lastEventAt: string;
private promiseIp: Promise<string | null>;
constructor(options: NewMixanOptions) {
this.logger = options.verbose ? console.log : () => {};
this.options = {
sessionTimeout: 1000 * 60 * 30,
session: true,
verbose: false,
batchInterval: 10000,
maxBatchSize: 10,
trackIp: false,
...options,
};
this.lastEventAt =
this.options.getItem('@mixan:lastEventAt') ?? '1970-01-01';
this.fetch = new Fetcher(this.options);
this.eventBatcher = new Batcher(this.options, (queue) => {
this.fetch.post(
'/events',
queue.map((item) => ({
...item,
properties: {
...this.globalProperties,
...item.properties,
},
profileId: item.profileId ?? this.profileId ?? null,
}))
);
});
this.promiseIp = this.options.trackIp
? fetch('https://api.ipify.org')
.then((res) => res.text())
.catch(() => null)
: Promise.resolve(null);
}
async ip() {
return this.promiseIp;
}
timestamp(modify = 0) {
return new Date(Date.now() + modify).toISOString();
}
init(properties?: Record<string, unknown>) {
if (properties) {
this.setGlobalProperties(properties);
}
this.logger('Mixan: Init');
this.setAnonymousUser();
}
event(name: string, properties: Record<string, unknown> = {}) {
const now = new Date();
const isSessionStart =
this.options.session &&
now.getTime() - new Date(this.lastEventAt).getTime() >
this.options.sessionTimeout;
if (isSessionStart) {
this.logger('Mixan: Session start');
this.eventBatcher.add({
name: 'session_start',
time: this.timestamp(-10),
properties: {},
profileId: this.profileId ?? null,
});
}
this.logger('Mixan: Queue event', name);
this.eventBatcher.add({
name,
properties,
time: this.timestamp(),
profileId: this.profileId ?? null,
});
this.lastEventAt = this.timestamp();
this.options.setItem('@mixan:lastEventAt', this.lastEventAt);
}
private async setAnonymousUser(retryCount = 0) {
const profileId = this.options.getItem('@mixan:profileId');
if (profileId) {
this.profileId = profileId;
await this.setUser({
properties: this.globalProperties,
});
this.logger('Mixan: Use existing profile', this.profileId);
} else {
const res = await this.fetch.post<ProfilePayload, { id: string }>(
'/profiles',
{
properties: this.globalProperties,
}
);
if (res) {
this.profileId = res.id;
this.options.setItem('@mixan:profileId', res.id);
this.logger('Mixan: Create new profile', this.profileId);
} else if (retryCount < 2) {
setTimeout(() => {
this.setAnonymousUser(retryCount + 1);
}, 500);
} else {
this.logger('Mixan: Failed to create new profile');
}
}
}
async setUser(profile: ProfilePayload) {
if (!this.profileId) {
return this.logger('Mixan: Set user failed, no profileId');
}
this.logger('Mixan: Set user', profile);
await this.fetch.post(`/profiles/${this.profileId}`, profile, {
method: 'PUT',
});
}
async setUserProperty(
name: string,
value: string | number | boolean | Record<string, unknown> | unknown[]
) {
if (!this.profileId) {
return this.logger('Mixan: Set user property, no profileId');
}
this.logger('Mixan: Set user property', name, value);
await this.fetch.post(`/profiles/${this.profileId}`, {
properties: {
[name]: value,
},
});
}
setGlobalProperties(properties: Record<string, unknown>) {
if (typeof properties !== 'object') {
return this.logger(
'Mixan: Set global properties failed, properties must be an object'
);
}
this.logger('Mixan: Set global properties', properties);
this.globalProperties = {
...this.globalProperties,
...properties,
};
}
async increment(name: string, value = 1) {
if (!this.profileId) {
this.logger('Mixan: Increment failed, no profileId');
return;
}
this.logger('Mixan: Increment user property', name, value);
await this.fetch.post(
`/profiles/${this.profileId}/increment`,
{
name,
value,
},
{
method: 'PUT',
}
);
}
async decrement(name: string, value = 1) {
if (!this.profileId) {
this.logger('Mixan: Decrement failed, no profileId');
return;
}
this.logger('Mixan: Decrement user property', name, value);
await this.fetch.post(
`/profiles/${this.profileId}/decrement`,
{
name,
value,
},
{
method: 'PUT',
}
);
}
flush() {
this.logger('Mixan: Flushing events queue');
this.eventBatcher.send();
}
clear() {
this.logger('Mixan: Clear, send remaining events and remove profileId');
this.eventBatcher.send();
this.options.removeItem('@mixan:profileId');
this.options.removeItem('@mixan:session');
this.profileId = undefined;
this.setAnonymousUser();
}
}