Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
144 changes: 144 additions & 0 deletions apps/start/src/components/onboarding/activation-checklist.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from '@tanstack/react-router';
import { CheckIcon, XIcon } from 'lucide-react';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { useAppParams } from '@/hooks/use-app-params';
import { useTRPC } from '@/integrations/trpc/react';
import { pushModal } from '@/modals';
import { cn } from '@/utils/cn';
import { op } from '@/utils/op';

// Getting-started checklist shown on the project overview until every step is
// done (or the user dismisses it). Steps derive from existing data — no state
// machine: first event (Project.firstEventAt), first report, invited teammate.

const dismissKey = (projectId: string) =>
`op-activation-checklist-dismissed:${projectId}`;

const readDismissed = (projectId: string) => {
try {
return localStorage.getItem(dismissKey(projectId)) === '1';
} catch {
return true;
}
};

export default function ActivationChecklist() {
const { organizationId, projectId } = useAppParams();
const trpc = useTRPC();

// Hidden until mounted so SSR and the first client render agree.
const [dismissed, setDismissed] = useState(true);
useEffect(() => {
setDismissed(readDismissed(projectId));
}, [projectId]);

const statusQuery = useQuery(
trpc.project.activationStatus.queryOptions(
{ projectId },
{ enabled: !dismissed }
)
);
const status = statusQuery.data;

if (dismissed || !status) {
return null;
}

const steps = [
{
key: 'first-event',
label: 'Install the SDK and receive your first event',
done: !!status.firstEventAt,
action: (
<Button asChild size="sm" variant="outline">
<a href={`/onboarding/${projectId}/connect`}>Set up tracking</a>
</Button>
),
},
{
key: 'first-report',
label: 'Create your first report',
done: status.hasReport,
action: (
<Button asChild size="sm" variant="outline">
<Link
params={{ organizationId, projectId }}
to="/$organizationId/$projectId/reports"
>
Explore reports
</Link>
</Button>
),
},
{
key: 'invite-teammate',
label: 'Invite a teammate',
done: status.hasTeammate,
action: (
<Button
onClick={() => {
op.track('activation_checklist_invite_clicked', { projectId });
pushModal('CreateInvite');
}}
size="sm"
variant="outline"
>
Invite
</Button>
),
},
];

const remaining = steps.filter((step) => !step.done);
if (remaining.length === 0) {
return null;
}

const dismiss = () => {
op.track('activation_checklist_dismissed', { projectId });
try {
localStorage.setItem(dismissKey(projectId), '1');
} catch {
// Storage unavailable — the checklist just shows again next session.
}
setDismissed(true);
};

return (
<div className="card col-span-6 p-4">
<div className="row items-center justify-between">
<div className="font-medium text-lg">
Get set up ({steps.length - remaining.length}/{steps.length})
</div>
<Button onClick={dismiss} size="icon" variant="ghost">
<XIcon className="size-4" />
</Button>
</div>
<div className="col mt-2 gap-2">
{steps.map((step) => (
<div
className="row items-center justify-between gap-4 rounded-md border p-3"
key={step.key}
>
<div className="row items-center gap-3">
<div
className={cn(
'center-center size-5 rounded-full border',
step.done && 'border-emerald-600 bg-emerald-600 text-white'
)}
>
{step.done && <CheckIcon className="size-3" />}
</div>
<span className={cn(step.done && 'text-muted-foreground')}>
{step.label}
</span>
</div>
{!step.done && step.action}
</div>
))}
</div>
</div>
);
}
18 changes: 14 additions & 4 deletions apps/start/src/hooks/use-ws.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import debounce from 'lodash.debounce';
import { useEffect, useMemo, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from 'react';
import { useWebSocket } from 'react-use-websocket/dist/lib/use-websocket';

import { getSuperJson } from '@openpanel/json';
Expand All @@ -21,12 +21,22 @@ export default function useWS<T>(
const ws = context.apiUrl.replace(/^https/, 'wss').replace(/^http/, 'ws');
const [baseUrl, setBaseUrl] = useState(`${ws}${path}`);

// Always call the latest onMessage. The memoized (debounced) wrapper below
// otherwise captures the first render's callback — if the path changes
// without unmounting (e.g. switching projects), the socket would reconnect
// but keep invoking a handler closed over the old path's state.
const onMessageRef = useRef(onMessage);
useEffect(() => {
onMessageRef.current = onMessage;
});

const debouncedOnMessage = useMemo(() => {
const invokeLatest = (event: T) => onMessageRef.current(event);
if (options?.debounce) {
return debounce(onMessage, options.debounce.delay, options.debounce);
return debounce(invokeLatest, options.debounce.delay, options.debounce);
}
return onMessage;
}, [options?.debounce?.delay]);
return invokeLatest;
}, [options?.debounce?.delay, options?.debounce?.maxWait]);

