Skip to content
Open
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
11 changes: 11 additions & 0 deletions .changeset/onboarding-checklist-tasks.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
---
'@hyperdx/common-utils': minor
'@hyperdx/api': minor
'@hyperdx/app': minor
---

Add a context-aware getting-started checklist to the sidebar. After the existing setup steps (connect ClickHouse, add data) complete, a second phase tracks product-usage milestones persisted per user on `user.onboardingData`: exploring data (running a non-trivial search), creating a dashboard, setting up an alert, and using the MCP server. Each task links to where to do it, the card can be dismissed, and the task registry is a single source of truth in `common-utils` (`ONBOARDING_TASK_IDS`) so adding a new task is a compile error until both the API validation enum and the frontend UI are updated.

Completion is recorded server-side so it is surface-agnostic: the `alert` and `dashboard` tasks complete whether the action came from the UI, the external REST API v2, or an MCP tool (all authenticate with the user's personal access key, so the action is attributable to that user). The `dashboard` task specifically means "built a chart" — it completes only once a dashboard has at least one tile, not for an empty dashboard shell. This includes adding a tile to a temporary (unsaved, URL-state) dashboard, which never touches the backend: the app records that case directly against `POST /me/onboarding/task`. Alert recording covers the standalone `/alerts` create path and dashboard-tile alerts. MCP usage is recorded in the tool-tracing chokepoint. Exploring data is recorded from the search page on any non-trivial search (a non-empty where clause in either Lucene or SQL, or an applied filter). All recording is fire-and-forget and never blocks the triggering operation. After a UI action the frontend patches only `onboardingData` in the cached `me` object (no `me` refetch, so the many `useMe` consumers — metadata, ClickHouse settings, nav — are untouched).

The checklist's "done" state is DERIVED from whether every current task is complete rather than a persisted flag, so adding or changing a task in `ONBOARDING_TASK_IDS` later automatically reopens the checklist for users who had finished the old set. Completing the final task in-session shows a brief celebration before the card hides; a user who was already finished on load sees nothing. The persisted `isDismissed` (`PATCH /me/onboarding/dismiss`) is only for the manual dismiss (X) when a user opts out early. The `me` response includes `onboardingData`, defaulted for users created before the field existed.
25 changes: 22 additions & 3 deletions packages/api/src/controllers/alerts.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import { groupBy } from 'lodash';
import { Types } from 'mongoose';
import { z } from 'zod';

import { recordOnboardingTaskCompletion } from '@/controllers/user';
import type { ObjectId } from '@/models';
import Alert, {
AlertChannel,
Expand Down Expand Up @@ -288,20 +289,23 @@ export const createAlert = async (
alertInput: z.infer<typeof internalAlertSchema>,
userId: ObjectId,
) => {
return new Alert({
const alert = await new Alert({
...makeAlert(alertInput, userId),
team: teamId,
}).save();
recordOnboardingTaskCompletion(userId, 'alert');
return alert;
};

// create an update alert function based off of the above create alert function
export const updateAlert = async (
id: string,
teamId: ObjectId,
alertInput: AlertInput,
userId?: ObjectId,
) => {
// should consider clearing AlertHistory when updating an alert?
return Alert.findOneAndUpdate(
const alert = await Alert.findOneAndUpdate(
{
_id: id,
team: teamId,
Expand All @@ -311,6 +315,13 @@ export const updateAlert = async (
returnDocument: 'after',
},
);
// Editing an existing alert is still "set up an alert" for onboarding —
// record it the same as create so the checklist completes from any edit,
// not only the initial create. No-op once already recorded.
if (alert != null) {
recordOnboardingTaskCompletion(userId, 'alert');
}
return alert;
};

export const getAlerts = async (
Expand Down Expand Up @@ -367,7 +378,7 @@ export const createOrUpdateDashboardAlerts = async (
alertsByTile: Record<string, AlertInput>,
userId?: ObjectId,
) => {
return Promise.all(
const result = await Promise.all(
Object.entries(alertsByTile).map(async ([tileId, alert]) => {
const filter = {
dashboard: dashboardId,
Expand All @@ -393,6 +404,14 @@ export const createOrUpdateDashboardAlerts = async (
});
}),
);

// A tile alert never goes through the /alerts router, so this is the only
// place a dashboard-tile alert can complete the onboarding task.
if (result.length > 0) {
recordOnboardingTaskCompletion(userId, 'alert');
}

return result;
};

export const deleteDashboardAlerts = async (
Expand Down
17 changes: 17 additions & 0 deletions packages/api/src/controllers/dashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import {
getDashboardAlertsByTile,
getTeamDashboardAlertsByDashboardAndTile,
} from '@/controllers/alerts';
import { recordOnboardingTaskCompletion } from '@/controllers/user';
import type { ObjectId } from '@/models';
import type { AlertDocument, IAlert } from '@/models/alert';
import Dashboard from '@/models/dashboard';
Expand All @@ -27,6 +28,18 @@ function pickAlertsByTile(tiles: Tile[]) {
}, {});
}

// The 'dashboard' onboarding task means the user built something worth charting,
// so it completes only once a dashboard actually has a tile — an empty
// dashboard shell (created, then never filled in) does not count.
function recordDashboardOnboardingIfHasTiles(
userId: ObjectId | undefined,
tiles: { length: number } | null | undefined,
) {
if ((tiles?.length ?? 0) > 0) {
recordOnboardingTaskCompletion(userId, 'dashboard');
}
}

/**
* Rewrite any legacy `chart-1`..`chart-10` tile colors from #2265 in
* an already-serialized dashboard JSON to their hue-named equivalents
Expand Down Expand Up @@ -181,6 +194,8 @@ export async function createDashboard(
userId,
);

recordDashboardOnboardingIfHasTiles(userId, newDashboard.tiles);

return newDashboard;
}

Expand Down Expand Up @@ -232,5 +247,7 @@ export async function updateDashboard(
);
}

recordDashboardOnboardingIfHasTiles(userId, updatedDashboard.tiles);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔵 minor — 'Build a dashboard' / 'Set up an alert' complete on edits where the user built nothing

updateDashboard records dashboard on any successful update of a dashboard that already has tiles — renaming it, changing tags, or dragging a tile all tick the box. Likewise createOrUpdateDashboardAlerts (packages/api/src/controllers/alerts.ts:411) records alert whenever the saved dashboard carries any tile alert, so a user who opens a teammate's dashboard and moves one tile gets both milestones without creating either. updateDashboard already loads oldDashboard, so record only when the tile count went from 0 to >0; for alerts, record only for the tiles whose alert was actually inserted (the upsert result exposes this).


return updatedDashboard;
}
56 changes: 56 additions & 0 deletions packages/api/src/controllers/user.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import type { OnboardingTaskId } from '@hyperdx/common-utils/dist/types';
import mongoose from 'mongoose';
import { v4 as uuidv4 } from 'uuid';

import type { ObjectId } from '@/models';
import Alert from '@/models/alert';
import User from '@/models/user';
import logger from '@/utils/logger';
export function findUserByAccessKey(accessKey: string) {
return User.findOne({ accessKey });
}
Expand Down Expand Up @@ -32,6 +34,60 @@ export function findUsersByTeam(team: string | ObjectId) {
return User.find({ team }).sort({ createdAt: 1 });
}

// Idempotent: $addToSet means completing an already-completed task is a no-op,
// so the frontend can fire optimistically without guarding against duplicates.
// taskId is typed OnboardingTaskId so call sites can't pass an unknown key.
export function completeOnboardingTask(
userId: string | ObjectId,
taskId: OnboardingTaskId,
) {
return User.findByIdAndUpdate(
userId,
{ $addToSet: { 'onboardingData.completedTasks': taskId } },
{ new: true },
);
}

export function setOnboardingDismissed(
userId: string | ObjectId,
isDismissed: boolean,
) {
return User.findByIdAndUpdate(
userId,
{ $set: { 'onboardingData.isDismissed': isDismissed } },
{ new: true },
);
}

// Fire-and-forget wrapper for recording a product-usage task from an unrelated
// write path (creating an alert, saving a dashboard, an MCP tool call).
// Onboarding bookkeeping must never fail or delay the operation that triggered
// it, so errors are swallowed after logging. No-op when userId is absent (e.g.
// a tile alert upserted without an owning user).
//
// Unlike completeOnboardingTask (which the /me/onboarding/task route calls and
// whose returned doc seeds the client cache), this path ignores the result, so
// it guards on $ne to skip the DB write entirely once the task is recorded.
// These call sites fire on every save / every MCP tool call, so skipping the
// redundant $addToSet avoids write amplification on hot paths.
export function recordOnboardingTaskCompletion(
userId: string | ObjectId | undefined | null,
taskId: OnboardingTaskId,
) {
if (userId == null) {
return;
}
void User.updateOne(
{ _id: userId, 'onboardingData.completedTasks': { $ne: taskId } },
{ $addToSet: { 'onboardingData.completedTasks': taskId } },
).catch(err => {
logger.warn(
{ error: err, userId: userId.toString(), taskId },
'Failed to record onboarding task completion',
);
});
}

export async function deleteTeamMember(
teamId: string | ObjectId,
userIdToDelete: string,
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/mcp/tools/alerts/saveAlert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,12 @@ export function registerSaveAlert({

// ── Update existing alert ──
if (alertId) {
const updated = await updateAlert(alertId, mongoTeamId, alertInput);
const updated = await updateAlert(
alertId,
mongoTeamId,
alertInput,
mongoUserId,
);
if (!updated) {
return mcpUserError('Alert not found');
}
Expand Down
7 changes: 6 additions & 1 deletion packages/api/src/mcp/tools/dashboards/patchDashboard.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { uniq } from 'lodash';

import * as config from '@/config';
import { recordOnboardingTaskCompletion } from '@/controllers/user';
import type { ToolRegistrar } from '@/mcp/tools/types';
import { mcpUserError } from '@/mcp/utils/errors';
import Dashboard from '@/models/dashboard';
Expand All @@ -25,7 +26,7 @@ export function registerPatchDashboard({
context,
registerTool,
}: ToolRegistrar): void {
const { teamId } = context;
const { teamId, userId } = context;
const frontendUrl = config.FRONTEND_URL;

registerTool(
Expand Down Expand Up @@ -232,6 +233,10 @@ export function registerPatchDashboard({
});
}

if (updatedDashboard.tiles.length > 0) {
recordOnboardingTaskCompletion(userId, 'dashboard');
}

// Return a lightweight response: the patched tile (if any) plus
// updated dashboard metadata, without the full tile array.
const output: Record<string, unknown> = {
Expand Down
17 changes: 16 additions & 1 deletion packages/api/src/mcp/tools/dashboards/saveDashboard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import mongoose from 'mongoose';
import { z } from 'zod';

import * as config from '@/config';
import { recordOnboardingTaskCompletion } from '@/controllers/user';
import type { ToolRegistrar } from '@/mcp/tools/types';
import { formatZodIssues, mcpUserError } from '@/mcp/utils/errors';
import Dashboard, { IDashboard } from '@/models/dashboard';
Expand Down Expand Up @@ -42,7 +43,7 @@ export function registerSaveDashboard({
context,
registerTool,
}: ToolRegistrar): void {
const { teamId } = context;
const { teamId, userId } = context;
const frontendUrl = config.FRONTEND_URL;

registerTool(
Expand Down Expand Up @@ -84,6 +85,7 @@ export function registerSaveDashboard({
if (!dashboardId) {
return createDashboard({
teamId,
userId,
frontendUrl,
name,
inputTiles,
Expand All @@ -94,6 +96,7 @@ export function registerSaveDashboard({
}
return updateDashboard({
teamId,
userId,
frontendUrl,
dashboardId,
name,
Expand Down Expand Up @@ -146,6 +149,7 @@ function assignFilterIds(

async function createDashboard({
teamId,
userId,
frontendUrl,
name,
inputTiles,
Expand All @@ -154,6 +158,7 @@ async function createDashboard({
inputFilters,
}: {
teamId: string;
userId: string | undefined;
frontendUrl: string | undefined;
name: string;
inputTiles: unknown[];
Expand Down Expand Up @@ -217,6 +222,10 @@ async function createDashboard({
...(parsedContainers !== undefined ? { containers: parsedContainers } : {}),
}).save();

if (newDashboard.tiles.length > 0) {
recordOnboardingTaskCompletion(userId, 'dashboard');
}

const externalDashboard = convertToExternalDashboard(newDashboard);
return {
content: [
Expand Down Expand Up @@ -246,6 +255,7 @@ async function createDashboard({

async function updateDashboard({
teamId,
userId,
frontendUrl,
dashboardId,
name,
Expand All @@ -255,6 +265,7 @@ async function updateDashboard({
inputFilters,
}: {
teamId: string;
userId: string | undefined;
frontendUrl: string | undefined;
dashboardId: string;
name: string;
Expand Down Expand Up @@ -380,6 +391,10 @@ async function updateDashboard({
existingTileIds,
});

if (updatedDashboard.tiles.length > 0) {
recordOnboardingTaskCompletion(userId, 'dashboard');
}

const externalDashboard = convertToExternalDashboard(updatedDashboard);
return {
content: [
Expand Down
6 changes: 6 additions & 0 deletions packages/api/src/mcp/utils/tracing.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { CallToolResult } from '@modelcontextprotocol/sdk/types.js';

import { recordOnboardingTaskCompletion } from '@/controllers/user';
import type { McpContext, ToolResult } from '@/mcp/tools/types';
import type { McpErrorCategory, McpErrorResult } from '@/mcp/utils/errors';
import { getErrorCategory } from '@/mcp/utils/errors';
Expand Down Expand Up @@ -118,6 +119,11 @@ export function withToolTracing<TArgs>(
{ ...logContext, durationMs },
`MCP tool completed: ${toolName}`,
);
// A successful tool call is the only reliable signal that the user
// exercised the MCP server; there is no read-time artifact to detect
// later. Fire-and-forget: onboarding bookkeeping must never affect
// the tool response, and $addToSet makes repeated calls a no-op.
recordOnboardingTaskCompletion(context.userId, 'mcp');
}

span.setAttribute('mcp.tool.duration_ms', durationMs);
Expand Down
21 changes: 21 additions & 0 deletions packages/api/src/models/user.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
import {
ONBOARDING_TASK_IDS,
type OnboardingData,
} from '@hyperdx/common-utils/dist/types';
// @ts-expect-error don't install the @types for this package, as it conflicts with mongoose
import passportLocalMongoose from '@hyperdx/passport-local-mongoose';
import mongoose, { Schema } from 'mongoose';
Expand All @@ -11,6 +15,9 @@ export interface IUser {
createdAt: Date;
email: string;
name: string;
// Optional so documents written before this field existed read back cleanly;
// the `me` route fills in defaults for those legacy users.
onboardingData?: OnboardingData;
team: ObjectId;
}

Expand All @@ -30,6 +37,20 @@ const UserSchema = new Schema(
return uuidv4();
},
},
onboardingData: {
type: new Schema<OnboardingData>(
{
completedTasks: {
type: [String],
enum: ONBOARDING_TASK_IDS,
default: [],
},
isDismissed: { type: Boolean, default: false },
},
{ _id: false },
),
default: () => ({ completedTasks: [], isDismissed: false }),
},
},
{
timestamps: true,
Expand Down
Loading
Loading