sdk changes

This commit is contained in:
Carl-Gerhard Lindesvärd
2024-02-11 21:31:12 +01:00
parent 484a6b1d41
commit 447fa5896e
65 changed files with 9428 additions and 723 deletions

View File

@@ -1 +1,2 @@
"use strict";(()=>{function y(u,t,e){return function(s,o,v){let m=`${u}${s}`,l,h={"mixan-client-id":t,"Content-Type":"application/json"};return e&&(h["mixan-client-secret"]=e),new Promise(p=>{let f=n=>{clearTimeout(l),fetch(m,{headers:h,method:"POST",body:JSON.stringify(o??{}),keepalive:!0,...v??{}}).then(async a=>{if(a.status!==200&&a.status!==202)return g(n,p);let w=await a.text();if(!w)return p(null);p(w)}).catch(()=>g(n,p))};function g(n,a){if(n>1)return a(null);l=setTimeout(()=>{f(n+1)},Math.pow(2,n)*500)}f(0)})}}var c=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=y(t.url,t.clientId,t.clientSecret)}init(t){this.state.properties=t??{}}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}async event(t,e){let i=await this.api("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()});this.options.setProfileId&&i&&this.options.setProfileId(i)}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}setUserProperty(t,e,i=!0){}timestamp(){return new Date().toISOString()}};var d=class extends c{constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}getTimezone(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{return}}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...s){let o=t.apply(this,s);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let e=history.replaceState;history.replaceState=function(...s){let o=e.apply(this,s);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),o},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){this.isServer()||super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer})}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
"use strict";(()=>{function m(s){return Promise.all(Object.entries(s).map(async([t,e])=>[t,await e??""])).then(t=>Object.fromEntries(t))}function P(s){let t={"Content-Type":"application/json"};return{headers:t,async fetch(e,i,o){let a=`${s}${e}`,h,w=await m(t);return new Promise(p=>{let u=n=>{clearTimeout(h),fetch(a,{headers:w,method:"POST",body:JSON.stringify(i??{}),keepalive:!0,...o??{}}).then(async c=>{if(c.status!==200&&c.status!==202)return f(n,p);let g=await c.text();if(!g)return p(null);p(g)}).catch(()=>f(n,p))};function f(n,c){if(n>1)return c(null);h=setTimeout(()=>{u(n+1)},Math.pow(2,n)*500)}u(0)})}}}var l=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=P(t.url),this.api.headers["mixan-client-id"]=t.clientId,this.options.clientSecret&&(this.api.headers["mixan-client-secret"]=this.options.clientSecret)}init(t){this.state.properties=t??{}}setUser(t){this.api.fetch("/profile",{profileId:this.getProfileId(),...t,properties:{...this.state.properties,...t.properties}})}increment(t,e){this.api.fetch("/profile/increment",{property:t,value:e,profileId:this.getProfileId()})}decrement(t,e){this.api.fetch("/profile/decrement",{property:t,value:e,profileId:this.getProfileId()})}event(t,e){this.api.fetch("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()}).then(i=>{this.options.setProfileId&&i&&this.options.setProfileId(i)})}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}timestamp(){return new Date().toISOString()}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}};var d=class extends l{lastPath="";constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...o){let a=t.apply(this,o);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),a};let e=history.replaceState;history.replaceState=function(...o){let a=e.apply(this,o);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),a},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){this.isServer()||this.lastPath!==window.location.href&&(this.lastPath=window.location.href,super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer}))}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
//# sourceMappingURL=cdn.global.js.map

View File

@@ -0,0 +1,48 @@
import fs from 'fs';
import path from 'path';
function transform(data: any) {
const obj: Record<string, unknown> = {};
for (const type in data) {
for (const name in data[type]) {
const domains = data[type][name].domains ?? [];
for (const domain of domains) {
obj[domain] = {
type,
name,
};
}
}
}
return obj;
}
async function main() {
// Get document, or throw exception on error
try {
const data = await fetch(
'https://s3-eu-west-1.amazonaws.com/snowplow-hosted-assets/third-party/referer-parser/referers-latest.json'
).then((res) => res.json());
fs.writeFileSync(
path.resolve(__dirname, '../src/referrers/index.ts'),
[
'// This file is generated by the script get-referrers.ts',
'',
'// The data is fetch from snowplow-referer-parser https://github.com/snowplow-referer-parser/referer-parser',
`// The orginal referers.yml is based on Piwik's SearchEngines.php and Socials.php, copyright 2012 Matthieu Aubry and available under the GNU General Public License v3.`,
'',
`const referrers: Record<string, { type: string, name: string }> = ${JSON.stringify(
transform(data)
)} as const;`,
'export default referrers;',
].join('\n'),
'utf-8'
);
} catch (e) {
console.log(e);
}
}
main();

View File

@@ -97,16 +97,14 @@ async function main() {
const ua = properties.ua!;
const uaInfo = parseUserAgent(ua);
const salts = await getSalts();
const profileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const [profileId, geo] = await Promise.all([
generateProfileId({
salt: salts.current,
origin,
ip,
ua,
}),
parseIp(ip),
]);
const geo = parseIp(ip);
const isNextEventNewSession =
nextEvent &&

View File

@@ -0,0 +1,18 @@
//@ts-nocheck
async function main() {
const crypto = require('crypto');
function createHash(data, len) {
return crypto
.createHash('shake256', { outputLength: len })
.update(data)
.digest('hex');
}
console.log(createHash('foo', 2));
// 1af9
console.log(createHash('foo', 32));
// 1af97f7818a28edf}
}
main();

View File

@@ -1,4 +1,5 @@
import { parseIp } from '@/utils/parseIp';
import { parseReferrer } from '@/utils/parseReferrer';
import { parseUserAgent } from '@/utils/parseUserAgent';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { omit } from 'ramda';
@@ -56,33 +57,30 @@ export async function postEvent(
let profileId: string | null = null;
const projectId = request.projectId;
const body = request.body;
const { path, hash, query } = parsePath(
body.properties?.path as string | undefined
);
const referrer = body.properties?.referrer as string | undefined;
const { path, hash, query } = parsePath(body.properties?.path);
const referrer = parseReferrer(body.properties?.referrer);
const ip = getClientIp(request)!;
const origin = request.headers.origin!;
const ua = request.headers['user-agent']!;
const uaInfo = parseUserAgent(ua);
const salts = await getSalts();
const currentProfileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
});
const [currentProfileId, previousProfileId, geo, eventsJobs] =
await Promise.all([
generateProfileId({
salt: salts.current,
origin,
ip,
ua,
}),
generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
}),
parseIp(ip),
eventsQueue.getJobs(['delayed']),
]);
const [geo, eventsJobs] = await Promise.all([
parseIp(ip),
eventsQueue.getJobs(['delayed']),
]);
// find session_end job
const sessionEndJobCurrentProfileId = findJobByPrefix(
@@ -148,8 +146,9 @@ export async function postEvent(
model: uaInfo.model,
duration: 0,
path: path,
referrer,
referrerName: referrer, // TODO
referrer: referrer.url,
referrerName: referrer.name,
referrerType: referrer.type,
};
const job = findJobByPrefix(eventsJobs, `event:${projectId}:${profileId}:`);
@@ -171,7 +170,7 @@ export async function postEvent(
duration,
},
});
job.promote();
await job.promote();
}
}

View File

