feat: add basic analytics (#29)
This commit is contained in:
@@ -11,6 +11,7 @@ import { fileModificationsToHTML } from '~/utils/diff';
|
||||
import { cubicEasingFn } from '~/utils/easings';
|
||||
import { createScopedLogger, renderLogger } from '~/utils/logger';
|
||||
import { BaseChat } from './BaseChat';
|
||||
import { sendAnalyticsEvent, AnalyticsTrackEvent, AnalyticsAction } from '~/lib/analytics';
|
||||
|
||||
const toastAnimation = cssTransition({
|
||||
enter: 'animated fadeInRight',
|
||||
@@ -191,6 +192,18 @@ export const ChatImpl = memo(({ initialMessages, storeMessageHistory }: ChatProp
|
||||
resetEnhancer();
|
||||
|
||||
textareaRef.current?.blur();
|
||||
|
||||
const event = messages.length === 0 ? AnalyticsTrackEvent.ChatCreated : AnalyticsTrackEvent.MessageSent;
|
||||
|
||||
sendAnalyticsEvent({
|
||||
action: AnalyticsAction.Track,
|
||||
payload: {
|
||||
event,
|
||||
properties: {
|
||||
message: _input,
|
||||
},
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const [messageRef, scrollRef] = useSnapScroll();
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
103
packages/bolt/app/lib/analytics.ts
Normal file
103
packages/bolt/app/lib/analytics.ts
Normal 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 };
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import { useStore } from '@nanostores/react';
|
||||
import type { LinksFunction } from '@remix-run/cloudflare';
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration } from '@remix-run/react';
|
||||
import { Links, Meta, Outlet, Scripts, ScrollRestoration, useLocation } from '@remix-run/react';
|
||||
import tailwindReset from '@unocss/reset/tailwind-compat.css?url';
|
||||
import { useEffect } from 'react';
|
||||
import { sendAnalyticsEvent, AnalyticsAction } from './lib/analytics';
|
||||
import { themeStore } from './lib/stores/theme';
|
||||
import { stripIndents } from './utils/stripIndent';
|
||||
|
||||
@@ -53,6 +55,20 @@ const inlineThemeCode = stripIndents`
|
||||
export function Layout({ children }: { children: React.ReactNode }) {
|
||||
const theme = useStore(themeStore);
|
||||
|
||||
const { pathname } = useLocation();
|
||||
|
||||
// log page events when the window location changes
|
||||
useEffect(() => {
|
||||
sendAnalyticsEvent({
|
||||
action: AnalyticsAction.Page,
|
||||
payload: {
|
||||
properties: {
|
||||
url: window.location.href,
|
||||
},
|
||||
},
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return (
|
||||
<html lang="en" data-theme={theme}>
|
||||
<head>
|
||||
|
||||
20
packages/bolt/app/routes/api.analytics.ts
Normal file
20
packages/bolt/app/routes/api.analytics.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
import { json, type ActionFunctionArgs } from '@remix-run/cloudflare';
|
||||
import { handleWithAuth } from '~/lib/.server/login';
|
||||
import { getSession } from '~/lib/.server/sessions';
|
||||
import { sendEventInternal, type AnalyticsEvent } from '~/lib/analytics';
|
||||
|
||||
async function analyticsAction({ request, context }: ActionFunctionArgs) {
|
||||
const event: AnalyticsEvent = await request.json();
|
||||
const { session } = await getSession(request, context.cloudflare.env);
|
||||
const { success, error } = await sendEventInternal(session.data, event);
|
||||
|
||||
if (!success) {
|
||||
return json({ error }, { status: 500 });
|
||||
}
|
||||
|
||||
return json({ success }, { status: 200 });
|
||||
}
|
||||
|
||||
export async function action(args: ActionFunctionArgs) {
|
||||
return handleWithAuth(args, analyticsAction);
|
||||
}
|
||||
@@ -9,6 +9,7 @@ import { useFetcher, useLoaderData } from '@remix-run/react';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { LoadingDots } from '~/components/ui/LoadingDots';
|
||||
import { createUserSession, isAuthenticated, validateAccessToken } from '~/lib/.server/sessions';
|
||||
import { identifyUser } from '~/lib/analytics';
|
||||
import { CLIENT_ID, CLIENT_ORIGIN } from '~/lib/constants';
|
||||
import { request as doRequest } from '~/lib/fetch';
|
||||
import { auth, type AuthAPI } from '~/lib/webcontainer/auth.client';
|
||||
@@ -62,9 +63,11 @@ export async function action({ request, context }: ActionFunctionArgs) {
|
||||
return json({ error: 'bolt-access' as const }, { status: 401 });
|
||||
}
|
||||
|
||||
const identity = await identifyUser(payload.access);
|
||||
|
||||
const tokenInfo: { expires_in: number; created_at: number } = await response.json();
|
||||
|
||||
const init = await createUserSession(request, context.cloudflare.env, { ...payload, ...tokenInfo });
|
||||
const init = await createUserSession(request, context.cloudflare.env, { ...payload, ...tokenInfo }, identity);
|
||||
|
||||
return redirectDocument('/', init);
|
||||
}
|
||||
@@ -105,6 +108,9 @@ export default function Login() {
|
||||
<h2 className="mt-6 text-center text-3xl font-extrabold text-gray-900">Login</h2>
|
||||
</div>
|
||||
<LoginForm />
|
||||
<p className="mt-4 text-sm text-center text-gray-600">
|
||||
By using Bolt, you agree to the collection of usage data for analytics.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
@@ -146,7 +152,7 @@ function LoginForm() {
|
||||
});
|
||||
}
|
||||
|
||||
function onTokens() {
|
||||
async function onTokens() {
|
||||
const tokens = auth.tokens()!;
|
||||
|
||||
fetcher.submit(tokens, {
|
||||
|
||||
@@ -39,6 +39,7 @@
|
||||
"@remix-run/cloudflare": "^2.10.2",
|
||||
"@remix-run/cloudflare-pages": "^2.10.2",
|
||||
"@remix-run/react": "^2.10.2",
|
||||
"@segment/analytics-node": "^2.1.2",
|
||||
"@stackblitz/sdk": "^1.11.0",
|
||||
"@uiw/codemirror-theme-vscode": "^4.23.0",
|
||||
"@unocss/reset": "^0.61.0",
|
||||
|
||||
Reference in New Issue
Block a user