diff --git a/apps/sdk-api/src/controllers/event.controller.ts b/apps/sdk-api/src/controllers/event.controller.ts index df11541a..30904977 100644 --- a/apps/sdk-api/src/controllers/event.controller.ts +++ b/apps/sdk-api/src/controllers/event.controller.ts @@ -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, }), diff --git a/packages/db/src/services/event.service.ts b/packages/db/src/services/event.service.ts index 5530395c..88247e5b 100644 --- a/packages/db/src/services/event.service.ts +++ b/packages/db/src/services/event.service.ts @@ -191,10 +191,6 @@ export async function createEvent( }); } - if (payload.properties.hash === '') { - delete payload.properties.hash; - } - const event: IClickhouseEvent = { id: uuid(), name: payload.name, diff --git a/packages/sdk-native/index.ts b/packages/sdk-native/index.ts index 9d27e89c..795e742b 100644 --- a/packages/sdk-native/index.ts +++ b/packages/sdk-native/index.ts @@ -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 { 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): void { + public screenView( + route: string, + properties?: PostEventPayload['properties'] + ): void { super.event('screen_view', { ...properties, - path: route, + __path: route, }); } } diff --git a/packages/sdk-next/index.tsx b/packages/sdk-next/index.tsx index 303eabfc..2b3e9cf0 100644 --- a/packages/sdk-next/index.tsx +++ b/packages/sdk-next/index.tsx @@ -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) { +export function trackEvent( + name: string, + data?: PostEventPayload['properties'] +) { window.op('event', name, data); } -export function trackScreenView(data?: Record) { +export function trackScreenView(data?: PostEventPayload['properties']) { trackEvent('screen_view', data); } diff --git a/packages/sdk-web/index.ts b/packages/sdk-web/index.ts index 182acc8a..4e6a7b05 100644 --- a/packages/sdk-web/index.ts +++ b/packages/sdk-web/index.ts @@ -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 { if (!this.isServer()) { this.setGlobalProperties({ - referrer: document.referrer, + __referrer: document.referrer, }); if (this.options.trackOutgoingLinks) { @@ -134,7 +135,7 @@ export class MixanWeb extends Mixan { }); } - public screenView(properties?: Record): void { + public screenView(properties?: PostEventPayload['properties']): void { if (this.isServer()) { return; } @@ -148,8 +149,8 @@ export class MixanWeb extends Mixan { this.lastPath = path; super.event('screen_view', { ...(properties ?? {}), - path, - title: document.title, + __path: path, + __title: document.title, }); } } diff --git a/packages/sdk/index.ts b/packages/sdk/index.ts index 33d160f1..fa120908 100644 --- a/packages/sdk/index.ts +++ b/packages/sdk/index.ts @@ -1,6 +1,7 @@ import type { DecrementProfilePayload, IncrementProfilePayload, + MixanEventOptions, PostEventPayload, UpdateProfilePayload, } from '@mixan/types'; @@ -21,10 +22,6 @@ export interface MixanState { properties: Record; } -export interface MixanEventOptions { - profileId?: string; -} - function awaitProperties( properties: Record> ): Promise> { @@ -168,10 +165,7 @@ export class Mixan { }); } - public event( - name: string, - properties?: Record & 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 { } public clear() { - this.state.properties = {}; this.state.deviceId = undefined; this.state.profileId = undefined; if (this.options.removeDeviceId) { diff --git a/packages/sdk/index_old.ts b/packages/sdk/index_old.ts deleted file mode 100644 index 4c42831e..00000000 --- a/packages/sdk/index_old.ts +++ /dev/null @@ -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; - -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( - path: string, - data?: PostData, - options?: RequestInit - ): Promise { - 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 { - queue: T[] = []; - timer?: ReturnType; - 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; - private profileId?: string; - private options: MixanOptions; - private logger: (...args: any[]) => void; - private globalProperties: Record = {}; - private lastEventAt: string; - private promiseIp: Promise; - - 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) { - if (properties) { - this.setGlobalProperties(properties); - } - this.logger('Mixan: Init'); - this.setAnonymousUser(); - } - - event(name: string, properties: Record = {}) { - 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( - '/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 | 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) { - 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(); - } -} diff --git a/packages/types/src/sdk.types.ts b/packages/types/src/sdk.types.ts index 42785247..f228af92 100644 --- a/packages/types/src/sdk.types.ts +++ b/packages/types/src/sdk.types.ts @@ -134,16 +134,16 @@ export interface MixanResponse { // NEW +export interface MixanEventOptions { + profileId?: string; +} + export interface PostEventPayload { name: string; timestamp: string; deviceId?: string; profileId?: string; - properties?: Record & { - title?: string | undefined; - referrer?: string | undefined; - path?: string | undefined; - }; + properties?: Record & MixanEventOptions; } export interface UpdateProfilePayload {