@@ -0,0 +1,291 @@
import { parseIp } from '@/utils/parseIp';
import { parseUserAgent } from '@/utils/parseUserAgent';
import type { FastifyReply, FastifyRequest } from 'fastify';
import { assocPath, mergeDeepRight, path } from 'ramda';
import { getClientIp } from 'request-ip';
import { generateProfileId, toDots } from '@mixan/common';
import type { IDBProfile, Profile } from '@mixan/db';
import { db, getSalts } from '@mixan/db';
import type {
IncrementProfilePayload,
UpdateProfilePayload,
} from '@mixan/types';
async function findProfile({
profileId,
ip,
origin,
ua,
}: {
profileId: string | null;
ip: string;
origin: string;
ua: string;
}) {
const salts = await getSalts();
const currentProfileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
});
const ids = [currentProfileId, previousProfileId];
if (profileId) {
ids.push(profileId);
}
const profiles = await db.profile.findMany({
where: {
id: {
in: ids,
},
},
});
return profiles.find((p) => {
return (
p.id === profileId ||
p.id === currentProfileId ||
p.id === previousProfileId
);
}) as IDBProfile | undefined;
}
export async function updateProfile(
request: FastifyRequest<{
Body: UpdateProfilePayload;
}>,
reply: FastifyReply
) {
const body = request.body;
const profileId: string | null = body.profileId ?? null;
const projectId = request.projectId;
const ip = getClientIp(request)!;
const origin = request.headers.origin ?? projectId;
const ua = request.headers['user-agent']!;
const salts = await getSalts();
const uaInfo = parseUserAgent(ua);
const geo = await parseIp(ip);
if (profileId === null) {
const currentProfileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
});
const profiles = await db.profile.findMany({
where: {
id: {
in: [currentProfileId, previousProfileId],
},
},
});
if (profiles.length === 0) {
const profile = await db.profile.create({
data: {
id: currentProfileId,
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
project_id: projectId,
properties: body.properties ?? {},
// ...uaInfo,
// ...geo,
},
});
return reply.status(201).send(profile);
}
const currentProfile = profiles.find((p) => p.id === currentProfileId);
const previousProfile = profiles.find((p) => p.id === previousProfileId);
const profile = currentProfile ?? previousProfile;
if (profile) {
await db.profile.update({
where: {
id: profile.id,
},
data: {
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
properties: toDots(
mergeDeepRight(
profile.properties as Record<string, unknown>,
body.properties ?? {}
)
),
// ...uaInfo,
// ...geo,
},
});
return reply.status(200).send(profile.id);
}
return reply.status(200).send();
}
const profile = await db.profile.findUnique({
where: {
id: profileId,
},
});
if (profile) {
await db.profile.update({
where: {
id: profile.id,
},
data: {
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
properties: toDots(
mergeDeepRight(
profile.properties as Record<string, unknown>,
body.properties ?? {}
)
),
// ...uaInfo,
// ...geo,
},
});
} else {
await db.profile.create({
data: {
id: profileId,
external_id: body.id,
first_name: body.first_name,
last_name: body.last_name,
email: body.email,
avatar: body.avatar,
project_id: projectId,
properties: body.properties ?? {},
// ...uaInfo,
// ...geo,
},
});
}
reply.status(202).send(profileId);
}
export async function incrementProfileProperty(
request: FastifyRequest<{
Body: IncrementProfilePayload;
}>,
reply: FastifyReply
) {
const body = request.body;
const profileId: string | null = body.profileId ?? null;
const projectId = request.projectId;
const ip = getClientIp(request)!;
const origin = request.headers.origin ?? projectId;
const ua = request.headers['user-agent']!;
const profile = await findProfile({
ip,
origin,
ua,
profileId,
});
if (!profile) {
return reply.status(404).send('Not found');
}
const property = path(body.property.split('.'), profile.properties);
if (typeof property !== 'number' && typeof property !== 'undefined') {
return reply.status(400).send('Not number');
}
profile.properties = assocPath(
body.property.split('.'),
property ? property + body.value : body.value,
profile.properties
);
await db.profile.update({
where: {
id: profile.id,
},
data: {
properties: profile.properties as any,
},
});
reply.status(202).send(profile.id);
}
export async function decrementProfileProperty(
request: FastifyRequest<{
Body: IncrementProfilePayload;
}>,
reply: FastifyReply
) {
const body = request.body;
const profileId: string | null = body.profileId ?? null;
const projectId = request.projectId;
const ip = getClientIp(request)!;
const origin = request.headers.origin ?? projectId;
const ua = request.headers['user-agent']!;
const profile = await findProfile({
ip,
origin,
ua,
profileId,
});
if (!profile) {
return reply.status(404).send('Not found');
}
const property = path(body.property.split('.'), profile.properties);
if (typeof property !== 'number') {
return reply.status(400).send('Not number');
}
profile.properties = assocPath(
body.property.split('.'),
property ? property - body.value : -body.value,
profile.properties
);
await db.profile.update({
where: {
id: profile.id,
},
data: {
properties: profile.properties as any,
},
});
reply.status(202).send(profile.id);
}

View File

