feat: add basic analytics (#29)

This commit is contained in:
Connor Fogarty
2024-08-12 10:37:45 -05:00
committed by GitHub
parent 6e99e4c11e
commit 8fd9d4477e
9 changed files with 278 additions and 4 deletions

View File

@@ -3,12 +3,15 @@ import { decodeJwt } from 'jose';
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
import { request as doRequest } from '~/lib/fetch';
import { logger } from '~/utils/logger';
import type { Identity } from '~/lib/analytics';
const DEV_SESSION_SECRET = import.meta.env.DEV ? 'LZQMrERo3Ewn/AbpSYJ9aw==' : undefined;
interface SessionData {
refresh: string;
expiresAt: number;
userId: string | null;
segmentWriteKey: string | null;
}
export async function isAuthenticated(request: Request, env: Env) {
@@ -50,6 +53,7 @@ export async function createUserSession(
request: Request,
env: Env,
tokens: { refresh: string; expires_in: number; created_at: number },
identity?: Identity,
): Promise<ResponseInit> {
const { session, sessionStorage } = await getSession(request, env);
@@ -58,6 +62,11 @@ export async function createUserSession(
session.set('refresh', tokens.refresh);
session.set('expiresAt', expiresAt);
if (identity) {
session.set('userId', identity.userId ?? null);
session.set('segmentWriteKey', identity.segmentWriteKey ?? null);
}
return {
headers: {
'Set-Cookie': await sessionStorage.commitSession(session, {
@@ -97,7 +106,7 @@ export function validateAccessToken(access: string) {
return jwtPayload.bolt === true;
}
async function getSession(request: Request, env: Env) {
export async function getSession(request: Request, env: Env) {
const sessionStorage = getSessionStorage(env);
const cookie = request.headers.get('Cookie');

View File

@@ -0,0 +1,103 @@
import { Analytics, type IdentifyParams, type PageParams, type TrackParams } from '@segment/analytics-node';
import { CLIENT_ORIGIN } from '~/lib/constants';
import { request as doRequest } from '~/lib/fetch';
import { logger } from '~/utils/logger';
export interface Identity {
userId?: string | null;
guestId?: string | null;
segmentWriteKey?: string | null;
}
const MESSAGE_PREFIX = 'Bolt';
export enum AnalyticsTrackEvent {
MessageSent = `${MESSAGE_PREFIX} Message Sent`,
ChatCreated = `${MESSAGE_PREFIX} Chat Created`,
}
export enum AnalyticsAction {
Identify = 'identify',
Page = 'page',
Track = 'track',
}
// we can omit the user ID since it's retrieved from the user's session
type OmitUserId<T> = Omit<T, 'userId'>;
export type AnalyticsEvent =
| { action: AnalyticsAction.Identify; payload: OmitUserId<IdentifyParams> }
| { action: AnalyticsAction.Page; payload: OmitUserId<PageParams> }
| { action: AnalyticsAction.Track; payload: OmitUserId<TrackParams> };
export async function identifyUser(access: string): Promise<Identity | undefined> {
const response = await doRequest(`${CLIENT_ORIGIN}/api/identify`, {
method: 'GET',
headers: { authorization: `Bearer ${access}` },
});
const body = await response.json();
if (!response.ok) {
return undefined;
}
// convert numerical identity values to strings
const stringified = Object.entries(body).map(([key, value]) => [
key,
typeof value === 'number' ? value.toString() : value,
]);
return Object.fromEntries(stringified) as Identity;
}
// send an analytics event from the client
export async function sendAnalyticsEvent(event: AnalyticsEvent) {
// don't send analytics events when in dev mode
if (import.meta.env.DEV) {
return;
}
const request = await fetch('/api/analytics', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(event),
});
if (!request.ok) {
logger.error(`Error handling Segment Analytics action: ${event.action}`);
}
}
// send an analytics event from the server
export async function sendEventInternal(identity: Identity, { action, payload }: AnalyticsEvent) {
const { userId, segmentWriteKey: writeKey } = identity;
if (!userId || !writeKey) {
logger.warn('Missing user ID or write key when logging analytics');
return { success: false as const, error: 'missing-data' };
}
const analytics = new Analytics({ flushAt: 1, writeKey }).on('error', logger.error);
try {
await new Promise((resolve, reject) => {
if (action === AnalyticsAction.Identify) {
analytics.identify({ ...payload, userId }, resolve);
} else if (action === AnalyticsAction.Page) {
analytics.page({ ...payload, userId }, resolve);
} else if (action === AnalyticsAction.Track) {
analytics.track({ ...payload, userId }, resolve);
} else {
reject();
}
});
} catch {
logger.error(`Error handling Segment Analytics action: ${action}`);
return { success: false as const, error: 'invalid-action' };
}
return { success: true as const };
}

View File

@@ -4,6 +4,7 @@ import type { Message } from 'ai';
import { openDatabase, setMessages, getMessages, getNextId, getUrlId } from './db';
import { toast } from 'react-toastify';
import { workbenchStore } from '~/lib/stores/workbench';
import { sendAnalyticsEvent, AnalyticsAction } from '~/lib/analytics';
export interface ChatHistory {
id: string;
@@ -111,4 +112,14 @@ function navigateChat(nextId: string) {
url.pathname = `/chat/${nextId}`;
window.history.replaceState({}, '', url);
// since the `replaceState` call doesn't trigger a page reload, we need to manually log this event
sendAnalyticsEvent({
action: AnalyticsAction.Page,
payload: {
properties: {
url: url.href,
},
},
});
}