Skip to content

Commit ae21d16

Browse files
authored
Instrument project-bluebird (Channels space) with analytics events (#2808)
1 parent 3c2de4a commit ae21d16

22 files changed

Lines changed: 832 additions & 57 deletions
Lines changed: 87 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,87 @@
1+
import { describe, expect, it } from "vitest";
2+
import {
3+
buildCanvasPromptProps,
4+
buildContextSaveProps,
5+
dashboardIdFromThread,
6+
} from "./canvasAnalytics";
7+
8+
describe("dashboardIdFromThread", () => {
9+
it("strips the dashboard: prefix", () => {
10+
expect(dashboardIdFromThread("dashboard:abc-123")).toBe("abc-123");
11+
});
12+
13+
it("leaves an unprefixed id untouched", () => {
14+
expect(dashboardIdFromThread("abc-123")).toBe("abc-123");
15+
});
16+
});
17+
18+
describe("buildCanvasPromptProps", () => {
19+
it("resolves dashboard id and prompt length for a free-typed prompt", () => {
20+
expect(
21+
buildCanvasPromptProps({
22+
surface: "json",
23+
threadId: "dashboard:d1",
24+
text: "hello",
25+
fromSuggestion: false,
26+
}),
27+
).toEqual({
28+
surface: "json",
29+
dashboard_id: "d1",
30+
from_suggestion: false,
31+
prompt_length_chars: 5,
32+
});
33+
});
34+
35+
it("flags suggestion-driven prompts", () => {
36+
const props = buildCanvasPromptProps({
37+
surface: "freeform",
38+
threadId: "dashboard:d2",
39+
text: "build a chart",
40+
fromSuggestion: true,
41+
});
42+
expect(props.from_suggestion).toBe(true);
43+
expect(props.surface).toBe("freeform");
44+
});
45+
46+
it("passes through the ask_agent_to_fix intent and omits it otherwise", () => {
47+
const withIntent = buildCanvasPromptProps({
48+
surface: "freeform",
49+
threadId: "dashboard:d3",
50+
text: "fix it",
51+
fromSuggestion: false,
52+
intent: "ask_agent_to_fix",
53+
});
54+
expect(withIntent.intent).toBe("ask_agent_to_fix");
55+
56+
const withoutIntent = buildCanvasPromptProps({
57+
surface: "freeform",
58+
threadId: "dashboard:d3",
59+
text: "fix it",
60+
fromSuggestion: false,
61+
});
62+
expect(withoutIntent).not.toHaveProperty("intent");
63+
});
64+
});
65+
66+
describe("buildContextSaveProps", () => {
67+
// is_first_version is the negation of hasInstructions; success passes through
68+
// independently. The table covers both inputs against both outcomes.
69+
it.each([
70+
{ hasInstructions: false, success: true, is_first_version: true },
71+
{ hasInstructions: true, success: true, is_first_version: false },
72+
{ hasInstructions: false, success: false, is_first_version: true },
73+
{ hasInstructions: true, success: false, is_first_version: false },
74+
])(
75+
"hasInstructions=$hasInstructions success=$success → is_first_version=$is_first_version",
76+
({ hasInstructions, success, is_first_version }) => {
77+
expect(
78+
buildContextSaveProps({ channelId: "c1", hasInstructions, success }),
79+
).toEqual({
80+
action_type: "save_version",
81+
channel_id: "c1",
82+
is_first_version,
83+
success,
84+
});
85+
},
86+
);
87+
});
Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,48 @@
1+
import type {
2+
CanvasPromptSentProperties,
3+
CanvasPromptSurface,
4+
ContextActionProperties,
5+
} from "@posthog/shared/analytics-events";
6+
7+
/** The dashboardId a canvas thread persists to ("dashboard:<id>" → "<id>"). */
8+
export function dashboardIdFromThread(threadId: string): string {
9+
return threadId.replace(/^dashboard:/, "");
10+
}
11+
12+
/**
13+
* Build the properties for a `CANVAS_PROMPT_SENT` event. Resolves the
14+
* dashboard id from the thread id, measures the prompt length, and folds in
15+
* the suggestion / intent context.
16+
*/
17+
export function buildCanvasPromptProps(opts: {
18+
surface: CanvasPromptSurface;
19+
threadId: string;
20+
text: string;
21+
fromSuggestion: boolean;
22+
intent?: "ask_agent_to_fix";
23+
}): CanvasPromptSentProperties {
24+
return {
25+
surface: opts.surface,
26+
dashboard_id: dashboardIdFromThread(opts.threadId),
27+
from_suggestion: opts.fromSuggestion,
28+
prompt_length_chars: opts.text.length,
29+
...(opts.intent ? { intent: opts.intent } : {}),
30+
};
31+
}
32+
33+
/**
34+
* Build the properties for a `save_version` `CONTEXT_ACTION` event. A channel
35+
* with no published instructions yet is publishing its first version.
36+
*/
37+
export function buildContextSaveProps(opts: {
38+
channelId: string;
39+
hasInstructions: boolean;
40+
success: boolean;
41+
}): ContextActionProperties {
42+
return {
43+
action_type: "save_version",
44+
channel_id: opts.channelId,
45+
is_first_version: !opts.hasInstructions,
46+
success: opts.success,
47+
};
48+
}

packages/shared/src/analytics-events.ts

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -211,6 +211,8 @@ export interface FolderRegisteredProperties {
211211
// Navigation events
212212
export interface CommandMenuActionProperties {
213213
action_type: CommandMenuAction;
214+
/** Channel acted on for the bluebird `open-channel` / `open-task` actions. */
215+
channel_id?: string;
214216
}
215217

216218
export interface SkillButtonTriggeredProperties {
@@ -764,6 +766,119 @@ export interface AgentsActionProperties {
764766
success?: boolean;
765767
}
766768

769+
// ── Project Bluebird / Channels (Website) space events ──
770+
771+
/** Where within the Channels space an interaction originated. */
772+
export type ChannelsSurface =
773+
| "header_button"
774+
| "title_bar"
775+
| "nav"
776+
| "sidebar"
777+
| "command_menu"
778+
| "new_task"
779+
| "dashboards_grid"
780+
| "canvas"
781+
| "context";
782+
783+
export type ChannelActionType =
784+
| "enter_space"
785+
| "leave_space"
786+
| "leave_feedback"
787+
| "nav_click"
788+
| "open_channel"
789+
| "collapse_channel"
790+
| "view_more_tasks"
791+
| "create"
792+
| "rename"
793+
| "delete"
794+
| "star"
795+
| "unstar"
796+
| "edit_context_open"
797+
| "new_task_open"
798+
| "new_task_suggestion"
799+
| "file_task"
800+
| "unfile_task"
801+
| "archive_task"
802+
| "open_task";
803+
804+
export interface ChannelActionProperties {
805+
action_type: ChannelActionType;
806+
surface: ChannelsSurface;
807+
/** The channel acted on, when one is in scope. */
808+
channel_id?: string;
809+
/** For file/unfile/archive/open task actions. */
810+
task_id?: string;
811+
/** For file_task: destination channel when different from `channel_id`. */
812+
target_channel_id?: string;
813+
/** For nav_click: which destination ("home"|"inbox"|"canvas"|"agents"|"files"|"settings"). */
814+
nav_target?: string;
815+
/** For new_task_suggestion: the starter-prompt card label. */
816+
suggestion_label?: string;
817+
/** Whether the underlying mutation resolved successfully. */
818+
success?: boolean;
819+
}
820+
821+
export type DashboardActionType =
822+
| "open"
823+
| "create"
824+
| "delete"
825+
| "save"
826+
| "fork"
827+
| "edit_toggle"
828+
| "revert"
829+
| "refresh"
830+
| "poll_mode_change"
831+
| "date_range_apply";
832+
833+
export interface DashboardActionProperties {
834+
action_type: DashboardActionType;
835+
surface: ChannelsSurface;
836+
channel_id?: string;
837+
dashboard_id?: string;
838+
/** The canvas render kind. */
839+
kind?: "json-render" | "freeform";
840+
/** Template chosen on create. */
841+
template_id?: string;
842+
/** edit_toggle: the state being entered. */
843+
editing?: boolean;
844+
/** poll_mode_change: the new value ("static"|"10s"|"10min"). */
845+
poll_mode?: string;
846+
/** date_range_apply: the named range, when not custom. */
847+
range_name?: string;
848+
/** Whether the underlying mutation resolved successfully. */
849+
success?: boolean;
850+
}
851+
852+
export type CanvasPromptSurface = "json" | "freeform";
853+
854+
export interface CanvasPromptSentProperties {
855+
surface: CanvasPromptSurface;
856+
dashboard_id?: string;
857+
/** True when sent via a suggestion chip rather than free-typed. */
858+
from_suggestion: boolean;
859+
/** "ask_agent_to_fix" for the freeform self-repair path; absent otherwise. */
860+
intent?: "ask_agent_to_fix";
861+
prompt_length_chars: number;
862+
}
863+
864+
export type ContextActionType = "save_version" | "generate_started" | "discard";
865+
866+
export interface ContextActionProperties {
867+
action_type: ContextActionType;
868+
channel_id: string;
869+
/** generate_started only. */
870+
execution_type?: "local" | "cloud";
871+
/** save_version: whether this created the first version vs. an update. */
872+
is_first_version?: boolean;
873+
success?: boolean;
874+
}
875+
876+
export interface ChannelsSpaceViewedProperties {
877+
/** Total channels visible when the space mounts. */
878+
channel_count: number;
879+
starred_count: number;
880+
}
881+
767882
// Subscription / billing events
768883

769884
export type UpgradePromptShownSurface = "usage_limit_modal" | "upgrade_dialog";
@@ -936,6 +1051,13 @@ export const ANALYTICS_EVENTS = {
9361051
CLOUD_TASK_USAGE_BLOCKED: "Cloud task usage blocked",
9371052
SUBSCRIPTION_STARTED: "Subscription started",
9381053
SUBSCRIPTION_CANCELLED: "Subscription cancelled",
1054+
1055+
// Project Bluebird (Channels) events
1056+
CHANNELS_SPACE_VIEWED: "Channels space viewed",
1057+
CHANNEL_ACTION: "Channel action",
1058+
DASHBOARD_ACTION: "Dashboard action",
1059+
CANVAS_PROMPT_SENT: "Canvas prompt sent",
1060+
CONTEXT_ACTION: "Context action",
9391061
} as const;
9401062

9411063
// Event property mapping
@@ -1071,4 +1193,11 @@ export type EventPropertyMap = {
10711193
[ANALYTICS_EVENTS.CLOUD_TASK_USAGE_BLOCKED]: CloudTaskUsageBlockedProperties;
10721194
[ANALYTICS_EVENTS.SUBSCRIPTION_STARTED]: SubscriptionStartedProperties;
10731195
[ANALYTICS_EVENTS.SUBSCRIPTION_CANCELLED]: SubscriptionCancelledProperties;
1196+
1197+
// Project Bluebird (Channels) events
1198+
[ANALYTICS_EVENTS.CHANNELS_SPACE_VIEWED]: ChannelsSpaceViewedProperties;
1199+
[ANALYTICS_EVENTS.CHANNEL_ACTION]: ChannelActionProperties;
1200+
[ANALYTICS_EVENTS.DASHBOARD_ACTION]: DashboardActionProperties;
1201+
[ANALYTICS_EVENTS.CANVAS_PROMPT_SENT]: CanvasPromptSentProperties;
1202+
[ANALYTICS_EVENTS.CONTEXT_ACTION]: ContextActionProperties;
10741203
};

packages/ui/src/features/canvas/components/CanvasChat.tsx

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
import { isNonEmptySpec } from "@json-render/core";
22
import { PaperPlaneRightIcon, SpinnerGapIcon } from "@phosphor-icons/react";
3+
import { buildCanvasPromptProps } from "@posthog/core/canvas/canvasAnalytics";
34
import { Button } from "@posthog/quill";
5+
import { ANALYTICS_EVENTS } from "@posthog/shared/analytics-events";
46
import { useCanvasTemplates } from "@posthog/ui/features/canvas/hooks/useCanvasTemplates";
7+
import { useFromSuggestion } from "@posthog/ui/features/canvas/hooks/useFromSuggestion";
58
import {
69
useCanvasChatStore,
710
useCanvasThread,
811
} from "@posthog/ui/features/canvas/stores/canvasChatStore";
12+
import { track } from "@posthog/ui/shell/analytics";
913
import { Box, Flex, ScrollArea, Text, TextArea } from "@radix-ui/themes";
1014
import { useEffect, useRef, useState } from "react";
1115

@@ -24,9 +28,11 @@ export function CanvasChat({ threadId }: { threadId: string }) {
2428
const [draft, setDraft] = useState("");
2529
const threadRef = useRef<HTMLDivElement>(null);
2630
const inputRef = useRef<HTMLTextAreaElement>(null);
31+
const fromSuggestion = useFromSuggestion();
2732

2833
// Drop a suggestion into the composer and focus it (ready to edit or send).
2934
const fillSuggestion = (text: string) => {
35+
fromSuggestion.mark();
3036
setDraft(text);
3137
inputRef.current?.focus();
3238
};
@@ -40,6 +46,15 @@ export function CanvasChat({ threadId }: { threadId: string }) {
4046
const submit = () => {
4147
const text = draft.trim();
4248
if (!text || isStreaming) return;
49+
track(
50+
ANALYTICS_EVENTS.CANVAS_PROMPT_SENT,
51+
buildCanvasPromptProps({
52+
surface: "json",
53+
threadId,
54+
text,
55+
fromSuggestion: fromSuggestion.consume(),
56+
}),
57+
);
4358
setDraft("");
4459
void send(threadId, text);
4560
};
@@ -133,7 +148,10 @@ export function CanvasChat({ threadId }: { threadId: string }) {
133148
className="flex-1"
134149
placeholder="Build a canvas of…"
135150
value={draft}
136-
onChange={(e) => setDraft(e.target.value)}
151+
onChange={(e) => {
152+
fromSuggestion.clear();
153+
setDraft(e.target.value);
154+
}}
137155
onKeyDown={(e) => {
138156
if (e.key === "Enter" && !e.shiftKey) {
139157
e.preventDefault();

0 commit comments

Comments
 (0)