give global properties __ prefix to easier find them

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-28 10:21:37 +01:00
parent 3679caf547
commit a898cfb14a
8 changed files with 49 additions and 399 deletions

View File

@@ -42,7 +42,7 @@ function parsePath(path?: string): {
return {
query: parseSearchParams(url.searchParams),
path: url.pathname,
hash: url.hash ?? undefined,
hash: url.hash || undefined,
};
} catch (error) {
return {
@@ -69,15 +69,18 @@ export async function postEvent(
reply: FastifyReply
) {
let deviceId: string | null = null;
const projectId = request.projectId;
const body = request.body;
const { projectId, body } = request;
const properties = body.properties ?? {};
const getProperty = (name: string): string | undefined => {
return (properties[name] as string | null | undefined) ?? undefined;
};
const profileId = body.profileId ?? '';
const createdAt = new Date(body.timestamp);
const url = body.properties?.path;
const url = getProperty('__path');
const { path, hash, query } = parsePath(url);
const referrer = isSameDomain(body.properties?.referrer, url)
const referrer = isSameDomain(getProperty('__referrer'), url)
? null
: parseReferrer(body.properties?.referrer);
: parseReferrer(getProperty('__referrer'));
const utmReferrer = getReferrerWithQuery(query);
const ip = getClientIp(request)!;
const origin = request.headers.origin!;
@@ -112,7 +115,14 @@ export async function postEvent(
sessionId: event?.sessionId || '',
profileId,
projectId,
properties: body.properties ?? {},
properties: Object.assign(
{},
omit(['__path', '__referrer'], properties),
{
hash,
query,
}
),
createdAt,
country: event?.country ?? '',
city: event?.city ?? '',
@@ -205,7 +215,7 @@ export async function postEvent(
profileId,
projectId,
sessionId: createSessionStart ? uuid() : sessionStartEvent?.sessionId ?? '',
properties: Object.assign({}, omit(['path', 'referrer'], body.properties), {
properties: Object.assign({}, omit(['__path', '__referrer'], properties), {
hash,
query,
}),

View File

@@ -191,10 +191,6 @@ export async function createEvent(
});
}
if (payload.properties.hash === '') {
delete payload.properties.hash;
}
const event: IClickhouseEvent = {
id: uuid(),
name: payload.name,

View File

@@ -4,6 +4,7 @@ import Constants from 'expo-constants';
import type { MixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
import type { PostEventPayload } from '@mixan/types';
type MixanNativeOptions = MixanOptions;
@@ -24,19 +25,22 @@ export class MixanNative extends Mixan<MixanNativeOptions> {
private async setProperties() {
this.setGlobalProperties({
version: Application.nativeApplicationVersion,
buildNumber: Application.nativeBuildVersion,
referrer:
__version: Application.nativeApplicationVersion,
__buildNumber: Application.nativeBuildVersion,
__referrer:
Platform.OS === 'android'
? await Application.getInstallReferrerAsync()
: undefined,
});
}
public screenView(route: string, properties?: Record<string, unknown>): void {
public screenView(
route: string,
properties?: PostEventPayload['properties']
): void {
super.event('screen_view', {
...properties,
path: route,
__path: route,
});
}
}

View File

@@ -1,8 +1,11 @@
import Script from 'next/script';
import type { MixanEventOptions } from '@mixan/sdk';
import type { MixanWebOptions } from '@mixan/sdk-web';
import type { UpdateProfilePayload } from '@mixan/types';
import type {
MixanEventOptions,
PostEventPayload,
UpdateProfilePayload,
} from '@mixan/types';
const CDN_URL = 'http://localhost:3002/op.js';
@@ -73,11 +76,14 @@ export function SetProfileId({ value }: SetProfileIdProps) {
);
}
export function trackEvent(name: string, data?: Record<string, unknown>) {
export function trackEvent(
name: string,
data?: PostEventPayload['properties']
) {
window.op('event', name, data);
}
export function trackScreenView(data?: Record<string, unknown>) {
export function trackScreenView(data?: PostEventPayload['properties']) {
trackEvent('screen_view', data);
}

View File

@@ -1,5 +1,6 @@
import type { MixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
import type { PostEventPayload } from '@mixan/types';
export type MixanWebOptions = MixanOptions & {
trackOutgoingLinks?: boolean;
@@ -22,7 +23,7 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
if (!this.isServer()) {
this.setGlobalProperties({
referrer: document.referrer,
__referrer: document.referrer,
});
if (this.options.trackOutgoingLinks) {
@@ -134,7 +135,7 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
});
}
public screenView(properties?: Record<string, unknown>): void {
public screenView(properties?: PostEventPayload['properties']): void {
if (this.isServer()) {
return;
}
@@ -148,8 +149,8 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
this.lastPath = path;
super.event('screen_view', {
...(properties ?? {}),
path,
title: document.title,
__path: path,
__title: document.title,
});
}
}

View File

@@ -1,6 +1,7 @@
import type {
DecrementProfilePayload,
IncrementProfilePayload,
MixanEventOptions,
PostEventPayload,
UpdateProfilePayload,
} from '@mixan/types';
@@ -21,10 +22,6 @@ export interface MixanState {
properties: Record<string, unknown>;
}
export interface MixanEventOptions {
profileId?: string;
}
function awaitProperties(
properties: Record<string, string | Promise<string | null>>
): Promise<Record<string, string>> {
@@ -168,10 +165,7 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
});
}
public event(
name: string,
properties?: Record<string, unknown> & MixanEventOptions
) {
public event(name: string, properties?: PostEventPayload['properties']) {
const profileId = properties?.profileId ?? this.state.profileId;
delete properties?.profileId;
this.api
@@ -200,7 +194,6 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
}
public clear() {
this.state.properties = {};
this.state.deviceId = undefined;
this.state.profileId = undefined;
if (this.options.removeDeviceId) {

View File

@@ -1,360 +0,0 @@
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;
profile?: boolean;
trackIp?: boolean;
setItem: (key: string, profileId: string) => void;
getItem: (key: string) => string | null;
removeItem: (key: string) => void;
}
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,
profile: true,
...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) {
if (!this.options.profile) {
return;
}
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.options.profile) {
return;
}
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.options.profile) {
return;
}
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.options.profile) {
return;
}
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.options.profile) {
return;
}
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:lastEventAt');
this.profileId = undefined;
this.setAnonymousUser();
}
}

View File

@@ -134,16 +134,16 @@ export interface MixanResponse<T> {
// NEW
export interface MixanEventOptions {
profileId?: string;
}
export interface PostEventPayload {
name: string;
timestamp: string;
deviceId?: string;
profileId?: string;
properties?: Record<string, unknown> & {
title?: string | undefined;
referrer?: string | undefined;
path?: string | undefined;
};
properties?: Record<string, unknown> & MixanEventOptions;
}
export interface UpdateProfilePayload {