@@ -7,6 +7,7 @@ import { redisPub } from '@mixan/redis';
import eventRouter from './routes/event.router';
import liveRouter from './routes/live.router';
import profileRouter from './routes/profile.router';
declare module 'fastify' {
interface FastifyRequest {
@@ -29,6 +30,7 @@ const startServer = async () => {
fastify.register(FastifySSEPlugin);
fastify.decorateRequest('projectId', '');
fastify.register(eventRouter, { prefix: '/event' });
fastify.register(profileRouter, { prefix: '/profile' });
fastify.register(liveRouter, { prefix: '/live' });
fastify.setErrorHandler((error, request, reply) => {
fastify.log.error(error);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,5 @@
# Snowplow Referer Parser
The file index.ts in this dir is generated from snowplows referer database [Snowplow Referer Parser](https://github.com/snowplow-referer-parser/referer-parser).
The orginal [referers.yml](https://github.com/snowplow-referer-parser/referer-parser/blob/master/resources/referers.yml) is based on Piwik's SearchEngines.php and Socials.php, copyright 2012 Matthieu Aubry and available under the GNU General Public License v3.

View File

@@ -0,0 +1,37 @@
import * as controller from '@/controllers/profile.controller';
import { validateSdkRequest } from '@/utils/auth';
import type { FastifyPluginCallback } from 'fastify';
const eventRouter: FastifyPluginCallback = (fastify, opts, done) => {
fastify.addHook('preHandler', (req, reply, done) => {
validateSdkRequest(req.headers)
.then((projectId) => {
req.projectId = projectId;
done();
})
.catch((e) => {
reply.status(401).send();
});
});
fastify.route({
method: 'POST',
url: '/',
handler: controller.updateProfile,
});
fastify.route({
method: 'POST',
url: '/increment',
handler: controller.incrementProfileProperty,
});
fastify.route({
method: 'POST',
url: '/decrement',
handler: controller.decrementProfileProperty,
});
done();
};
export default eventRouter;

View File

@@ -1,12 +1,34 @@
export async function parseIp(ip: string) {
interface RemoteIpLookupResponse {
country: string | undefined;
city: string | undefined;
stateprov: string | undefined;
continent: string | undefined;
}
interface GeoLocation {
country: string | undefined;
city: string | undefined;
region: string | undefined;
continent: string | undefined;
}
const geo: GeoLocation = {
country: undefined,
city: undefined,
region: undefined,
continent: undefined,
};
const ignore = ['127.0.0.1', '::1'];
export async function parseIp(ip?: string): Promise<GeoLocation> {
if (!ip || ignore.includes(ip)) {
return geo;
}
try {
const geo = await fetch(`${process.env.GEO_IP_HOST}/${ip}`);
const res = (await geo.json()) as {
country: string | undefined;
city: string | undefined;
stateprov: string | undefined;
continent: string | undefined;
};
const res = (await geo.json()) as RemoteIpLookupResponse;
return {
country: res.country,
@@ -16,12 +38,6 @@ export async function parseIp(ip: string) {
};
} catch (e) {
console.log('Failed to parse ip', e);
return {
country: undefined,
city: undefined,
region: undefined,
continent: undefined,
};
return geo;
}
}

View File

@@ -0,0 +1,15 @@
import referrers from '../referrers';
export function parseReferrer(url?: string) {
const { hostname } = new URL(url || '');
const match = referrers[hostname];
console.log('Parsing referrer', url);
console.log('Match', match);
return {
name: match?.name ?? '',
type: match?.type ?? 'unknown',
url: url ?? '',
};
}

View File

@@ -1,2 +1,2 @@
"use strict";(()=>{function y(u,t,e){return function(s,o,v){let m=`${u}${s}`,l,h={"mixan-client-id":t,"Content-Type":"application/json"};return e&&(h["mixan-client-secret"]=e),new Promise(a=>{let f=n=>{clearTimeout(l),fetch(m,{headers:h,method:"POST",body:JSON.stringify(o??{}),keepalive:!0,...v??{}}).then(async p=>{if(p.status!==200)return g(n,a);let w=await p.json();if(!w)return a(null);a(w)}).catch(()=>g(n,a))};function g(n,p){if(n>1)return p(null);l=setTimeout(()=>{f(n+1)},Math.pow(2,n)*500)}f(0)})}}var c=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=y(t.url,t.clientId,t.clientSecret)}init(t){this.state.properties=t??{}}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}async event(t,e){let i=await this.api("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()});this.options.setProfileId&&i&&this.options.setProfileId(i)}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}setUserProperty(t,e,i=!0){}timestamp(){return new Date().toISOString()}};var d=class extends c{constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}getTimezone(){try{return Intl.DateTimeFormat().resolvedOptions().timeZone}catch{return}}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...s){let o=t.apply(this,s);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),o};let e=history.replaceState;history.replaceState=function(...s){let o=e.apply(this,s);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),o},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView())}screenView(t){this.isServer()||super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer})}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
//# sourceMappingURL=op.js.map
"use strict";(()=>{function m(s){return Promise.all(Object.entries(s).map(async([t,e])=>[t,await e??""])).then(t=>Object.fromEntries(t))}function P(s){let t={"Content-Type":"application/json"};return{headers:t,async fetch(e,i,o){let a=`${s}${e}`,h,w=await m(t);return new Promise(p=>{let u=n=>{clearTimeout(h),fetch(a,{headers:w,method:"POST",body:JSON.stringify(i??{}),keepalive:!0,...o??{}}).then(async c=>{if(c.status!==200&&c.status!==202)return f(n,p);let g=await c.text();if(!g)return p(null);p(g)}).catch(()=>f(n,p))};function f(n,c){if(n>1)return c(null);h=setTimeout(()=>{u(n+1)},Math.pow(2,n)*500)}u(0)})}}}var l=class{options;api;state={properties:{}};constructor(t){this.options=t,this.api=P(t.url),this.api.headers["mixan-client-id"]=t.clientId,this.options.clientSecret&&(this.api.headers["mixan-client-secret"]=this.options.clientSecret)}init(t){this.state.properties=t??{}}setUser(t){this.api.fetch("/profile",{profileId:this.getProfileId(),...t,properties:{...this.state.properties,...t.properties}})}increment(t,e){this.api.fetch("/profile/increment",{property:t,value:e,profileId:this.getProfileId()})}decrement(t,e){this.api.fetch("/profile/decrement",{property:t,value:e,profileId:this.getProfileId()})}event(t,e){this.api.fetch("/event",{name:t,properties:{...this.state.properties,...e??{}},timestamp:this.timestamp(),profileId:this.getProfileId()}).then(i=>{this.options.setProfileId&&i&&this.options.setProfileId(i)})}setGlobalProperties(t){this.state.properties={...this.state.properties,...t}}clear(){this.state.profileId=void 0,this.options.removeProfileId&&this.options.removeProfileId()}timestamp(){return new Date().toISOString()}getProfileId(){if(this.state.profileId)return this.state.profileId;this.options.getProfileId&&(this.state.profileId=this.options.getProfileId()||void 0)}};var d=class extends l{lastPath="";constructor(t){super(t),this.options.trackOutgoingLinks&&this.trackOutgoingLinks(),this.options.trackScreenViews&&this.trackScreenViews()}isServer(){return typeof document>"u"}trackOutgoingLinks(){this.isServer()||document.addEventListener("click",t=>{let e=t.target;if(e.tagName==="A"){let i=e.getAttribute("href");i?.startsWith("http")&&super.event("link_out",{href:i,text:e.innerText})}})}trackScreenViews(){if(this.isServer())return;let t=history.pushState;history.pushState=function(...o){let a=t.apply(this,o);return window.dispatchEvent(new Event("pushstate")),window.dispatchEvent(new Event("locationchange")),a};let e=history.replaceState;history.replaceState=function(...o){let a=e.apply(this,o);return window.dispatchEvent(new Event("replacestate")),window.dispatchEvent(new Event("locationchange")),a},window.addEventListener("popstate",()=>window.dispatchEvent(new Event("locationchange"))),this.options.hash?window.addEventListener("hashchange",()=>this.screenView()):window.addEventListener("locationchange",()=>this.screenView()),this.screenView()}screenView(t){this.isServer()||this.lastPath!==window.location.href&&(this.lastPath=window.location.href,super.event("screen_view",{...t??{},path:window.location.href,title:document.title,referrer:document.referrer}))}};var r=document.currentScript;r&&(window.openpanel=new d({url:r?.getAttribute("data-url"),clientId:r?.getAttribute("data-client-id"),clientSecret:r?.getAttribute("data-client-secret"),trackOutgoingLinks:!!r?.getAttribute("data-track-outgoing-links"),trackScreenViews:!!r?.getAttribute("data-track-screen-views")}));})();
//# sourceMappingURL=cdn.global.js.map

View File

@@ -7,8 +7,8 @@ export default function Document() {
<script
async
src="/op.js"
data-url="https://api.openpanel.dev"
data-client-id="301c6dc1-424c-4bc3-9886-a8beab09b615"
data-url="http://localhost:3333"
data-client-id="0acce97f-1126-4439-b7ee-5d384e2fc94b"
data-track-screen-views="1"
data-track-outgoing-links="1"
/>

View File

@@ -1,4 +1,3 @@
import { mixan } from '@/analytics';
import Link from 'next/link';
export default function Test() {
@@ -7,22 +6,33 @@ export default function Test() {
<Link href="/">Home</Link>
<button
onClick={() => {
mixan.setUser({
// @ts-expect-error
window.openpanel.setUser({
first_name: 'John',
});
mixan.setUser({
last_name: 'Doe',
});
mixan.setUser({
email: 'john.doe@gmail.com',
});
mixan.setUser({
id: '1234',
});
}}
>
Set user
</button>
<button
onClick={() => {
// @ts-expect-error
window.openpanel.increment('app_open', 1);
}}
>
Increment
</button>
<button
onClick={() => {
// @ts-expect-error
window.openpanel.decrement('app_open', 1);
}}
>
Decrement
</button>
<button
onClick={() => {
localStorage.clear();
@@ -44,7 +54,8 @@ export default function Test() {
</button>
<button
onClick={() => {
mixan.clear();
// @ts-expect-error
window.openpanel.clear();
}}
>
Logout

View File

@@ -46,6 +46,7 @@
"cmdk": "^0.2.0",
"hamburger-react": "^2.5.0",
"lodash.debounce": "^4.0.8",
"lodash.throttle": "^4.1.1",
"lottie-react": "^2.4.0",
"lucide-react": "^0.323.0",
"mathjs": "^12.3.0",
@@ -84,6 +85,7 @@
"@mixan/tsconfig": "workspace:*",
"@types/bcrypt": "^5.0.0",
"@types/lodash.debounce": "^4.0.9",
"@types/lodash.throttle": "^4.1.9",
"@types/node": "^18.16.0",
"@types/ramda": "^0.29.6",
"@types/react": "^18.2.20",

View File

@@ -198,22 +198,19 @@ export default function OverviewMetrics() {
return (
<Sheet>
<StickyBelowHeader className="p-4 flex gap-2 justify-between">
<ReportRange
size="sm"
value={range}
onChange={(value) => setRange(value)}
/>
<div className="flex-wrap flex gap-2">
<LiveCounter initialCount={0} />
<OverviewFiltersButtons />
<div className="flex gap-2">
<ReportRange value={range} onChange={(value) => setRange(value)} />
<SheetTrigger asChild>
<Button size="sm" variant="cta" icon={FilterIcon}>
<Button variant="outline" responsive icon={FilterIcon}>
Filters
</Button>
</SheetTrigger>
</div>
<div className="flex gap-2">
<LiveCounter initialCount={0} />
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button size="sm" icon={Globe2Icon}>
<Button icon={Globe2Icon} responsive>
Public
</Button>
</DropdownMenuTrigger>
@@ -236,6 +233,10 @@ export default function OverviewMetrics() {
</DropdownMenu>
</div>
</StickyBelowHeader>
<div className="p-4 flex gap-2 flex-wrap">
<OverviewFiltersButtons />
</div>
<div className="p-4 grid gap-4 grid-cols-6">
{reports.map((report, index) => (
<button
@@ -276,7 +277,7 @@ export default function OverviewMetrics() {
</div>
</div>
<SheetContent className="!max-w-lg w-full" side="left">
<SheetContent className="!max-w-lg w-full" side="right">
<OverviewFilters />
</SheetContent>
</Sheet>

View File

@@ -13,9 +13,8 @@ import { ProfileListItem } from './profile-list-item';
interface ListProfilesProps {
projectId: string;
organizationId: string;
}
export function ListProfiles({ organizationId, projectId }: ListProfilesProps) {
export function ListProfiles({ projectId }: ListProfilesProps) {
const [query, setQuery] = useQueryState('q');
const pagination = usePagination();
const profilesQuery = api.profile.list.useQuery(

View File

@@ -13,10 +13,9 @@ export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
await getExists(organizationId, projectId);
return (
<PageLayout title="Events" organizationSlug={organizationId}>
<ListProfiles projectId={projectId} organizationId={organizationId} />
<ListProfiles projectId={projectId} />
</PageLayout>
);
}

View File

@@ -1,17 +1,20 @@
import OverviewMetrics from '@/app/(app)/[organizationId]/[projectId]/overview-metrics';
import { Logo } from '@/components/Logo';
import { getOrganizationByProjectId } from '@/server/services/organization.service';
import { getOrganizationBySlug } from '@/server/services/organization.service';
import { getProjectById } from '@/server/services/project.service';
interface PageProps {
params: {
organizationId: string;
projectId: string;
};
}
export default async function Page({ params: { projectId } }: PageProps) {
export default async function Page({
params: { organizationId, projectId },
}: PageProps) {
const project = await getProjectById(projectId);
const organization = await getOrganizationByProjectId(projectId);
const organization = await getOrganizationBySlug(organizationId);
return (
<div className="p-4 md:p-16 bg-gradient-to-tl from-blue-950 to-blue-600">
<div className="max-w-4xl mx-auto">

View File

@@ -1,6 +0,0 @@
import { authOptions } from '@/server/auth';
import NextAuth from 'next-auth/next';
const handler = NextAuth(authOptions);
export { handler as GET, handler as POST };

View File

@@ -1,13 +1,8 @@
'use client';
import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import { cn } from '@/utils/cn';
import { X } from 'lucide-react';
import { Button } from '../ui/button';
import { Combobox } from '../ui/combobox';
import { Label } from '../ui/label';
import { useOverviewOptions } from './useOverviewOptions';
export function OverviewFiltersButtons() {
@@ -17,111 +12,166 @@ export function OverviewFiltersButtons() {
{options.referrer && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setReferrer(null)}
>
{options.referrer}
<span className="mr-1">Referrer is</span>
<strong>{options.referrer}</strong>
</Button>
)}
{options.device && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setDevice(null)}
>
{options.device}
<span className="mr-1">Device is</span>
<strong>{options.device}</strong>
</Button>
)}
{options.page && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setPage(null)}
>
{options.page}
<span className="mr-1">Page is</span>
<strong>{options.page}</strong>
</Button>
)}
{options.utmSource && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmSource(null)}
>
{options.utmSource}
<span className="mr-1">Utm Source is</span>
<strong>{options.utmSource}</strong>
</Button>
)}
{options.utmMedium && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmMedium(null)}
>
{options.utmMedium}
<span className="mr-1">Utm Medium is</span>
<strong>{options.utmMedium}</strong>
</Button>
)}
{options.utmCampaign && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmCampaign(null)}
>
{options.utmCampaign}
<span className="mr-1">Utm Campaign is</span>
<strong>{options.utmCampaign}</strong>
</Button>
)}
{options.utmTerm && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmTerm(null)}
>
{options.utmTerm}
<span className="mr-1">Utm Term is</span>
<strong>{options.utmTerm}</strong>
</Button>
)}
{options.utmContent && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setUtmContent(null)}
>
{options.utmContent}
<span className="mr-1">Utm Content is</span>
<strong>{options.utmContent}</strong>
</Button>
)}
{options.country && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setCountry(null)}
>
{options.country}
<span className="mr-1">Country is</span>
<strong>{options.country}</strong>
</Button>
)}
{options.region && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setRegion(null)}
>
{options.region}
<span className="mr-1">Region is</span>
<strong>{options.region}</strong>
</Button>
)}
{options.city && (
<Button
size="sm"
variant="ghost"
variant="outline"
icon={X}
onClick={() => options.setCity(null)}
>
{options.city}
<span className="mr-1">City is</span>
<strong>{options.city}</strong>
</Button>
)}
{options.browser && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setBrowser(null)}
>
<span className="mr-1">Browser is</span>
<strong>{options.browser}</strong>
</Button>
)}
{options.browserVersion && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setBrowserVersion(null)}
>
<span className="mr-1">Browser Version is</span>
<strong>{options.browserVersion}</strong>
</Button>
)}
{options.os && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setOS(null)}
>
<span className="mr-1">OS is</span>
<strong>{options.os}</strong>
</Button>
)}
{options.osVersion && (
<Button
size="sm"
variant="outline"
icon={X}
onClick={() => options.setOSVersion(null)}
>
<span className="mr-1">OS Version is</span>
<strong>{options.osVersion}</strong>
</Button>
)}
</>

View File

@@ -11,8 +11,20 @@ import { useOverviewOptions } from './useOverviewOptions';
import { useOverviewWidget } from './useOverviewWidget';
export default function OverviewTopDevices() {
const { filters, interval, range, previous, setCountry, setRegion, setCity } =
useOverviewOptions();
const {
filters,
interval,
range,
previous,
setBrowser,
setBrowserVersion,
browser,
browserVersion,
setOS,
setOSVersion,
os,
osVersion,
} = useOverviewOptions();
const [widget, setWidget, widgets] = useOverviewWidget('tech', {
devices: {
title: 'Top devices',
@@ -180,19 +192,22 @@ export default function OverviewTopDevices() {
{...widget.chart}
previous={false}
onClick={(item) => {
// switch (widget.key) {
// case 'browser':
// setWidget('browser_version');
// // setCountry(item.name);
// break;
// case 'regions':
// setWidget('cities');
// setRegion(item.name);
// break;
// case 'cities':
// setCity(item.name);
// break;
// }
switch (widget.key) {
case 'browser':
setWidget('browser_version');
setBrowser(item.name);
break;
case 'browser_version':
setBrowserVersion(item.name);
break;
case 'os':
setWidget('os_version');
setOS(item.name);
break;
case 'os_version':
setOSVersion(item.name);
break;
}
}}
/>
</Suspense>

View File

@@ -1,7 +1,19 @@
'use client';
import { Children, useCallback, useEffect, useRef, useState } from 'react';
import { useThrottle } from '@/hooks/useThrottle';
import { cn } from '@/utils/cn';
import throttle from 'lodash.throttle';
import { ChevronsUpDownIcon } from 'lucide-react';
import { last } from 'ramda';
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuGroup,
DropdownMenuItem,
DropdownMenuTrigger,
} from '../ui/dropdown-menu';
import type { WidgetHeadProps } from '../Widget';
import { WidgetHead as WidgetHeadBase } from '../Widget';
@@ -14,14 +26,98 @@ export function WidgetHead({ className, ...props }: WidgetHeadProps) {
);
}
export function WidgetButtons({ className, ...props }: WidgetHeadProps) {
export function WidgetButtons({
className,
children,
...props
}: WidgetHeadProps) {
const container = useRef<HTMLDivElement>(null);
const sizes = useRef<number[]>([]);
const [slice, setSlice] = useState(Children.count(children) - 1);
const gap = 8;
const handleResize = useThrottle(() => {
if (container.current) {
if (sizes.current.length === 0) {
// Get buttons
const buttons: HTMLButtonElement[] = Array.from(
container.current.querySelectorAll(`button`)
);
// Get sizes and cache them
sizes.current = buttons.map(
(button) => Math.ceil(button.offsetWidth) + gap
);
}
const containerWidth = container.current.offsetWidth;
const buttonsWidth = sizes.current.reduce((acc, size) => acc + size, 0);
const moreWidth = (last(sizes.current) ?? 0) + gap;
if (buttonsWidth > containerWidth) {
const res = sizes.current.reduce(
(acc, size, index) => {
if (acc.size + size + moreWidth > containerWidth) {
return { index: acc.index, size: acc.size + size };
}
return { index, size: acc.size + size };
},
{ index: 0, size: 0 }
);
setSlice(res.index);
} else {
setSlice(sizes.current.length - 1);
}
}
}, 30);
useEffect(() => {
handleResize();
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize, children]);
const hidden = '!opacity-0 absolute pointer-events-none';
return (
<div
ref={container}
className={cn(
'flex gap-2 [&_button]:text-xs [&_button]:opacity-50 [&_button.active]:opacity-100',
'flex-1 justify-end transition-opacity flex flex-wrap [&_button]:text-xs [&_button]:opacity-50 [&_button]:whitespace-nowrap [&_button.active]:opacity-100',
className
)}
style={{ gap }}
{...props}
/>
>
{Children.map(children, (child, index) => {
return (
<div className={cn('flex', slice < index ? hidden : 'opacity-100')}>
{child}
</div>
);
})}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<button
className={cn(
'flex items-center gap-1 select-none',
sizes.current.length - 1 === slice ? hidden : 'opacity-50'
)}
>
More <ChevronsUpDownIcon size={12} />
</button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="[&_button]:w-full">
<DropdownMenuGroup>
{Children.map(children, (child, index) => {
if (index <= slice) {
return null;
}
return <DropdownMenuItem asChild>{child}</DropdownMenuItem>;
})}
</DropdownMenuGroup>
</DropdownMenuContent>
</DropdownMenu>
</div>
);
}

View File

@@ -34,15 +34,12 @@ export function useOverviewOptions() {
'referrer',
parseAsString.withOptions(nuqsOptions)
);
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [page, setPage] = useQueryState(
'page',
parseAsString.withOptions(nuqsOptions)
);
// Sources
const [utmSource, setUtmSource] = useQueryState(
'utm_source',
parseAsString.withOptions(nuqsOptions)
@@ -64,6 +61,7 @@ export function useOverviewOptions() {
parseAsString.withOptions(nuqsOptions)
);
// Geo
const [country, setCountry] = useQueryState(
'country',
parseAsString.withOptions(nuqsOptions)
@@ -77,6 +75,28 @@ export function useOverviewOptions() {
parseAsString.withOptions(nuqsOptions)
);
//
const [device, setDevice] = useQueryState(
'device',
parseAsString.withOptions(nuqsOptions)
);
const [browser, setBrowser] = useQueryState(
'browser',
parseAsString.withOptions(nuqsOptions)
);
const [browserVersion, setBrowserVersion] = useQueryState(
'browser_version',
parseAsString.withOptions(nuqsOptions)
);
const [os, setOS] = useQueryState(
'os',
parseAsString.withOptions(nuqsOptions)
);
const [osVersion, setOSVersion] = useQueryState(
'os_version',
parseAsString.withOptions(nuqsOptions)
);
const filters = useMemo(() => {
const filters: IChartInput['events'][number]['filters'] = [];
if (referrer) {
@@ -178,6 +198,42 @@ export function useOverviewOptions() {
});
}
if (browser) {
filters.push({
id: 'browser',
operator: 'is',
name: 'browser',
value: [browser],
});
}
if (browserVersion) {
filters.push({
id: 'browser_version',
operator: 'is',
name: 'browser_version',
value: [browserVersion],
});
}
if (os) {
filters.push({
id: 'os',
operator: 'is',
name: 'os',
value: [os],
});
}
if (osVersion) {
filters.push({
id: 'os_version',
operator: 'is',
name: 'os_version',
value: [osVersion],
});
}
return filters;
}, [
referrer,
@@ -191,6 +247,10 @@ export function useOverviewOptions() {
country,
region,
city,
browser,
browserVersion,
os,
osVersion,
]);
return {
@@ -202,8 +262,6 @@ export function useOverviewOptions() {
setMetric,
referrer,
setReferrer,
device,
setDevice,
page,
setPage,
@@ -230,5 +288,17 @@ export function useOverviewOptions() {
setRegion,
city,
setCity,
// Tech
device,
setDevice,
browser,
setBrowser,
browserVersion,
setBrowserVersion,
os,
setOS,
osVersion,
setOSVersion,
};
}

View File

@@ -1,10 +1,11 @@
import { createContext, memo, useContext, useMemo } from 'react';
import type { IChartSerie } from '@/server/api/routers/chart';
import type { IChartInput } from '@/types';
export interface ChartContextType extends IChartInput {
editMode?: boolean;
hideID?: boolean;
onClick?: (item: any) => void;
onClick?: (item: IChartSerie) => void;
}
type ChartProviderProps = {

View File

@@ -17,6 +17,7 @@ import {
} from '@/components/ui/tooltip';
import { useNumber } from '@/hooks/useNumerFormatter';
import { cn } from '@/utils/cn';
import { NOT_SET_VALUE } from '@/utils/constants';
import { getChartColor } from '@/utils/theme';
import { createColumnHelper } from '@tanstack/react-table';
@@ -51,10 +52,15 @@ export function ReportBarChart({ data }: ReportBarChartProps) {
</div>
)}
{series.map((serie, index) => {
const isClickable = serie.name !== NOT_SET_VALUE && onClick;
return (
<div
key={serie.name}
className="py-2 flex flex-1 w-full gap-4 items-center"
className={cn(
'py-2 flex flex-1 w-full gap-4 items-center',
isClickable && 'cursor-pointer hover:bg-gray-100'
)}
{...(isClickable ? { onClick: () => onClick(serie) } : {})}
>
<div className="flex-1 break-all">{serie.name}</div>
<div className="flex-shrink-0 flex w-1/4 gap-4 items-center justify-end">

View File

@@ -6,7 +6,6 @@ import { api } from '@/app/_trpc/client';
import { useAppParams } from '@/hooks/useAppParams';
import type { IChartInput } from '@/types';
import { ChartAnimation, ChartAnimationContainer } from './ChartAnimation';
import { ChartEmpty } from './ChartEmpty';
import { withChartProivder } from './ChartProvider';
import { ReportAreaChart } from './ReportAreaChart';

View File

@@ -44,6 +44,7 @@ export interface ButtonProps
asChild?: boolean;
loading?: boolean;
icon?: LucideIcon;
responsive?: boolean;
}
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
@@ -57,6 +58,7 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
loading,
disabled,
icon,
responsive,
...props
},
ref
@@ -71,9 +73,19 @@ const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
{...props}
>
{Icon && (
<Icon className={cn('h-4 w-4 mr-2', loading && 'animate-spin')} />
<Icon
className={cn(
'h-4 w-4 mr-2',
responsive && 'mr-0 sm:mr-2',
loading && 'animate-spin'
)}
/>
)}
{responsive ? (
<span className="hidden sm:block">{children}</span>
) : (
children
)}
{children}
</Comp>
);
}

View File

@@ -0,0 +1,15 @@
import { useCallback, useEffect, useRef } from 'react';
import throttle from 'lodash.throttle';
export function useThrottle(cb: () => void, delay: number) {
const options = { leading: true, trailing: false }; // add custom lodash options
const cbRef = useRef(cb);
// use mutable ref to make useCallback/throttle not depend on `cb` dep
useEffect(() => {
cbRef.current = cb;
});
return useCallback(
throttle(() => cbRef.current(), delay, options),
[delay]
);
}

View File

@@ -1,18 +0,0 @@
import * as cache from '@/server/cache';
import { db } from '@/server/db';
import { getUniqueEvents } from '@/server/services/event.service';
import type { NextApiRequest, NextApiResponse } from 'next';
export default async function handler(
req: NextApiRequest,
res: NextApiResponse
) {
const projects = await db.project.findMany();
for (const project of projects) {
const events = await getUniqueEvents({ projectId: project.id });
cache.set(`events_${project.id}`, 1000 * 60 * 60 * 24, events);
}
res.status(200).json({ ok: true });
}

View File

@@ -1,41 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import { eventsQueue } from '@mixan/queue';
import type { BatchPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: BatchPayload[];
}
export const config = {
api: {
responseLimit: false,
},
};
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
await eventsQueue.add('batch', {
projectId,
payload: req.body,
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,40 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { EventPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: EventPayload[];
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
await db.event.createMany({
data: req.body.map((event) => ({
name: event.name,
properties: event.properties,
createdAt: event.time,
project_id: projectId,
profile_id: event.profileId,
})),
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,38 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfileIncrementPayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'PUT') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req, res);
const profileId = req.query.profileId as string;
await tickProfileProperty({
name: req.body.name,
tick: -Math.abs(req.body.value),
profileId,
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,38 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { createError, handleError } from '@/server/exceptions';
import { tickProfileProperty } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ProfileIncrementPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfileIncrementPayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'PUT') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req, res);
const profileId = req.query.profileId as string;
await tickProfileProperty({
name: req.body.name,
tick: req.body.value,
profileId,
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,54 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import { getProfile } from '@/server/services/profile.service';
import type { NextApiRequest, NextApiResponse } from 'next';
import type { ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfilePayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'PUT' && req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
await validateSdkRequest(req, res);
const profileId = req.query.profileId as string;
const profile = await getProfile(profileId);
const { body } = req;
await db.profile.update({
where: {
id: profileId,
},
data: {
external_id: body.id,
email: body.email,
first_name: body.first_name,
last_name: body.last_name,
avatar: body.avatar,
properties: {
...(typeof profile.properties === 'object'
? profile.properties ?? {}
: {}),
...(body.properties ?? {}),
},
},
});
res.status(200).json({ status: 'ok' });
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,51 +0,0 @@
import { validateSdkRequest } from '@/server/auth';
import { db } from '@/server/db';
import { createError, handleError } from '@/server/exceptions';
import type { NextApiRequest, NextApiResponse } from 'next';
import randomAnimalName from 'random-animal-name';
import type { CreateProfileResponse, ProfilePayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: ProfilePayload;
}
export default async function handler(req: Request, res: NextApiResponse) {
if (req.method == 'OPTIONS') {
await validateSdkRequest(req, res);
return res.status(200).json({});
}
if (req.method !== 'POST') {
return handleError(res, createError(405, 'Method not allowed'));
}
try {
// Check client id & secret
const projectId = await validateSdkRequest(req, res);
// Providing an `ID` is deprecated, should be removed in the future
const profileId = 'id' in req.body ? req.body.id : undefined;
const { properties } = req.body ?? {};
const profile = await db.profile.create({
data: {
id: profileId,
external_id: null,
email: null,
first_name: randomAnimalName(),
last_name: null,
avatar: null,
properties: {
...(properties ?? {}),
},
project_id: projectId,
},
});
const response: CreateProfileResponse = { id: profile.id };
res.status(200).json(response);
} catch (error) {
handleError(res, error);
}
}

View File

@@ -1,32 +0,0 @@
import type { NextApiRequest, NextApiResponse } from 'next';
import { eventsQueue } from '@mixan/queue';
import type { BatchPayload } from '@mixan/types';
interface Request extends NextApiRequest {
body: BatchPayload[];
}
export const config = {
api: {
responseLimit: false,
},
};
export default function handler(req: Request, res: NextApiResponse) {
eventsQueue.add('batch', {
payload: [
{
type: 'event',
payload: {
profileId: 'f8235c6a-c720-4f38-8f6c-b6b7d31e16db',
name: 'test',
properties: {},
time: new Date().toISOString(),
},
},
],
projectId: 'b725eadb-a1fe-4be8-bf0b-9d9bfa6aac12',
});
res.status(200).json({ status: 'ok' });
}

View File

@@ -5,7 +5,7 @@ import type {
IGetChartDataInput,
IInterval,
} from '@/types';
import { alphabetIds } from '@/utils/constants';
import { alphabetIds, NOT_SET_VALUE } from '@/utils/constants';
import { round } from '@/utils/math';
import * as mathjs from 'mathjs';
import { sort } from 'ramda';
@@ -207,7 +207,7 @@ export async function getChartData(payload: IGetChartDataInput) {
(acc, item) => {
// item.label can be null when using breakdowns on a property
// that doesn't exist on all events
const label = item.label?.trim() || '(not set)';
const label = item.label?.trim() || NOT_SET_VALUE;
if (label) {
if (acc[label]) {
acc[label]?.push(item);

View File

@@ -29,19 +29,21 @@ interface Metrics {
};
}
interface FinalChart {
events: IChartInput['events'];
series: {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
export interface IChartSerie {
name: string;
event: IChartEvent;
metrics: Metrics;
data: {
date: string;
count: number;
label: string | null;
previous: PreviousValue;
}[];
}
export interface FinalChart {
events: IChartInput['events'];
series: IChartSerie[];
metrics: Metrics;
}

View File

@@ -1,9 +1,10 @@
import { randomUUID } from 'crypto';
import { createTRPCRouter, protectedProcedure } from '@/server/api/trpc';
import { db } from '@/server/db';
import { hashPassword } from '@/server/services/hash.service';
import { z } from 'zod';
import { hashPassword } from '@mixan/common';
export const clientRouter = createTRPCRouter({
list: protectedProcedure
.input(

View File

@@ -29,6 +29,8 @@ export const eventRouter = createTRPCRouter({
sb.where.name = `name IN (${events.map((e) => `'${e}'`).join(',')})`;
}
sb.orderBy.created_at = 'created_at DESC';
return (await chQuery<IDBEvent>(getSql())).map(transformEvent);
}),
});

View File

@@ -1,43 +0,0 @@
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
const keyLength = 32;
/**
* Has a password or a secret with a password hashing algorithm (scrypt)
* @param {string} password
* @returns {string} The salt+hash
*/
export async function hashPassword(password: string): Promise<string> {
return new Promise((resolve, reject) => {
// generate random 16 bytes long salt - recommended by NodeJS Docs
const salt = randomBytes(16).toString('hex');
scrypt(password, salt, keyLength, (err, derivedKey) => {
if (err) reject(err);
// derivedKey is of type Buffer
resolve(`${salt}.${derivedKey.toString('hex')}`);
});
});
}
/**
* Compare a plain text password with a salt+hash password
* @param {string} password The plain text password
* @param {string} hash The hash+salt to check against
* @returns {boolean}
*/
export async function verifyPassword(
password: string,
hash: string
): Promise<boolean> {
return new Promise((resolve, reject) => {
const [salt, hashKey] = hash.split('.');
// we need to pass buffer values to timingSafeEqual
const hashKeyBuff = Buffer.from(hashKey!, 'hex');
scrypt(password, salt!, keyLength, (err, derivedKey) => {
if (err) {
reject(err);
}
// compare the new supplied password with the hashed password using timeSafeEqual
resolve(timingSafeEqual(hashKeyBuff, derivedKey));
});
});
}

View File

@@ -1,3 +1,5 @@
export const NOT_SET_VALUE = '(not set)';
export const operators = {
is: 'Is',
isNot: 'Is not',

View File

@@ -2,3 +2,4 @@ export * from './src/crypto';
export * from './src/profileId';
export * from './src/date';
export * from './src/object';
export * from './src/names';

View File

@@ -8,7 +8,8 @@
"typecheck": "tsc --noEmit"
},
"dependencies": {
"ramda": "^0.29.1"
"ramda": "^0.29.1",
"unique-names-generator": "^4.7.1"
},
"devDependencies": {
"@mixan/eslint-config": "workspace:*",

View File

@@ -1,4 +1,9 @@
import { randomBytes, scrypt, timingSafeEqual } from 'crypto';
import {
createHash as cryptoCreateHash,
randomBytes,
scrypt,
timingSafeEqual,
} from 'crypto';
export function generateSalt() {
return randomBytes(16).toString('hex');
@@ -11,12 +16,11 @@ export function generateSalt() {
*/
export async function hashPassword(
password: string,
_salt?: string,
keyLength = 32
): Promise<string> {
return new Promise((resolve, reject) => {
// generate random 16 bytes long salt - recommended by NodeJS Docs
const salt = _salt || generateSalt();
const salt = generateSalt();
scrypt(password, salt, keyLength, (err, derivedKey) => {
if (err) reject(err);
// derivedKey is of type Buffer
@@ -49,3 +53,9 @@ export async function verifyPassword(
});
});
}
export function createHash(data: string, len: number) {
return cryptoCreateHash('shake256', { outputLength: len })
.update(data)
.digest('hex');
}

View File

@@ -0,0 +1,18 @@
import { animals, names, uniqueNamesGenerator } from 'unique-names-generator';
export function randomName() {
return uniqueNamesGenerator({
dictionaries: [names, animals],
length: 2,
style: 'capital',
separator: ' ',
});
}
export function randomSplitName() {
const [firstName, lastName] = randomName().split(' ');
return {
firstName,
lastName,
};
}

View File

@@ -1,4 +1,4 @@
import { hashPassword } from './crypto';
import { createHash } from './crypto';
interface GenerateProfileIdOptions {
salt: string;
@@ -7,11 +7,11 @@ interface GenerateProfileIdOptions {
origin: string;
}
export async function generateProfileId({
export function generateProfileId({
salt,
ua,
ip,
origin,
}: GenerateProfileIdOptions) {
return await hashPassword(`${ua}:${ip}:${origin}`, salt, 8);
return createHash(`${ua}:${ip}:${origin}:${salt}`, 16);
}

View File

@@ -2,15 +2,10 @@ CREATE TABLE openpanel.events (
`name` String,
`profile_id` String,
`project_id` String,
-- the route
`path` String,
`utm_source` String,
`utm_medium` String,
`utm_campaign` String,
`utm_term` String,
`utm_content` String,
`referrer` String,
`referrer_name` String,
`referrer_type` String,
`duration` UInt64,
`properties` Map(String, String),
`created_at` DateTime64(3),

View File

@@ -97,24 +97,23 @@ async function main() {
const ua = event.properties.ua as string;
const uaInfo = parseUserAgent(ua);
const salts = await getSalts();
const currentProfileId = generateProfileId({
salt: salts.current,
origin,
ip,
ua,
});
const previousProfileId = generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
});
const [currentProfileId, previousProfileId, geo, eventsJobs] =
await Promise.all([
generateProfileId({
salt: salts.current,
origin,
ip,
ua,
}),
generateProfileId({
salt: salts.previous,
origin,
ip,
ua,
}),
parseIp(ip),
eventsQueue.getJobs(['delayed']),
]);
const [geo, eventsJobs] = Promise.all([
parseIp(ip),
eventsQueue.getJobs(['delayed']),
]);
const payload: IServiceCreateEventPayload = {
name: body.name,
profileId,

View File

@@ -1,5 +1,7 @@
/* eslint-disable */
import { Profile } from '@prisma/client';
export type IDBEvent = {
id: string;
name: string;
@@ -9,14 +11,6 @@ export type IDBEvent = {
created_at: string;
};
export type IDBProfile = {
id: string;
external_id?: string;
first_name?: string;
last_name?: string;
email?: string;
avatar?: string;
properties: Record<string, string>;
project_id: String;
created_at: string;
export type IDBProfile = Omit<Profile, 'properties'> & {
properties: Record<string, unknown>;
};

View File

@@ -1,9 +1,10 @@
import { omit } from 'ramda';
import { toDots } from '@mixan/common';
import { redisPub } from '@mixan/redis';
import { randomSplitName, toDots } from '@mixan/common';
import { redis, redisPub } from '@mixan/redis';
import { ch, chQuery, formatClickhouseDate } from '../clickhouse-client';
import { db } from '../prisma-client';
export interface IClickhouseEvent {
name: string;
@@ -12,6 +13,7 @@ export interface IClickhouseEvent {
path: string;
referrer: string;
referrer_name: string;
referrer_type: string;
duration: number;
properties: Record<string, string | number | boolean>;
created_at: string;
@@ -50,6 +52,7 @@ export function transformEvent(
path: event.path,
referrer: event.referrer,
referrerName: event.referrer_name,
referrerType: event.referrer_type,
};
}
@@ -77,6 +80,7 @@ export interface IServiceCreateEventPayload {
path: string;
referrer: string | undefined;
referrerName: string | undefined;
referrerType: string | undefined;
}
export function getEvents(sql: string) {
@@ -88,6 +92,41 @@ export function getEvents(sql: string) {
export async function createEvent(payload: IServiceCreateEventPayload) {
console.log(`create event ${payload.name} for ${payload.profileId}`);
if (payload.name === 'session_start') {
const profile = await db.profile.findUnique({
where: {
id: payload.profileId,
},
});
if (!profile) {
const { firstName, lastName } = randomSplitName();
await db.profile.create({
data: {
id: payload.profileId,
project_id: payload.projectId,
first_name: firstName,
last_name: lastName,
properties: {
country: payload.country ?? '',
city: payload.city ?? '',
region: payload.region ?? '',
os: payload.os ?? '',
os_version: payload.osVersion ?? '',
browser: payload.browser ?? '',
browser_version: payload.browserVersion ?? '',
device: payload.device ?? '',
brand: payload.brand ?? '',
model: payload.model ?? '',
referrer: payload.referrer ?? '',
referrer_name: payload.referrerName ?? '',
referrer_type: payload.referrerType ?? '',
},
},
});
}
}
if (payload.properties.hash === '') {
delete payload.properties.hash;
}
@@ -112,6 +151,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
duration: payload.duration,
referrer: payload.referrer ?? '',
referrer_name: payload.referrerName ?? '',
referrer_type: payload.referrerType ?? '',
};
const res = await ch.insert({
@@ -121,6 +161,7 @@ export async function createEvent(payload: IServiceCreateEventPayload) {
});
redisPub.publish('event', JSON.stringify(transformEvent(event)));
redis.set(`live:event:${event.project_id}:${event.profile_id}`, '', 'EX', 10);
return {
...res,

View File

@@ -1,21 +1,46 @@
import type { NewMixanOptions } from '@mixan/sdk';
import { AppState, Platform } from 'react-native';
import * as Application from 'expo-application';
import Constants from 'expo-constants';
import * as Network from 'expo-network';
import type { MixanOptions } from '@mixan/sdk';
import { Mixan } from '@mixan/sdk';
export class MixanNative extends Mixan {
constructor(options: NewMixanOptions) {
type MixanNativeOptions = MixanOptions & {
ipUrl?: string;
};
export class MixanNative extends Mixan<MixanNativeOptions> {
constructor(options: MixanNativeOptions) {
super(options);
this.api.headers['X-Forwarded-For'] = Network.getIpAddressAsync();
this.api.headers['User-Agent'] = Constants.getWebViewUserAgentAsync();
AppState.addEventListener('change', (state) => {
if (state === 'active') {
this.setProperties();
}
});
this.setProperties();
}
init(properties?: Record<string, unknown>) {
super.init({
...(properties ?? {}),
private async setProperties() {
this.setGlobalProperties({
version: Application.nativeApplicationVersion,
buildNumber: Application.nativeBuildVersion,
referrer:
Platform.OS === 'android'
? await Application.getInstallReferrerAsync()
: undefined,
});
}
screenView(route: string, properties?: Record<string, unknown>): void {
public screenView(route: string, properties?: Record<string, unknown>): void {
super.event('screen_view', {
...properties,
route: route,
path: route,
});
}
}

View File

@@ -21,6 +21,12 @@
"tsup": "^7.2.0",
"typescript": "^5.2.2"
},
"peerDependencies": {
"react-native": "^0.72.5",
"expo-application": "~5.3.0",
"expo-constants": "~14.4.2",
"expo-network": "~5.8.0"
},
"eslintConfig": {
"root": true,
"extends": [

View File

@@ -1,7 +1,8 @@
import { defineConfig } from 'tsup';
import config from '@mixan/tsconfig/tsup.config.json' assert {
type: 'json'
}
import config from '@mixan/tsconfig/tsup.config.json' assert { type: 'json' };
export default defineConfig(config as any);
export default defineConfig({
...(config as any),
minify: false,
});

View File

@@ -7,7 +7,6 @@ if (el) {
window.openpanel = new Openpanel({
url: el?.getAttribute('data-url'),
clientId: el?.getAttribute('data-client-id'),
clientSecret: el?.getAttribute('data-client-secret'),
trackOutgoingLinks: !!el?.getAttribute('data-track-outgoing-links'),
trackScreenViews: !!el?.getAttribute('data-track-screen-views'),
});

View File

@@ -8,6 +8,8 @@ type MixanWebOptions = MixanOptions & {
};
export class MixanWeb extends Mixan<MixanWebOptions> {
private lastPath = '';
constructor(options: MixanWebOptions) {
super(options);
@@ -24,14 +26,6 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
return typeof document === 'undefined';
}
private getTimezone() {
try {
return Intl.DateTimeFormat().resolvedOptions().timeZone;
} catch (e) {
return undefined;
}
}
public trackOutgoingLinks() {
if (this.isServer()) {
return;
@@ -90,9 +84,16 @@ export class MixanWeb extends Mixan<MixanWebOptions> {
return;
}
const path = window.location.href;
if (this.lastPath === path) {
return;
}
this.lastPath = path;
super.event('screen_view', {
...(properties ?? {}),
path: window.location.href,
path,
title: document.title,
referrer: document.referrer,
});

View File

@@ -4,7 +4,7 @@
"module": "index.ts",
"scripts": {
"build": "rm -rf dist && tsup",
"build-for-openpanel": "pnpm build && cp dist/cdn.global.js ../../apps/public/public/op.js",
"build-for-openpanel": "pnpm build && cp dist/cdn.global.js ../../apps/public/public/op.js && cp dist/cdn.global.js ../../apps/test/public/op.js",
"lint": "eslint .",
"format": "prettier --check \"**/*.{mjs,ts,md,json}\"",
"typecheck": "tsc --noEmit"

View File

@@ -1,7 +1,9 @@
import { defineConfig } from 'tsup';
import config from '@mixan/tsconfig/tsup.config.json' assert {
type: 'json'
}
import config from '@mixan/tsconfig/tsup.config.json' assert { type: 'json' };
export default defineConfig(config as any);
export default defineConfig({
...(config as any),
entry: ['index.ts', 'cdn.ts'],
format: ['cjs', 'esm', 'iife'],
});

View File

@@ -1,4 +1,9 @@
import type { PostEventPayload } from '@mixan/types';
import type {
DecrementProfilePayload,
IncrementProfilePayload,
PostEventPayload,
UpdateProfilePayload,
} from '@mixan/types';
export interface MixanOptions {
url: string;
@@ -15,80 +20,94 @@ export interface MixanState {
properties: Record<string, unknown>;
}
function createApi(_url: string, clientId: string, clientSecret?: string) {
return function post<ReqBody, ResBody>(
path: string,
data: ReqBody,
options?: RequestInit
): Promise<ResBody | null> {
const url = `${_url}${path}`;
let timer: ReturnType<typeof setTimeout>;
const headers: Record<string, string> = {
'mixan-client-id': clientId,
'Content-Type': 'application/json',
};
if (clientSecret) {
headers['mixan-client-secret'] = clientSecret;
}
return new Promise((resolve) => {
const wrappedFetch = (attempt: number) => {
clearTimeout(timer);
fetch(url, {
headers,
method: 'POST',
body: JSON.stringify(data ?? {}),
keepalive: true,
...(options ?? {}),
})
.then(async (res) => {
if (res.status !== 200 && res.status !== 202) {
return retry(attempt, resolve);
}
function awaitProperties(
properties: Record<string, string | Promise<string | null>>
): Promise<Record<string, string>> {
return Promise.all(
Object.entries(properties).map(async ([key, value]) => {
return [key, (await value) ?? ''];
})
).then((entries) => Object.fromEntries(entries));
}
const response = await res.text();
if (!response) {
return resolve(null);
}
resolve(response as ResBody);
function createApi(_url: string) {
const headers: Record<string, string | Promise<string | null>> = {
'Content-Type': 'application/json',
};
return {
headers,
async fetch<ReqBody, ResBody>(
path: string,
data: ReqBody,
options?: RequestInit
): Promise<ResBody | null> {
const url = `${_url}${path}`;
let timer: ReturnType<typeof setTimeout>;
const h = await awaitProperties(headers);
return new Promise((resolve) => {
const wrappedFetch = (attempt: number) => {
clearTimeout(timer);
fetch(url, {
headers: h,
method: 'POST',
body: JSON.stringify(data ?? {}),
keepalive: true,
...(options ?? {}),
})
.catch(() => {
return retry(attempt, resolve);
});
};
.then(async (res) => {
if (res.status !== 200 && res.status !== 202) {
return retry(attempt, resolve);
}
function retry(
attempt: number,
resolve: (value: ResBody | null) => void
) {
if (attempt > 1) {
return resolve(null);
const response = await res.text();
if (!response) {
return resolve(null);
}
resolve(response as ResBody);
})
.catch(() => {
return retry(attempt, resolve);
});
};
function retry(
attempt: number,
resolve: (value: ResBody | null) => void
) {
if (attempt > 1) {
return resolve(null);
}
timer = setTimeout(
() => {
wrappedFetch(attempt + 1);
},
Math.pow(2, attempt) * 500
);
}
timer = setTimeout(
() => {
wrappedFetch(attempt + 1);
},
Math.pow(2, attempt) * 500
);
}
wrappedFetch(0);
});
wrappedFetch(0);
});
},
};
}
export class Mixan<Options extends MixanOptions = MixanOptions> {
public options: Options;
private api: ReturnType<typeof createApi>;
public api: ReturnType<typeof createApi>;
private state: MixanState = {
properties: {},
};
constructor(options: Options) {
this.options = options;
this.api = createApi(options.url, options.clientId, options.clientSecret);
this.api = createApi(options.url);
this.api.headers['mixan-client-id'] = options.clientId;
if (this.options.clientSecret) {
this.api.headers['mixan-client-secret'] = this.options.clientSecret;
}
}
// Public
@@ -97,61 +116,49 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
this.state.properties = properties ?? {};
}
// public setUser(payload: Omit<BatchUpdateProfilePayload, 'profileId'>) {
// this.batcher.add({
// type: 'update_profile',
// payload: {
// ...payload,
// properties: payload.properties ?? {},
// profileId: this.state.profileId,
// },
// });
// }
// public increment(name: string, value: number) {
// this.batcher.add({
// type: 'increment',
// payload: {
// name,
// value,
// profileId: this.state.profileId,
// },
// });
// }
// public decrement(name: string, value: number) {
// this.batcher.add({
// type: 'decrement',
// payload: {
// name,
// value,
// profileId: this.state.profileId,
// },
// });
// }
private getProfileId() {
if (this.state.profileId) {
return this.state.profileId;
} else if (this.options.getProfileId) {
this.state.profileId = this.options.getProfileId() || undefined;
}
}
public async event(name: string, properties?: Record<string, unknown>) {
const profileId = await this.api<PostEventPayload, string>('/event', {
name,
public setUser(payload: Omit<UpdateProfilePayload, 'profileId'>) {
this.api.fetch<UpdateProfilePayload, string>('/profile', {
profileId: this.getProfileId(),
...payload,
properties: {
...this.state.properties,
...(properties ?? {}),
...payload.properties,
},
timestamp: this.timestamp(),
});
}
public increment(property: string, value: number) {
this.api.fetch<IncrementProfilePayload, string>('/profile/increment', {
property,
value,
profileId: this.getProfileId(),
});
}
if (this.options.setProfileId && profileId) {
this.options.setProfileId(profileId);
}
public decrement(property: string, value: number) {
this.api.fetch<DecrementProfilePayload, string>('/profile/decrement', {
property,
value,
profileId: this.getProfileId(),
});
}
public event(name: string, properties?: Record<string, unknown>) {
this.api
.fetch<PostEventPayload, string>('/event', {
name,
properties: {
...this.state.properties,
...(properties ?? {}),
},
timestamp: this.timestamp(),
profileId: this.getProfileId(),
})
.then((profileId) => {
if (this.options.setProfileId && profileId) {
this.options.setProfileId(profileId);
}
});
}
public setGlobalProperties(properties: Record<string, unknown>) {
@@ -168,21 +175,17 @@ export class Mixan<Options extends MixanOptions = MixanOptions> {
}
}
public setUserProperty(name: string, value: unknown, update = true) {
// this.batcher.add({
// type: 'set_profile_property',
// payload: {
// name,
// value,
// update,
// profileId: this.state.profileId,
// },
// });
}
// Private
private timestamp() {
return new Date().toISOString();
}
private getProfileId() {
if (this.state.profileId) {
return this.state.profileId;
} else if (this.options.getProfileId) {
this.state.profileId = this.options.getProfileId() || undefined;
}
}
}

View File

@@ -138,5 +138,31 @@ export interface PostEventPayload {
name: string;
timestamp: string;
profileId?: string;
properties?: Record<string, unknown>;
properties?: Record<string, unknown> & {
title?: string | undefined;
referrer?: string | undefined;
path?: string | undefined;
};
}
export interface UpdateProfilePayload {
profileId?: string;
id?: string;
first_name?: string;
last_name?: string;
email?: string;
avatar?: string;
properties?: MixanJson;
}
export interface IncrementProfilePayload {
profileId?: string;
property: string;
value: number;
}
export interface DecrementProfilePayload {
profileId?: string;
property: string;
value: number;
}

5565
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -53,6 +53,11 @@ function main() {
};
try {
savePackageJson(workspacePath('./packages/types/package.json'), {
...typesPkg,
...properties,
});
for (const name of sdkPackages) {
const pkgJson = require(workspacePath(`./packages/${name}/package.json`));
savePackageJson(workspacePath(`./packages/${name}/package.json`), {
@@ -67,11 +72,6 @@ function main() {
),
});
}
savePackageJson(workspacePath('./packages/types/package.json'), {
...typesPkg,
...properties,
});
} catch (error) {
exit('Update JSON files', error);
}
@@ -79,14 +79,15 @@ function main() {
console.log('✅ Update JSON files');
try {
execSync('pnpm build', {
cwd: workspacePath(`./packages/types`),
});
for (const name of sdkPackages) {
execSync('pnpm build', {
cwd: workspacePath(`./packages/${name}`),
});
}
execSync('pnpm build', {
cwd: workspacePath(`./packages/types`),
});
} catch (error) {
exit('Failed build packages', error);
}

View File

@@ -1,9 +1,9 @@
{
"entry": ["index.ts", "cdn.ts"],
"format": ["cjs", "esm", "iife"],
"entry": ["index.ts"],
"format": ["cjs", "esm"],
"dts": true,
"splitting": false,
"sourcemap": false,
"sourcemap": true,
"clean": true,
"minify": true
}