diff --git a/packages/bolt/app/components/chat/Chat.client.tsx b/packages/bolt/app/components/chat/Chat.client.tsx index 0b2a176..bc2ea31 100644 --- a/packages/bolt/app/components/chat/Chat.client.tsx +++ b/packages/bolt/app/components/chat/Chat.client.tsx @@ -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(); diff --git a/packages/bolt/app/lib/.server/sessions.ts b/packages/bolt/app/lib/.server/sessions.ts index 9a66eb6..ee7a0f2 100644 --- a/packages/bolt/app/lib/.server/sessions.ts +++ b/packages/bolt/app/lib/.server/sessions.ts @@ -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 { 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'); diff --git a/packages/bolt/app/lib/analytics.ts b/packages/bolt/app/lib/analytics.ts new file mode 100644 index 0000000..df41c3e --- /dev/null +++ b/packages/bolt/app/lib/analytics.ts @@ -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 = Omit; + +export type AnalyticsEvent = + | { action: AnalyticsAction.Identify; payload: OmitUserId } + | { action: AnalyticsAction.Page; payload: OmitUserId } + | { action: AnalyticsAction.Track; payload: OmitUserId }; + +export async function identifyUser(access: string): Promise { + 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 }; +} diff --git a/packages/bolt/app/lib/persistence/useChatHistory.ts b/packages/bolt/app/lib/persistence/useChatHistory.ts index 1a7c8a3..8634aac 100644 --- a/packages/bolt/app/lib/persistence/useChatHistory.ts +++ b/packages/bolt/app/lib/persistence/useChatHistory.ts @@ -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, + }, + }, + }); } diff --git a/packages/bolt/app/root.tsx b/packages/bolt/app/root.tsx index c122bf6..e0f0fef 100644 --- a/packages/bolt/app/root.tsx +++ b/packages/bolt/app/root.tsx @@ -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 ( diff --git a/packages/bolt/app/routes/api.analytics.ts b/packages/bolt/app/routes/api.analytics.ts new file mode 100644 index 0000000..e702984 --- /dev/null +++ b/packages/bolt/app/routes/api.analytics.ts @@ -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); +} diff --git a/packages/bolt/app/routes/login.tsx b/packages/bolt/app/routes/login.tsx index 591a171..caabcfe 100644 --- a/packages/bolt/app/routes/login.tsx +++ b/packages/bolt/app/routes/login.tsx @@ -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() {

Login

+

+ By using Bolt, you agree to the collection of usage data for analytics. +

)} @@ -146,7 +152,7 @@ function LoginForm() { }); } - function onTokens() { + async function onTokens() { const tokens = auth.tokens()!; fetcher.submit(tokens, { diff --git a/packages/bolt/package.json b/packages/bolt/package.json index c378a4e..059e0a5 100644 --- a/packages/bolt/package.json +++ b/packages/bolt/package.json @@ -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", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8bb2b48..a4a0119 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -104,6 +104,9 @@ importers: '@remix-run/react': specifier: ^2.10.2 version: 2.10.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1)(typescript@5.5.2) + '@segment/analytics-node': + specifier: ^2.1.2 + version: 2.1.2 '@stackblitz/sdk': specifier: ^1.11.0 version: 1.11.0 @@ -1167,6 +1170,14 @@ packages: '@lezer/sass@1.0.6': resolution: {integrity: sha512-w/RCO2dIzZH1To8p+xjs8cE+yfgGus8NZ/dXeWl/QzHyr+TeBs71qiE70KPImEwvTsmEjoWh0A5SxMzKd5BWBQ==} + '@lukeed/csprng@1.1.0': + resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} + engines: {node: '>=8'} + + '@lukeed/uuid@2.0.1': + resolution: {integrity: sha512-qC72D4+CDdjGqJvkFMMEAtancHUQ7/d/tAiHf64z8MopFDmcrtbcJuerDtFceuAfQJ2pDSfCKCtbqoGBNnwg0w==} + engines: {node: '>=8'} + '@mdx-js/mdx@2.3.0': resolution: {integrity: sha512-jLuwRlz8DQfQNiUCJR50Y09CGPq3fLtmtUQfVrj79E0JWu3dvsVcxVIcfhR5h0iXu+/z++zDrYeiJqifRynJkA==} @@ -1444,6 +1455,16 @@ packages: cpu: [x64] os: [win32] + '@segment/analytics-core@1.6.0': + resolution: {integrity: sha512-bn9X++IScUfpT7aJGjKU/yJAu/Ko2sYD6HsKA70Z2560E89x30pqgqboVKY8kootvQnT4UKCJiUr5NDMgjmWdQ==} + + '@segment/analytics-generic-utils@1.2.0': + resolution: {integrity: sha512-DfnW6mW3YQOLlDQQdR89k4EqfHb0g/3XvBXkovH1FstUN93eL1kfW9CsDcVQyH3bAC5ZsFyjA/o/1Q2j0QeoWw==} + + '@segment/analytics-node@2.1.2': + resolution: {integrity: sha512-CIqWH5G0pB/LAFAZEZtntAxujiYIpdk0F+YGhfM6N/qt4/VLWjFcd4VZXVLW7xqaxig64UKWGQhe8bszXDRXXw==} + engines: {node: '>=18'} + '@shikijs/core@1.9.1': resolution: {integrity: sha512-EmUful2MQtY8KgCF1OkBtOuMcvaZEvmdubhW0UHCGXi21O9dRLeADVCj+k6ZS+de7Mz9d2qixOXJ+GLhcK3pXg==} @@ -1988,6 +2009,9 @@ packages: buffer@5.7.1: resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + buffer@6.0.3: + resolution: {integrity: sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==} + builtin-status-codes@3.0.0: resolution: {integrity: sha512-HpGFw18DgFWlncDfjTa2rcQ4W88O1mC8e8yZ2AvQY5KDaktSTwo+KRf6nHK6FRI5FyRyb/5T6+TSxfP7QyGsmQ==} @@ -2381,6 +2405,10 @@ packages: resolution: {integrity: sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==} engines: {node: '>=12'} + dset@3.1.3: + resolution: {integrity: sha512-20TuZZHCEZ2O71q9/+8BwKwZ0QtD9D8ObhrihJPr+vLLYlSuAU3/zL4cSlgbfeoGHTjCSJBa7NGcrF9/Bx/WJQ==} + engines: {node: '>=4'} + duplexer@0.1.2: resolution: {integrity: sha512-jtD6YG370ZCIi/9GTaJKQxWTZD045+4R4hTk/x1UyoqadyJ9x9CgSi1RlVDQF8U2sxLLSnFkCaMihqljHIWgMg==} @@ -3742,6 +3770,15 @@ packages: node-fetch-native@1.6.4: resolution: {integrity: sha512-IhOigYzAKHd244OC0JIMIUrjzctirCmPkaIfhDeGcEETWof5zKYUW7e7MYvChGWh/4CJeXEgsRyGzuF334rOOQ==} + node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + node-fetch@3.3.2: resolution: {integrity: sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==} engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} @@ -4661,6 +4698,9 @@ packages: resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} engines: {node: '>=6'} + tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + trim-lines@3.0.1: resolution: {integrity: sha512-kRj8B+YHZCc9kQYdWfJB2/oUl9rA99qbowYYBtr4ui4mZyAQ2JpvVBd/6U2YloATfqBhBTSMhTpgBHtU0Mf3Rg==} @@ -4992,6 +5032,12 @@ packages: resolution: {integrity: sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==} engines: {node: '>= 8'} + webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + which-typed-array@1.1.15: resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} engines: {node: '>= 0.4'} @@ -6073,6 +6119,12 @@ snapshots: '@lezer/highlight': 1.2.0 '@lezer/lr': 1.4.1 + '@lukeed/csprng@1.1.0': {} + + '@lukeed/uuid@2.0.1': + dependencies: + '@lukeed/csprng': 1.1.0 + '@mdx-js/mdx@2.3.0': dependencies: '@types/estree-jsx': 1.0.5 @@ -6425,6 +6477,29 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.18.0': optional: true + '@segment/analytics-core@1.6.0': + dependencies: + '@lukeed/uuid': 2.0.1 + '@segment/analytics-generic-utils': 1.2.0 + dset: 3.1.3 + tslib: 2.6.3 + + '@segment/analytics-generic-utils@1.2.0': + dependencies: + tslib: 2.6.3 + + '@segment/analytics-node@2.1.2': + dependencies: + '@lukeed/uuid': 2.0.1 + '@segment/analytics-core': 1.6.0 + '@segment/analytics-generic-utils': 1.2.0 + buffer: 6.0.3 + jose: 5.6.3 + node-fetch: 2.7.0 + tslib: 2.6.3 + transitivePeerDependencies: + - encoding + '@shikijs/core@1.9.1': {} '@sinclair/typebox@0.27.8': {} @@ -7186,6 +7261,11 @@ snapshots: base64-js: 1.5.1 ieee754: 1.2.1 + buffer@6.0.3: + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + builtin-status-codes@3.0.0: {} bytes@3.0.0: @@ -7569,6 +7649,8 @@ snapshots: dotenv@16.4.5: {} + dset@3.1.3: {} + duplexer@0.1.2: {} duplexify@3.7.1: @@ -9422,6 +9504,10 @@ snapshots: node-fetch-native@1.6.4: {} + node-fetch@2.7.0: + dependencies: + whatwg-url: 5.0.0 + node-fetch@3.3.2: dependencies: data-uri-to-buffer: 4.0.1 @@ -10441,6 +10527,8 @@ snapshots: totalist@3.0.1: {} + tr46@0.0.3: {} + trim-lines@3.0.1: {} trough@2.2.0: {} @@ -10842,6 +10930,13 @@ snapshots: web-streams-polyfill@3.3.3: {} + webidl-conversions@3.0.1: {} + + whatwg-url@5.0.0: + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + which-typed-array@1.1.15: dependencies: available-typed-arrays: 1.0.7