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

View File

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

View File

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

View File

@@ -1,8 +1,11 @@
import Script from 'next/script'; import Script from 'next/script';
import type { MixanEventOptions } from '@mixan/sdk';
import type { MixanWebOptions } from '@mixan/sdk-web'; 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'; 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); window.op('event', name, data);
} }
export function trackScreenView(data?: Record<string, unknown>) { export function trackScreenView(data?: PostEventPayload['properties']) {
trackEvent('screen_view', data); trackEvent('screen_view', data);
} }

View File

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

View File

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