useEffect(() => {
if (baseUrl === `${ws}${path}`) return;
Expand Down
9 changes: 8 additions & 1 deletion apps/start/src/modals/add-project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -109,9 +109,16 @@ export default function AddProject() {
<CreateClientSuccess {...mutation.data.client} />
)}
<ButtonContainer className="justify-end">
<Button className="flex-1" onClick={() => popModal()}>
<Button onClick={() => popModal()} variant="outline">
Close
</Button>
{/* Route through the same connect -> verify steps as onboarding so
a second project doesn't skip the install instructions. */}
<Button asChild className="flex-1">
<a href={`/onboarding/${mutation.data.id}/connect`}>
Set up tracking
</a>
</Button>
</ButtonContainer>
</>
) : (
Expand Down
23 changes: 23 additions & 0 deletions apps/start/src/routes/__root.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import {
createRootRouteWithContext,
HeadContent,
Scripts,
useRouteContext,
} from '@tanstack/react-router';
import { useEffect } from 'react';

import 'flag-icons/css/flag-icons.min.css';
import 'katex/dist/katex.min.css';
Expand Down Expand Up @@ -93,6 +95,26 @@ export const Route = createRootRouteWithContext<MyRouterContext>()({
pendingComponent: FullPageLoadingState,
});

// Tie dashboard events to the signed-in user so activation funnels
// (signup -> project created -> first event -> report) can be measured.
function OpIdentify() {
const context = useRouteContext({ strict: false });
const user = context.session?.user;

useEffect(() => {
if (user?.id) {
op.identify({
profileId: user.id,
email: user.email,
firstName: user.firstName ?? undefined,
lastName: user.lastName ?? undefined,
});
}
}, [user?.id, user?.email, user?.firstName, user?.lastName]);

return null;
}

function RootDocument({ children }: { children: React.ReactNode }) {
useSessionExtension();

Expand All @@ -102,6 +124,7 @@ function RootDocument({ children }: { children: React.ReactNode }) {
<HeadContent />
</head>
<body className="grainy min-h-screen bg-def-100 font-sans text-base leading-normal antialiased">
<OpIdentify />
<Providers>{children}</Providers>
<ThemeScriptOnce />
<Scripts />
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { createFileRoute } from '@tanstack/react-router';
import { LazyComponent } from '@/components/lazy-component';
import ActivationChecklist from '@/components/onboarding/activation-checklist';
import { useRangePageContext } from '@/hooks/use-page-context-helpers';
import {
OverviewFilterButton,
Expand Down Expand Up @@ -57,6 +58,7 @@ function ProjectDashboard() {
</div>
</div>
<div className="grid grid-cols-6 gap-4 p-4 pt-0">
<ActivationChecklist />
<OverviewMetrics projectId={projectId} />
<OverviewInsights projectId={projectId} />
<OverviewTopSources projectId={projectId} />
Expand Down
27 changes: 25 additions & 2 deletions apps/start/src/routes/_steps.onboarding.$projectId.verify.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useQuery } from '@tanstack/react-query';
import { useQuery, useQueryClient } from '@tanstack/react-query';
import { createFileRoute, Link, redirect } from '@tanstack/react-router';
import { BoxSelectIcon } from 'lucide-react';
import { ButtonContainer } from '@/components/button-container';
Expand All @@ -7,8 +7,11 @@ import FullPageLoadingState from '@/components/full-page-loading-state';
import VerifyListener from '@/components/onboarding/onboarding-verify-listener';
import { VerifyFaq } from '@/components/onboarding/verify-faq';
import { LinkButton } from '@/components/ui/button';
import { useEffect } from 'react';
import useWS from '@/hooks/use-ws';
import { useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/lib/utils';
import { op } from '@/utils/op';
import { createEntityTitle, PAGE_TITLES } from '@/utils/title';

export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
Expand All @@ -34,15 +37,35 @@ export const Route = createFileRoute('/_steps/onboarding/$projectId/verify')({
function Component() {
const { projectId } = Route.useParams();
const trpc = useTRPC();
const queryClient = useQueryClient();
const { data: events } = useQuery(
trpc.event.events.queryOptions(
{ projectId },
{
refetchInterval: 2500,
// The live websocket below flips the verifier instantly; this poll is
// only a fallback for when the socket can't connect.
refetchInterval: 10_000,
}
)
);
// Refetch the event list the moment an event arrives instead of waiting for
// the next poll — same channel the in-app live event feed uses.
useWS(`/live/events/${projectId}`, () => {
queryClient.invalidateQueries(
trpc.event.events.queryFilter({ projectId })
);
});
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const isVerified = events?.data && events.data.length > 0;

useEffect(() => {
op.track('onboarding_verify_viewed', { projectId });
}, [projectId]);

useEffect(() => {
if (isVerified) {
op.track('onboarding_first_event_verified', { projectId });
}
}, [isVerified, projectId]);
const { data: project } = useQuery(
trpc.project.getProjectWithClients.queryOptions({ projectId })
);
Expand Down
2 changes: 2 additions & 0 deletions apps/start/src/routes/_steps.onboarding.project.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,7 @@ import { Label } from '@/components/ui/label';
import { useClientSecret } from '@/hooks/use-client-secret';
import { handleError, useTRPC } from '@/integrations/trpc/react';
import { cn } from '@/utils/cn';
import { op } from '@/utils/op';

const validateSearch = z.object({
inviteId: z.string().optional(),
Expand Down Expand Up @@ -74,6 +75,7 @@ function Component() {
trpc.onboarding.project.mutationOptions({
onError: handleError,
onSuccess(res) {
op.track('onboarding_project_created', { projectId: res.projectId });
queryClient.invalidateQueries(trpc.organization.list.queryFilter());
setSecret(res.secret);
navigate({
Expand Down
12 changes: 8 additions & 4 deletions apps/worker/src/jobs/cron.data-health.ts
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,9 @@ export async function dataHealthCronJob() {
organizationId: true,
noDataNotifiedAt: true,
dataStoppedNotifiedAt: true,
organization: { select: { id: true, subscriptionState: true } },
organization: {
select: { id: true, subscriptionState: true, onboarding: true },
},
},
});

Expand All @@ -81,10 +83,12 @@ export async function dataHealthCronJob() {

if (!lastEventAt) {
// Never received an event. One notice per project, after the grace
// period. (The onboarding drip already nudges brand-new orgs, so this
// mainly catches additional projects and broken installs.)
// period. Orgs still in the onboarding email drip are excluded — its
// day-2/6 emails already carry "stuck on the install?" variants, so this
// notice targets post-onboarding orgs and additional projects.
const inOnboardingDrip = project.organization.onboarding !== 'completed';
const oldEnough = now - project.createdAt.getTime() > NO_DATA_AFTER_MS;
if (oldEnough && !project.noDataNotifiedAt) {
if (oldEnough && !project.noDataNotifiedAt && !inOnboardingDrip) {
const entry = byOrg.get(project.organizationId) ?? {
organizationId: project.organizationId,
noData: [],
Expand Down
20 changes: 20 additions & 0 deletions apps/worker/src/jobs/events.incoming-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import type { IServiceCreateEventPayload, IServiceEvent } from '@openpanel/db';
import {
checkNotificationRulesForEvent,
createEvent,
db,
getProjectByIdCached,
matchEvent,
sessionBuffer,
Expand Down Expand Up @@ -35,6 +36,24 @@ async function isEventExcludedByProjectFilter(
return eventExcludeFilters.some((filter) => matchEvent(payload, filter));
}

/**
* Records the project's first-ever event timestamp exactly once. The cached
* project read makes this a no-op on every event after the first; the
* conditional update keeps concurrent workers idempotent.
*/
async function markFirstEvent(projectId: string, logger: ILogger) {
const project = await getProjectByIdCached(projectId);
if (!project || project.firstEventAt) {
return;
}
await db.project.updateMany({
where: { id: projectId, firstEventAt: null },
data: { firstEventAt: new Date() },
});
await getProjectByIdCached.clear(projectId);
logger.info({ projectId }, 'Project received its first event');
}

async function createEventAndNotify(
payload: IServiceCreateEventPayload,
logger: ILogger,
Expand All @@ -53,6 +72,7 @@ async function createEventAndNotify(
const [event] = await Promise.all([
createEvent(payload),
checkNotificationRulesForEvent(payload).catch(() => null),
markFirstEvent(projectId, logger).catch(() => null),
]);
return event;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
-- Set once by the worker when a project's first event arrives. Powers the
-- activation checklist and onboarding verification.
ALTER TABLE "projects"
ADD COLUMN "firstEventAt" TIMESTAMP(3);
Loading
Loading