mirror of
https://github.com/Significant-Gravitas/AutoGPT.git
synced 2026-11-10 04:41:59 -10:00
fix(frontend/onboarding): quieter brain dump prompts and a safe mic retry
- "Otto might ask" is read out only after "Another question"; a question the timer swaps in is no longer announced, so screen readers are not interrupted every 12-20 s and nothing is spoken into the take. - The prompt no longer auto-advances while recording; a click still moves it. - A microphone prompt answered after the typed text was sent no longer starts a take over the processing screen: `start()` takes a `shouldRecord` check, asked once the mic opens. - A refusal while the site is allowed (the OS blocking the browser) gets copy pointing at the computer's privacy settings. - `brain_dump_permission_retry` is now `brain_dump_permission_retried`, and both new events use `via` instead of `trigger`, per the tracking plan. - The tracking plan lists the new events and properties, and notes that `brain_dump_permission_denied` and `brain_dump_mic_failed` repeat on refused retries. Co-authored-by: Claude Opus 5.5 (Claude Code) <[email protected]>
This commit is contained in:
1 parent
8a781ddfb3
commit
68cedd65a6
17 files changed
+525
-337
No files matched your search
+22
-11
@@ -505,6 +505,17 @@ describe("onboarding brain dump — typed fallback", () => {
|
||||
getUserMedia.mockRejectedValue(
|
||||
new DOMException("denied", "NotAllowedError"),
|
||||
);
|
||||
// happy-dom reports every permission as granted, which reads as the
|
||||
// system refusing an allowed site.
|
||||
Object.defineProperty(navigator, "permissions", {
|
||||
configurable: true,
|
||||
value: {
|
||||
query: vi.fn().mockResolvedValue(new FakePermissionStatus("denied")),
|
||||
},
|
||||
});
|
||||
onTestFinished(() => {
|
||||
Reflect.deleteProperty(navigator, "permissions");
|
||||
});
|
||||
landOnPainPointsStep();
|
||||
|
||||
render(<OnboardingPage />);
|
||||
@@ -1306,7 +1317,7 @@ describe("onboarding brain dump — Otto might ask", () => {
|
||||
await screen.findByText(ENGINEERING.questions[5].text),
|
||||
).toBeDefined();
|
||||
expect(posthog.capture).toHaveBeenCalledWith("brain_dump_prompt_advanced", {
|
||||
trigger: "click",
|
||||
via: "click",
|
||||
screen: "typing",
|
||||
prompt_index: 5,
|
||||
prompt_set: "Engineering",
|
||||
@@ -1473,18 +1484,18 @@ describe("onboarding brain dump — a refused microphone", () => {
|
||||
await screen.findByRole("button", { name: "Send recording" }),
|
||||
).toBeDefined();
|
||||
expect(screen.queryByRole("alert")).toBeNull();
|
||||
expect(captured("brain_dump_permission_retry")).toEqual([
|
||||
expect(captured("brain_dump_permission_retried")).toEqual([
|
||||
[
|
||||
"brain_dump_permission_retry",
|
||||
"brain_dump_permission_retried",
|
||||
{
|
||||
result: "granted",
|
||||
permission_state: "prompt",
|
||||
trigger: "retry_link",
|
||||
via: "retry_link",
|
||||
},
|
||||
],
|
||||
]);
|
||||
const names = capturedNames();
|
||||
expect(names.indexOf("brain_dump_permission_retry")).toBeLessThan(
|
||||
expect(names.indexOf("brain_dump_permission_retried")).toBeLessThan(
|
||||
names.indexOf("brain_dump_started"),
|
||||
);
|
||||
});
|
||||
@@ -1501,13 +1512,13 @@ describe("onboarding brain dump — a refused microphone", () => {
|
||||
await waitFor(() =>
|
||||
expect(captured("brain_dump_permission_denied")).toHaveLength(2),
|
||||
);
|
||||
expect(captured("brain_dump_permission_retry")).toEqual([
|
||||
expect(captured("brain_dump_permission_retried")).toEqual([
|
||||
[
|
||||
"brain_dump_permission_retry",
|
||||
"brain_dump_permission_retried",
|
||||
{
|
||||
result: "denied",
|
||||
permission_state: "denied",
|
||||
trigger: "record_instead",
|
||||
via: "record_instead",
|
||||
},
|
||||
],
|
||||
]);
|
||||
@@ -1527,13 +1538,13 @@ describe("onboarding brain dump — a refused microphone", () => {
|
||||
});
|
||||
|
||||
await waitFor(() => expect(screen.queryByRole("alert")).toBeNull());
|
||||
expect(captured("brain_dump_permission_retry")).toEqual([
|
||||
expect(captured("brain_dump_permission_retried")).toEqual([
|
||||
[
|
||||
"brain_dump_permission_retry",
|
||||
"brain_dump_permission_retried",
|
||||
{
|
||||
result: "granted",
|
||||
permission_state: "granted",
|
||||
trigger: "permission_change",
|
||||
via: "permission_change",
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
+2
@@ -92,6 +92,7 @@ export function BrainDumpStep() {
|
||||
isTooLong={dump.isTypedTooLong}
|
||||
onSubmit={dump.handleSubmitTyped}
|
||||
question={dump.prompt.question.text}
|
||||
announcement={dump.prompt.announcement}
|
||||
onAnotherQuestion={showAnotherQuestion}
|
||||
isMicBlocked={dump.isMicBlocked}
|
||||
micPermission={dump.micPermission}
|
||||
@@ -102,6 +103,7 @@ export function BrainDumpStep() {
|
||||
<OttoMightAsk
|
||||
question={dump.prompt.question.text}
|
||||
onAnother={showAnotherQuestion}
|
||||
announcement={dump.prompt.announcement}
|
||||
/>
|
||||
<RecordingControls
|
||||
onStop={dump.handleStop}
|
||||
|
||||
+24
@@ -395,6 +395,30 @@ describe("useBrainDumpRecorder", () => {
|
||||
|
||||
expect(onMicAnswer).toHaveBeenCalledExactlyOnceWith("unavailable");
|
||||
});
|
||||
|
||||
it("starts no take when the caller no longer wants one", async () => {
|
||||
const track = { stop: vi.fn() };
|
||||
stubGetUserMedia(vi.fn().mockResolvedValue({ getTracks: () => [track] }));
|
||||
const onMicAnswer = vi.fn();
|
||||
const { result } = renderHook(() => useBrainDumpRecorder());
|
||||
|
||||
let started: boolean | undefined;
|
||||
await act(async () => {
|
||||
started = await result.current.start({
|
||||
onMicAnswer,
|
||||
shouldRecord: () => false,
|
||||
});
|
||||
});
|
||||
|
||||
expect(started).toBe(false);
|
||||
expect(onMicAnswer).toHaveBeenCalledExactlyOnceWith("granted");
|
||||
expect(track.stop).toHaveBeenCalled();
|
||||
expect(result.current.recordingId).toBeNull();
|
||||
expect(trackBrainDump).not.toHaveBeenCalledWith(
|
||||
"brain_dump_started",
|
||||
expect.anything(),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// A dismissed prompt asks again, and a blocked site can be allowed from
|
||||
|
||||
+60
-17
@@ -1145,11 +1145,14 @@ describe("useBrainDumpStep — a refused microphone", () => {
|
||||
expect(result.current.screen).toBe("recording");
|
||||
expect(result.current.isMicBlocked).toBe(false);
|
||||
// No Permissions API in this browser.
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_permission_retry", {
|
||||
result: "granted",
|
||||
permission_state: "unknown",
|
||||
trigger: "retry_link",
|
||||
});
|
||||
expect(trackBrainDump).toHaveBeenCalledWith(
|
||||
"brain_dump_permission_retried",
|
||||
{
|
||||
result: "granted",
|
||||
permission_state: "unknown",
|
||||
via: "retry_link",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("stays on typing and counts one fallback when refused again", async () => {
|
||||
@@ -1162,11 +1165,14 @@ describe("useBrainDumpStep — a refused microphone", () => {
|
||||
|
||||
expect(result.current.screen).toBe("typing");
|
||||
expect(result.current.isMicBlocked).toBe(true);
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_permission_retry", {
|
||||
result: "denied",
|
||||
permission_state: "unknown",
|
||||
trigger: "record_instead",
|
||||
});
|
||||
expect(trackBrainDump).toHaveBeenCalledWith(
|
||||
"brain_dump_permission_retried",
|
||||
{
|
||||
result: "denied",
|
||||
permission_state: "unknown",
|
||||
via: "record_instead",
|
||||
},
|
||||
);
|
||||
expect(trackedCount("brain_dump_typed_fallback")).toBe(1);
|
||||
});
|
||||
|
||||
@@ -1184,7 +1190,7 @@ describe("useBrainDumpStep — a refused microphone", () => {
|
||||
expect(result.current.screen).toBe("typing");
|
||||
expect(result.current.isMicBlocked).toBe(false);
|
||||
expect(trackBrainDump).toHaveBeenCalledWith(
|
||||
"brain_dump_permission_retry",
|
||||
"brain_dump_permission_retried",
|
||||
expect.objectContaining({ result: "unavailable" }),
|
||||
);
|
||||
});
|
||||
@@ -1207,6 +1213,40 @@ describe("useBrainDumpStep — a refused microphone", () => {
|
||||
expect(recorderState.start).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("lets the microphone go when it is allowed after the typed text was sent", async () => {
|
||||
let allow!: () => void;
|
||||
let started: boolean | undefined;
|
||||
recorderState.start.mockImplementation(
|
||||
(options?: { shouldRecord?: () => boolean }) =>
|
||||
new Promise<boolean>((resolve) => {
|
||||
allow = () => {
|
||||
started = options?.shouldRecord?.() !== false;
|
||||
resolve(started);
|
||||
};
|
||||
}),
|
||||
);
|
||||
finalizeBrainDump.mockReturnValue(
|
||||
deferred<ReturnType<typeof completed>>().promise,
|
||||
);
|
||||
const { result } = await renderRefused();
|
||||
|
||||
act(() => {
|
||||
void result.current.handleRetryMicrophone();
|
||||
result.current.setTypedText("I run a bakery.");
|
||||
});
|
||||
act(() => {
|
||||
void result.current.handleSubmitTyped();
|
||||
});
|
||||
await waitFor(() => expect(result.current.screen).toBe("processing"));
|
||||
|
||||
await act(async () => {
|
||||
allow();
|
||||
});
|
||||
|
||||
expect(started).toBe(false);
|
||||
expect(result.current.screen).toBe("processing");
|
||||
});
|
||||
|
||||
it("goes back to the orb from record instead when nothing was refused", async () => {
|
||||
const { result } = await renderStep();
|
||||
act(() => {
|
||||
@@ -1239,11 +1279,14 @@ describe("useBrainDumpStep — a refused microphone", () => {
|
||||
|
||||
expect(result.current.isMicBlocked).toBe(false);
|
||||
expect(result.current.screen).toBe("typing");
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_permission_retry", {
|
||||
result: "granted",
|
||||
permission_state: "granted",
|
||||
trigger: "permission_change",
|
||||
});
|
||||
expect(trackBrainDump).toHaveBeenCalledWith(
|
||||
"brain_dump_permission_retried",
|
||||
{
|
||||
result: "granted",
|
||||
permission_state: "granted",
|
||||
via: "permission_change",
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
it("clears it without a retry event when the block is only reset", async () => {
|
||||
@@ -1263,7 +1306,7 @@ describe("useBrainDumpStep — a refused microphone", () => {
|
||||
});
|
||||
|
||||
expect(result.current.isMicBlocked).toBe(false);
|
||||
expect(trackedCount("brain_dump_permission_retry")).toBe(0);
|
||||
expect(trackedCount("brain_dump_permission_retried")).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
+53
-12
@@ -67,28 +67,69 @@ describe("usePromptRotation", () => {
|
||||
expect(trackBrainDump).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each([
|
||||
["typing", 20_000],
|
||||
["recording", 15_000],
|
||||
] as const)("auto-advances every %s interval", async (screen, interval) => {
|
||||
const { result } = renderRotation({ role: "Operations", screen });
|
||||
it("auto-advances every 20 seconds while typing", async () => {
|
||||
const { result } = renderRotation({ role: "Operations", screen: "typing" });
|
||||
|
||||
await wait(interval - 1);
|
||||
await wait(19_999);
|
||||
expect(result.current.index).toBe(4);
|
||||
|
||||
await wait(1);
|
||||
expect(result.current.question).toEqual(OPERATIONS[5]);
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_prompt_advanced", {
|
||||
trigger: "auto",
|
||||
screen,
|
||||
via: "auto",
|
||||
screen: "typing",
|
||||
prompt_index: 5,
|
||||
prompt_set: "Operations",
|
||||
});
|
||||
|
||||
await wait(interval);
|
||||
await wait(20_000);
|
||||
expect(result.current.index).toBe(6);
|
||||
});
|
||||
|
||||
it("only moves on a click while recording", async () => {
|
||||
const { result } = renderRotation({
|
||||
role: "Operations",
|
||||
screen: "recording",
|
||||
});
|
||||
|
||||
await wait(120_000);
|
||||
expect(result.current.index).toBe(4);
|
||||
expect(trackBrainDump).not.toHaveBeenCalled();
|
||||
|
||||
act(() => result.current.advance("click"));
|
||||
expect(result.current.index).toBe(5);
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_prompt_advanced", {
|
||||
via: "click",
|
||||
screen: "recording",
|
||||
prompt_index: 5,
|
||||
prompt_set: "Operations",
|
||||
});
|
||||
});
|
||||
|
||||
it("announces only a question the user asked for", async () => {
|
||||
const { result, rerender } = renderRotation({
|
||||
role: "Operations",
|
||||
screen: "typing",
|
||||
});
|
||||
expect(result.current.announcement).toBe("");
|
||||
|
||||
await wait(20_000);
|
||||
expect(result.current.index).toBe(5);
|
||||
expect(result.current.announcement).toBe("");
|
||||
|
||||
act(() => result.current.advance("click"));
|
||||
expect(result.current.announcement).toBe(OPERATIONS[6].text);
|
||||
|
||||
// Another screen mounts its own prompt; the old click is not read again.
|
||||
rerender({ role: "Operations", screen: "recording" });
|
||||
expect(result.current.announcement).toBe("");
|
||||
rerender({ role: "Operations", screen: "typing" });
|
||||
|
||||
await wait(20_000);
|
||||
expect(result.current.index).toBe(7);
|
||||
expect(result.current.announcement).toBe("");
|
||||
});
|
||||
|
||||
it("rotates at rest only without the ambient cards, every 12 seconds", async () => {
|
||||
const desktop = renderRotation({ role: "Operations", screen: "rest" });
|
||||
await wait(60_000);
|
||||
@@ -101,7 +142,7 @@ describe("usePromptRotation", () => {
|
||||
expect(phone.result.current.index).toBe(5);
|
||||
expect(trackBrainDump).toHaveBeenCalledWith(
|
||||
"brain_dump_prompt_advanced",
|
||||
expect.objectContaining({ screen: "rest", trigger: "auto" }),
|
||||
expect.objectContaining({ screen: "rest", via: "auto" }),
|
||||
);
|
||||
});
|
||||
|
||||
@@ -114,7 +155,7 @@ describe("usePromptRotation", () => {
|
||||
});
|
||||
expect(result.current.index).toBe(5);
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_prompt_advanced", {
|
||||
trigger: "click",
|
||||
via: "click",
|
||||
screen: "typing",
|
||||
prompt_index: 5,
|
||||
prompt_set: "Operations",
|
||||
@@ -223,7 +264,7 @@ describe("usePromptRotation", () => {
|
||||
it("clears its timer when unmounted", () => {
|
||||
const { unmount } = renderRotation({
|
||||
role: "Operations",
|
||||
screen: "recording",
|
||||
screen: "typing",
|
||||
});
|
||||
expect(vi.getTimerCount()).toBeGreaterThan(0);
|
||||
|
||||
|
||||
+14
-1
@@ -23,11 +23,18 @@ const COPY = {
|
||||
reason: "The microphone prompt was closed, so write here instead.",
|
||||
howTo: "To talk,",
|
||||
},
|
||||
// The site is allowed but the operating system refused the browser, as
|
||||
// Chrome on a Mac without microphone access in System Settings does.
|
||||
system: {
|
||||
reason: "Your computer blocked the microphone, so write here instead.",
|
||||
howTo:
|
||||
"To talk, let your browser use the microphone in your computer's privacy settings, then",
|
||||
},
|
||||
} as const;
|
||||
|
||||
export function MicBlockedNotice({ isBlocked, permission, onRetry }: Props) {
|
||||
if (!isBlocked) return null;
|
||||
const copy = permission === "prompt" ? COPY.dismissed : COPY.blocked;
|
||||
const copy = noticeCopy(permission);
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -58,3 +65,9 @@ export function MicBlockedNotice({ isBlocked, permission, onRetry }: Props) {
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function noticeCopy(permission: MicrophonePermission) {
|
||||
if (permission === "prompt") return COPY.dismissed;
|
||||
if (permission === "granted") return COPY.system;
|
||||
return COPY.blocked;
|
||||
}
|
||||
+6
-2
@@ -10,6 +10,8 @@ interface Props {
|
||||
// Without it there is no "Another question": the phone's rest screen
|
||||
// shows the prompt as a hint only.
|
||||
onAnother?: () => void;
|
||||
// The question the user just asked for, read out once; empty otherwise.
|
||||
announcement?: string;
|
||||
align?: "left" | "center";
|
||||
compact?: boolean;
|
||||
className?: string;
|
||||
@@ -18,6 +20,7 @@ interface Props {
|
||||
export function OttoMightAsk({
|
||||
question,
|
||||
onAnother,
|
||||
announcement = "",
|
||||
align = "center",
|
||||
compact = false,
|
||||
className,
|
||||
@@ -74,13 +77,14 @@ export function OttoMightAsk({
|
||||
</Button>
|
||||
)}
|
||||
{/* The only announcement of the prompt layer: the ambient cards are
|
||||
hidden from assistive tech. */}
|
||||
hidden from assistive tech, and a question the timer swaps in is
|
||||
left for the user to find. */}
|
||||
<p
|
||||
aria-live="polite"
|
||||
data-testid="otto-might-ask-announcer"
|
||||
className="sr-only"
|
||||
>
|
||||
{`Otto might ask: ${question}`}
|
||||
{announcement && `Otto might ask: ${announcement}`}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
|
||||
+3
@@ -13,6 +13,7 @@ interface Props {
|
||||
onSubmit: () => void;
|
||||
isTooLong: boolean;
|
||||
question: string;
|
||||
announcement: string;
|
||||
onAnotherQuestion: () => void;
|
||||
isMicBlocked: boolean;
|
||||
micPermission: MicrophonePermission;
|
||||
@@ -25,6 +26,7 @@ export function TypingComposer({
|
||||
onSubmit,
|
||||
isTooLong,
|
||||
question,
|
||||
announcement,
|
||||
onAnotherQuestion,
|
||||
isMicBlocked,
|
||||
micPermission,
|
||||
@@ -44,6 +46,7 @@ export function TypingComposer({
|
||||
<OttoMightAsk
|
||||
question={question}
|
||||
onAnother={onAnotherQuestion}
|
||||
announcement={announcement}
|
||||
align="left"
|
||||
/>
|
||||
{/* Fewer rows on a short laptop screen, and one fewer while the
|
||||
|
||||
+3
@@ -7,12 +7,15 @@ const BLOCKED =
|
||||
"Your browser blocked the microphone, so write here instead. To talk, allow the microphone in your browser's site settings (the lock or microphone icon by the address bar), then try the microphone again.";
|
||||
const DISMISSED =
|
||||
"The microphone prompt was closed, so write here instead. To talk, try the microphone again.";
|
||||
const SYSTEM =
|
||||
"Your computer blocked the microphone, so write here instead. To talk, let your browser use the microphone in your computer's privacy settings, then try the microphone again.";
|
||||
|
||||
describe("MicBlockedNotice", () => {
|
||||
it.each([
|
||||
["denied", BLOCKED],
|
||||
["unknown", BLOCKED],
|
||||
["prompt", DISMISSED],
|
||||
["granted", SYSTEM],
|
||||
] as const)(
|
||||
"explains a %s microphone and how to get it back",
|
||||
(permission, copy) => {
|
||||
|
||||
+4
-3
@@ -29,6 +29,7 @@ function LivePrompt() {
|
||||
<OttoMightAsk
|
||||
question={prompt.question.text}
|
||||
onAnother={() => prompt.advance("click")}
|
||||
announcement={prompt.announcement}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -55,19 +56,19 @@ describe("OttoMightAsk", () => {
|
||||
expect(await screen.findByText(QUESTIONS[5].text)).toBeDefined();
|
||||
expect(screen.queryByText(QUESTIONS[4].text)).toBeNull();
|
||||
expect(trackBrainDump).toHaveBeenCalledWith("brain_dump_prompt_advanced", {
|
||||
trigger: "click",
|
||||
via: "click",
|
||||
screen: "recording",
|
||||
prompt_index: 5,
|
||||
prompt_set: "Sales/BD",
|
||||
});
|
||||
});
|
||||
|
||||
it("announces the current question politely", async () => {
|
||||
it("announces a question only once the user asks for it", async () => {
|
||||
render(<LivePrompt />);
|
||||
const region = screen.getByTestId("otto-might-ask-announcer");
|
||||
|
||||
expect(region.getAttribute("aria-live")).toBe("polite");
|
||||
expect(region.textContent).toBe(`Otto might ask: ${QUESTIONS[4].text}`);
|
||||
expect(region.textContent).toBe("");
|
||||
|
||||
await userEvent.click(
|
||||
screen.getByRole("button", { name: "Another question" }),
|
||||
|
||||
+5
-2
@@ -34,6 +34,9 @@ interface StartOptions {
|
||||
// Called once the browser has answered, before the take starts, so a
|
||||
// caller can report the answer ahead of `brain_dump_started`.
|
||||
onMicAnswer?: (answer: MicAnswer) => void;
|
||||
// Asked once the microphone opens: the caller may have moved on while the
|
||||
// permission prompt was open, and then no take is started.
|
||||
shouldRecord?: () => boolean;
|
||||
}
|
||||
|
||||
export function useBrainDumpRecorder({ promptSet }: Args = {}) {
|
||||
@@ -136,7 +139,7 @@ export function useBrainDumpRecorder({ promptSet }: Args = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openMicAndRecord({ onMicAnswer }: StartOptions) {
|
||||
async function openMicAndRecord({ onMicAnswer, shouldRecord }: StartOptions) {
|
||||
// Permission is requested on the first tap, never on screen load —
|
||||
// a browser prompt before the user has read the headline is how
|
||||
// denial rates go up.
|
||||
@@ -154,7 +157,7 @@ export function useBrainDumpRecorder({ promptSet }: Args = {}) {
|
||||
setMicUnavailable(false);
|
||||
// Back during the prompt, or a skip during "Record again", can leave the
|
||||
// step before the mic opens, and its unmount cleanup has already run.
|
||||
if (unmountedRef.current) {
|
||||
if (unmountedRef.current || shouldRecord?.() === false) {
|
||||
stream.getTracks().forEach((track) => track.stop());
|
||||
return false;
|
||||
}
|
||||
|
||||
+7
@@ -52,8 +52,15 @@ export function useBrainDumpStep() {
|
||||
|
||||
const [screen, setScreen] = useState<ScreenState>("rest");
|
||||
const prompt = usePromptRotation({ role, screen });
|
||||
// For answers that arrive after the render that asked, like a permission
|
||||
// prompt answered once the typed text is already on its way.
|
||||
const screenRef = useRef(screen);
|
||||
useEffect(() => {
|
||||
screenRef.current = screen;
|
||||
});
|
||||
const microphone = useMicrophoneRetry({
|
||||
recorder,
|
||||
canRecord: () => screenRef.current === "typing",
|
||||
onRecording: () => setScreen("recording"),
|
||||
});
|
||||
// A refused microphone is one fallback to typing per visit, however many
|
||||
|
||||
+11
-7
@@ -11,10 +11,13 @@ type Recorder = Pick<
|
||||
"permissionDenied" | "clearPermissionDenied" | "start"
|
||||
>;
|
||||
|
||||
type MicRetryTrigger = "retry_link" | "record_instead";
|
||||
type MicRetryVia = "retry_link" | "record_instead";
|
||||
|
||||
interface Args {
|
||||
recorder: Recorder;
|
||||
// Whether the user still wants the take once the prompt is answered: the
|
||||
// typed text may have been sent while it was open.
|
||||
canRecord: () => boolean;
|
||||
// The retry opened the microphone and the take is running.
|
||||
onRecording: () => void;
|
||||
}
|
||||
@@ -23,7 +26,7 @@ interface Args {
|
||||
// refuses with the same error whether the prompt was blocked or only
|
||||
// closed, and a block is one click to undo by the address bar, so neither
|
||||
// is treated as final.
|
||||
export function useMicrophoneRetry({ recorder, onRecording }: Args) {
|
||||
export function useMicrophoneRetry({ recorder, canRecord, onRecording }: Args) {
|
||||
const permission = useMicrophonePermission({
|
||||
onChange: handlePermissionChange,
|
||||
});
|
||||
@@ -37,14 +40,14 @@ export function useMicrophoneRetry({ recorder, onRecording }: Args) {
|
||||
if (state !== "granted" && state !== "prompt") return;
|
||||
recorder.clearPermissionDenied();
|
||||
if (state !== "granted") return;
|
||||
trackBrainDump("brain_dump_permission_retry", {
|
||||
trackBrainDump("brain_dump_permission_retried", {
|
||||
result: "granted",
|
||||
permission_state: state,
|
||||
trigger: "permission_change",
|
||||
via: "permission_change",
|
||||
});
|
||||
}
|
||||
|
||||
async function retry(trigger: MicRetryTrigger) {
|
||||
async function retry(via: MicRetryVia) {
|
||||
if (isRetryingRef.current) return;
|
||||
isRetryingRef.current = true;
|
||||
const permissionState = permission.state;
|
||||
@@ -54,12 +57,13 @@ export function useMicrophoneRetry({ recorder, onRecording }: Args) {
|
||||
try {
|
||||
started = await recorder.start({
|
||||
onMicAnswer(result) {
|
||||
trackBrainDump("brain_dump_permission_retry", {
|
||||
trackBrainDump("brain_dump_permission_retried", {
|
||||
result,
|
||||
permission_state: permissionState,
|
||||
trigger,
|
||||
via,
|
||||
});
|
||||
},
|
||||
shouldRecord: canRecord,
|
||||
});
|
||||
} finally {
|
||||
isRetryingRef.current = false;
|
||||
|
||||
+46
-19
@@ -13,16 +13,17 @@ import { useIsWideLayout } from "./useIsWideLayout";
|
||||
// the ambient layer shows.
|
||||
const FIRST_INDEX = 4;
|
||||
|
||||
// Where the live prompt is on screen, and how long each question stays.
|
||||
// At rest it only shows where the ambient cards do not fit.
|
||||
const AUTO_ADVANCE_MS = {
|
||||
// Where the live prompt is on screen. At rest it only shows where the
|
||||
// ambient cards do not fit.
|
||||
type PromptScreen = "rest" | "typing" | "recording";
|
||||
type AdvanceVia = "auto" | "click";
|
||||
|
||||
// How long each question stays. While recording it only moves on a click: a
|
||||
// question changing mid-answer pulls the speaker off their thought.
|
||||
const AUTO_ADVANCE_MS: Partial<Record<PromptScreen, number>> = {
|
||||
rest: 12_000,
|
||||
typing: 20_000,
|
||||
recording: 15_000,
|
||||
} as const;
|
||||
|
||||
type PromptScreen = keyof typeof AUTO_ADVANCE_MS;
|
||||
type AdvanceTrigger = "auto" | "click";
|
||||
};
|
||||
|
||||
interface Args {
|
||||
role: string;
|
||||
@@ -33,9 +34,12 @@ export function usePromptRotation({ role, screen }: Args) {
|
||||
const promptSet = promptSetNameForRole(role);
|
||||
const hasAmbientCards = useIsWideLayout();
|
||||
const reduceMotion = useReducedMotion() === true;
|
||||
const [rotation, setRotation] = useState({ promptSet, index: FIRST_INDEX });
|
||||
const index = rotation.promptSet === promptSet ? rotation.index : FIRST_INDEX;
|
||||
const [rotation, setRotation] = useState<Rotation>(firstRotation(promptSet));
|
||||
const current =
|
||||
rotation.promptSet === promptSet ? rotation : firstRotation(promptSet);
|
||||
const { index } = current;
|
||||
const shownOn = livePromptScreen(screen, hasAmbientCards);
|
||||
const question = BRAIN_DUMP_PROMPT_SETS[promptSet].questions[index];
|
||||
const seenRef = useRef(new Set<string>());
|
||||
|
||||
useEffect(() => {
|
||||
@@ -45,29 +49,48 @@ export function usePromptRotation({ role, screen }: Args) {
|
||||
// Restarted on every advance, so a click buys the new question its full
|
||||
// time on screen.
|
||||
useEffect(() => {
|
||||
if (!shownOn || reduceMotion) return;
|
||||
const interval = shownOn ? AUTO_ADVANCE_MS[shownOn] : undefined;
|
||||
if (!shownOn || !interval || reduceMotion) return;
|
||||
const timer = setInterval(() => {
|
||||
if (document.visibilityState !== "visible") return;
|
||||
setRotation(advanceFrom({ promptSet, index, screen: shownOn }, "auto"));
|
||||
}, AUTO_ADVANCE_MS[shownOn]);
|
||||
}, interval);
|
||||
return () => clearInterval(timer);
|
||||
}, [shownOn, reduceMotion, promptSet, index]);
|
||||
|
||||
function advance(trigger: AdvanceTrigger) {
|
||||
function advance(via: AdvanceVia) {
|
||||
if (!shownOn) return;
|
||||
setRotation(advanceFrom({ promptSet, index, screen: shownOn }, trigger));
|
||||
setRotation(advanceFrom({ promptSet, index, screen: shownOn }, via));
|
||||
}
|
||||
|
||||
return {
|
||||
question: BRAIN_DUMP_PROMPT_SETS[promptSet].questions[index],
|
||||
question,
|
||||
index,
|
||||
promptSet,
|
||||
advance,
|
||||
// Only a question the user asked for is read out: one announced on
|
||||
// every timer tick would talk over a screen reader, and into the take.
|
||||
announcement:
|
||||
current.clickedOn !== null && current.clickedOn === shownOn
|
||||
? question.text
|
||||
: "",
|
||||
// Read when an event is sent, never while rendering.
|
||||
promptsSeen: () => seenRef.current.size,
|
||||
};
|
||||
}
|
||||
|
||||
interface Rotation {
|
||||
promptSet: PromptSetName;
|
||||
index: number;
|
||||
// The screen "Another question" was last clicked on, until the timer
|
||||
// moves the question on.
|
||||
clickedOn: PromptScreen | null;
|
||||
}
|
||||
|
||||
function firstRotation(promptSet: PromptSetName): Rotation {
|
||||
return { promptSet, index: FIRST_INDEX, clickedOn: null };
|
||||
}
|
||||
|
||||
function livePromptScreen(
|
||||
screen: ScreenState,
|
||||
hasAmbientCards: boolean,
|
||||
@@ -79,15 +102,19 @@ function livePromptScreen(
|
||||
|
||||
function advanceFrom(
|
||||
from: { promptSet: PromptSetName; index: number; screen: PromptScreen },
|
||||
trigger: AdvanceTrigger,
|
||||
) {
|
||||
via: AdvanceVia,
|
||||
): Rotation {
|
||||
const { questions } = BRAIN_DUMP_PROMPT_SETS[from.promptSet];
|
||||
const next = (from.index + 1) % questions.length;
|
||||
trackBrainDump("brain_dump_prompt_advanced", {
|
||||
trigger,
|
||||
via,
|
||||
screen: from.screen,
|
||||
prompt_index: next,
|
||||
prompt_set: from.promptSet,
|
||||
});
|
||||
return { promptSet: from.promptSet, index: next };
|
||||
return {
|
||||
promptSet: from.promptSet,
|
||||
index: next,
|
||||
clickedOn: via === "click" ? from.screen : null,
|
||||
};
|
||||
}
|
||||
@@ -13,7 +13,7 @@ const LIVE_EVENT_NAMES = [
|
||||
"brain_dump_download",
|
||||
"brain_dump_mic_failed",
|
||||
"brain_dump_permission_denied",
|
||||
"brain_dump_permission_retry",
|
||||
"brain_dump_permission_retried",
|
||||
"brain_dump_prompt_advanced",
|
||||
"brain_dump_recovery_shown",
|
||||
"brain_dump_recovery_used",
|
||||
|
||||
@@ -70,17 +70,17 @@ export const BrainDumpEvent = {
|
||||
// browser's `error_name` (NotFoundError, NotReadableError, …).
|
||||
BRAIN_DUMP_MIC_FAILED: "brain_dump_mic_failed",
|
||||
BRAIN_DUMP_TYPED_FALLBACK: "brain_dump_typed_fallback",
|
||||
// The "Otto might ask" prompt moved on, by its timer (`trigger: auto`) or
|
||||
// The "Otto might ask" prompt moved on, by its timer (`via: auto`) or
|
||||
// "Another question" (`click`), with the `screen`, the `prompt_index` now
|
||||
// shown and the role's `prompt_set`. Never sent for the first prompt.
|
||||
BRAIN_DUMP_PROMPT_ADVANCED: "brain_dump_prompt_advanced",
|
||||
// A way back to Talk after the microphone was refused: `result` is what
|
||||
// the browser answered (`granted`, `denied`, or `unavailable` for any
|
||||
// other mic error), `trigger` is the link used or `permission_change`
|
||||
// when the user allowed the site in its settings. `permission_state` is
|
||||
// the Permissions API state when the retry began, or the one it changed
|
||||
// to for `permission_change`; `unknown` where the API is missing.
|
||||
BRAIN_DUMP_PERMISSION_RETRY: "brain_dump_permission_retry",
|
||||
// other mic error), `via` is the link used or `permission_change` when
|
||||
// the user allowed the site in its settings. `permission_state` is the
|
||||
// Permissions API state when the retry began, or the one it changed to
|
||||
// for `permission_change`; `unknown` where the API is missing.
|
||||
BRAIN_DUMP_PERMISSION_RETRIED: "brain_dump_permission_retried",
|
||||
// The welcome dialog shown on first copilot landing was closed — the
|
||||
// greeting fetch and reveal animation start from this moment.
|
||||
WELCOME_DIALOG_CLOSED: "welcome_dialog_closed",
|
||||
|
||||
+258
-256
@@ -14,7 +14,7 @@ the tour's tracking), the server-side `signup` goal
|
||||
along to the backend (`X-Datafast-*` headers) and into Stripe Checkout
|
||||
metadata, so purchases are attributed to the channel that brought the visitor.
|
||||
|
||||
Event names follow the product analytics plan, *Every Second Counts* (owner:
|
||||
Event names follow the product analytics plan, _Every Second Counts_ (owner:
|
||||
Toran), section "What we will record". Where that plan names an event for an
|
||||
action, this list uses its name; [Differences from the analytics plan](#differences-from-the-analytics-plan)
|
||||
lists what is not aligned yet.
|
||||
@@ -25,9 +25,9 @@ against these modules, and backend funnel `data_index` keys embed the name
|
||||
too (`briefing_generated:<id>`, `briefing_delivered:<id>`). A
|
||||
rename therefore has to search for the old string, not just edit the modules:
|
||||
|
||||
| Sender | Module | Pin test |
|
||||
| --- | --- | --- |
|
||||
| Backend | `autogpt_platform/backend/backend/util/posthog_events.py` (`PostHogEvent`, `PlannedPostHogEvent`) | `posthog_events_test.py` |
|
||||
| Sender | Module | Pin test |
|
||||
| ------- | --------------------------------------------------------------------------------------------------------------------------- | ---------------------------------- |
|
||||
| Backend | `autogpt_platform/backend/backend/util/posthog_events.py` (`PostHogEvent`, `PlannedPostHogEvent`) | `posthog_events_test.py` |
|
||||
| Browser | `autogpt_platform/frontend/src/services/analytics/posthog-events.ts` (`PostHogEvent` and its groups, `PlannedPostHogEvent`) | `__tests__/posthog-events.test.ts` |
|
||||
|
||||
## Rules
|
||||
@@ -64,11 +64,11 @@ rename therefore has to search for the old string, not just edit the modules:
|
||||
|
||||
Every event must carry:
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| `environment` | `settings.config.app_env` on the backend (`local`, `dev`, `prod`), the matching app environment in the browser. |
|
||||
| `source` | Which emitter sent it: `chat_copilot` (the events `copilot/tracking.py` captures itself; `chat_message_sent` goes through `product_analytics` and is `platform`), `platform` (every other backend emitter), `web` (the browser), `website` (the marketing site, see [Marketing site](#marketing-site)). |
|
||||
| `revision` | The deployed build's git sha, which Sentry reports as its release: `SENTRY_RELEASE` on the backend, the build's commit in the browser (`SENTRY_RELEASE`, else Vercel's `VERCEL_GIT_COMMIT_SHA`). Left out of a build that has none, such as a local one, and from `$ai_generation` (below). |
|
||||
| Property | Value |
|
||||
| ------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `environment` | `settings.config.app_env` on the backend (`local`, `dev`, `prod`), the matching app environment in the browser. |
|
||||
| `source` | Which emitter sent it: `chat_copilot` (the events `copilot/tracking.py` captures itself; `chat_message_sent` goes through `product_analytics` and is `platform`), `platform` (every other backend emitter), `web` (the browser), `website` (the marketing site, see [Marketing site](#marketing-site)). |
|
||||
| `revision` | The deployed build's git sha, which Sentry reports as its release: `SENTRY_RELEASE` on the backend, the build's commit in the browser (`SENTRY_RELEASE`, else Vercel's `VERCEL_GIT_COMMIT_SHA`). Left out of a build that has none, such as a local one, and from `$ai_generation` (below). |
|
||||
|
||||
Every backend event goes through `capture()` in
|
||||
`backend/util/posthog_client.py`, which applies `environment` and `source`
|
||||
@@ -121,7 +121,7 @@ them out (SECRT-2728):
|
||||
it at signup with the other account properties, and again whenever an
|
||||
internal account signs in (`POST /api/auth/user`), so someone made an admin
|
||||
later is flagged from their next sign-in. `poetry run cli
|
||||
posthog-audience-backfill` sets it on earlier accounts. Someone who stops
|
||||
posthog-audience-backfill` sets it on earlier accounts. Someone who stops
|
||||
being an admin keeps it until that backfill runs again.
|
||||
- **`impersonated`** is an event property, true on every event an admin
|
||||
caused while acting as a user (`X-Act-As-User-Id`). The backend sets it in
|
||||
@@ -185,25 +185,25 @@ Automated work is measured separately: `schedule_fired` and `trigger_fired`.
|
||||
|
||||
## Status legend
|
||||
|
||||
| Status | Meaning |
|
||||
| --- | --- |
|
||||
| `live` | Sent today under this name. Keep it. |
|
||||
| `add` | Not sent yet; the name is in `PlannedPostHogEvent` until the change that starts sending it. |
|
||||
| Status | Meaning |
|
||||
| ------ | ------------------------------------------------------------------------------------------- |
|
||||
| `live` | Sent today under this name. Keep it. |
|
||||
| `add` | Not sent yet; the name is in `PlannedPostHogEvent` until the change that starts sending it. |
|
||||
|
||||
Events that are no longer sent are listed under [Removed](#removed), with
|
||||
what replaced them.
|
||||
|
||||
## Acquisition
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `$pageview` | browser | live | `$current_url` | A route or query string changes (`PostHogPageViewTracker`). |
|
||||
| `tour_started` | browser | live | — | The public `/tour` page is opened (once per tab). |
|
||||
| `tour_scenario_started` | browser | live | `scenario` | A tour scenario starts playing. |
|
||||
| `tour_scenario_completed` | browser | live | `scenario` | A tour scenario reaches its end. |
|
||||
| `tour_cta_clicked` | browser | live | `label` (`free-trial`, `signup`, `pricing`, `another-scenario`, `self-host`, `share`), `placement` where the CTA has one, `surface` (`tour`, `marketplace`) on sidebar-card clicks | A tour or logged-out marketplace call to action is clicked. |
|
||||
| `marketing_opted_out` | browser | live | `surface` (`signup`) | Someone refuses marketing email in the app. `signup`: "opt out" is clicked in the legal line under the signup buttons (not "Undo"); nothing else is sent, since there is no user yet, so it only gives the opt-out rate. The refusal itself is stored on the user (`marketingOptOutAt`). |
|
||||
| `signup_completed` | backend | live | `signup_method` (the auth provider, e.g. `email`, `google`: from the user's first Better Auth account row, where `credential` is reported as `email`, else from a Supabase token's `app_metadata.provider`; omitted when neither has it) | The user row is created (`data/user.py`), whichever request creates it. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ------------------------- | ------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$pageview` | browser | live | `$current_url` | A route or query string changes (`PostHogPageViewTracker`). |
|
||||
| `tour_started` | browser | live | — | The public `/tour` page is opened (once per tab). |
|
||||
| `tour_scenario_started` | browser | live | `scenario` | A tour scenario starts playing. |
|
||||
| `tour_scenario_completed` | browser | live | `scenario` | A tour scenario reaches its end. |
|
||||
| `tour_cta_clicked` | browser | live | `label` (`free-trial`, `signup`, `pricing`, `another-scenario`, `self-host`, `share`), `placement` where the CTA has one, `surface` (`tour`, `marketplace`) on sidebar-card clicks | A tour or logged-out marketplace call to action is clicked. |
|
||||
| `marketing_opted_out` | browser | live | `surface` (`signup`) | Someone refuses marketing email in the app. `signup`: "opt out" is clicked in the legal line under the signup buttons (not "Undo"); nothing else is sent, since there is no user yet, so it only gives the opt-out rate. The refusal itself is stored on the user (`marketingOptOutAt`). |
|
||||
| `signup_completed` | backend | live | `signup_method` (the auth provider, e.g. `email`, `google`: from the user's first Better Auth account row, where `credential` is reported as `email`, else from a Supabase token's `app_metadata.provider`; omitted when neither has it) | The user row is created (`data/user.py`), whichever request creates it. |
|
||||
|
||||
Sidebar CTA clicks retain the tour event names for compatibility. Filter
|
||||
`surface` to distinguish the marketplace from the tour; older sidebar events
|
||||
@@ -241,41 +241,41 @@ website visit to a paid account (SECRT-2769). Its code is
|
||||
Vercel), `source: website`, and `revision` (the commit) where the build has
|
||||
one.
|
||||
|
||||
| Event | Fires when |
|
||||
| --- | --- |
|
||||
| `$pageview` | A route or query string changes, and once more when consent arrives, so the landing page counts. |
|
||||
| `$pageleave`, `$autocapture` | As in the app. |
|
||||
| `section_viewed` | A section marked for DataFast's scroll goals comes on screen: as soon as any of it shows, and again after it left the screen and comes back. `section` is the part after `scroll_to_` (e.g. `faq`). |
|
||||
| DataFast goals | Every DataFast goal is also sent to PostHog under an `object_action`, past-tense name, with the same parameters (`data-fast-goal-*` attributes, hyphens turned into underscores, or the properties passed from code, such as `pricing_billing_toggled.billing`). |
|
||||
| Event | Fires when |
|
||||
| ---------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$pageview` | A route or query string changes, and once more when consent arrives, so the landing page counts. |
|
||||
| `$pageleave`, `$autocapture` | As in the app. |
|
||||
| `section_viewed` | A section marked for DataFast's scroll goals comes on screen: as soon as any of it shows, and again after it left the screen and comes back. `section` is the part after `scroll_to_` (e.g. `faq`). |
|
||||
| DataFast goals | Every DataFast goal is also sent to PostHog under an `object_action`, past-tense name, with the same parameters (`data-fast-goal-*` attributes, hyphens turned into underscores, or the properties passed from code, such as `pricing_billing_toggled.billing`). |
|
||||
|
||||
The goal names (`POSTHOG_GOAL_EVENTS` on the site, where a test fails for a
|
||||
goal without one):
|
||||
|
||||
| DataFast goal | PostHog event |
|
||||
| --- | --- |
|
||||
| `demo_tour_click` | `demo_tour_clicked` |
|
||||
| `demo_video_play` | `demo_video_played` |
|
||||
| `demo_video_sound` | `demo_video_sound_toggled` |
|
||||
| `get_started_click` | `get_started_clicked` |
|
||||
| `github_selfhost_click` | `github_selfhost_clicked` |
|
||||
| `github_stars_click` | `github_stars_clicked` |
|
||||
| `hero_prompt_chip_click` | `hero_prompt_chip_clicked` |
|
||||
| `hero_prompt_focus` | `hero_prompt_focused` |
|
||||
| `hero_prompt_submit` | `hero_prompt_submitted` |
|
||||
| `marketplace_agent_click` | `marketplace_agent_clicked` |
|
||||
| DataFast goal | PostHog event |
|
||||
| -------------------------- | ---------------------------- |
|
||||
| `demo_tour_click` | `demo_tour_clicked` |
|
||||
| `demo_video_play` | `demo_video_played` |
|
||||
| `demo_video_sound` | `demo_video_sound_toggled` |
|
||||
| `get_started_click` | `get_started_clicked` |
|
||||
| `github_selfhost_click` | `github_selfhost_clicked` |
|
||||
| `github_stars_click` | `github_stars_clicked` |
|
||||
| `hero_prompt_chip_click` | `hero_prompt_chip_clicked` |
|
||||
| `hero_prompt_focus` | `hero_prompt_focused` |
|
||||
| `hero_prompt_submit` | `hero_prompt_submitted` |
|
||||
| `marketplace_agent_click` | `marketplace_agent_clicked` |
|
||||
| `marketplace_browse_click` | `marketplace_browse_clicked` |
|
||||
| `nav_product_click` | `nav_product_clicked` |
|
||||
| `press_source_click` | `press_source_clicked` |
|
||||
| `pricing_billing_toggle` | `pricing_billing_toggled` |
|
||||
| `pricing_click` | `pricing_clicked` |
|
||||
| `pricing_plan_cta_click` | `pricing_plan_cta_clicked` |
|
||||
| `product_demo_interact` | `product_demo_interacted` |
|
||||
| `product_video_play` | `product_video_played` |
|
||||
| `see_it_in_action_click` | `see_it_in_action_clicked` |
|
||||
| `signin_click` | `signin_clicked` |
|
||||
| `nav_product_click` | `nav_product_clicked` |
|
||||
| `press_source_click` | `press_source_clicked` |
|
||||
| `pricing_billing_toggle` | `pricing_billing_toggled` |
|
||||
| `pricing_click` | `pricing_clicked` |
|
||||
| `pricing_plan_cta_click` | `pricing_plan_cta_clicked` |
|
||||
| `product_demo_interact` | `product_demo_interacted` |
|
||||
| `product_video_play` | `product_video_played` |
|
||||
| `see_it_in_action_click` | `see_it_in_action_clicked` |
|
||||
| `signin_click` | `signin_clicked` |
|
||||
| `testimonial_source_click` | `testimonial_source_clicked` |
|
||||
| `usecase_cta_click` | `usecase_cta_clicked` |
|
||||
| `watch_demo_click` | `watch_demo_clicked` |
|
||||
| `usecase_cta_click` | `usecase_cta_clicked` |
|
||||
| `watch_demo_click` | `watch_demo_clicked` |
|
||||
|
||||
These names follow the [naming rules](#naming) and are never renamed. They
|
||||
live in the site's repo, not in `posthog-events.ts`.
|
||||
@@ -328,111 +328,113 @@ merges the website pageviews into the new user:
|
||||
|
||||
## Onboarding and activation
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `onboarding_step_viewed` | browser | live | `step` (`team`, `autopilot`, `role`, `pain_points`, `connect`, `hire`, `preparing`) | A wizard step is shown (once per tab). The paywall step reports `paywall_viewed` instead. |
|
||||
| `onboarding_completed` | backend | live | — | `ONBOARDING_COMPLETE` is first recorded for the user (`complete_onboarding_step`). |
|
||||
| `brain_dump_started` | browser | live | — | Recording starts. |
|
||||
| `brain_dump_completed` | browser | live | `input_mode`, `duration_secs` and `finalize_latency_ms` (voice) or `chars` (typed) | A dump was accepted and the wizard advances. `finalize_latency_ms` is the `finalizeBrainDump()` round trip, timed after the upload flush. |
|
||||
| `brain_dump_canceled` | browser | live | — | Recording is cancelled. |
|
||||
| `brain_dump_skipped` | browser | live | — | The step is skipped. |
|
||||
| `brain_dump_recovery_shown` | browser | live | `parts` | A saved partial recording is offered back. |
|
||||
| `brain_dump_recovery_used` | browser | live | — | The saved recording is used. |
|
||||
| `brain_dump_retry` | browser | live | `attempt` | A failed finalize is retried. |
|
||||
| `brain_dump_restarted` | browser | live | — | Recording starts over. |
|
||||
| `brain_dump_download` | browser | live | — | The recording is downloaded after a failure. |
|
||||
| `brain_dump_permission_denied` | browser | live | — | Microphone permission is refused. |
|
||||
| `brain_dump_mic_failed` | browser | live | `error_name` | The microphone fails to open, for any reason, a refused permission included. |
|
||||
| `brain_dump_typed_fallback` | browser | live | `reason` | The user types instead of speaking. |
|
||||
| `transcription_failed` | browser | live | `error_code`, `finalize_latency_ms` | Finalize returns a failure. |
|
||||
| `welcome_dialog_closed` | browser | live | — | The first-landing welcome dialog closes. |
|
||||
| `capability_card_viewed` | browser | live | `card_index`, `deck` | A capability card is shown. |
|
||||
| `capability_cards_completed` | browser | live | `card_index`, `deck` | The deck is finished. |
|
||||
| `capability_cards_skipped` | browser | live | `card_index`, `deck` | The deck is skipped. |
|
||||
| `intro_path` | browser | live | `path` | The intro path (A/B) is chosen. |
|
||||
| `intro_followup_sent` | browser | live | `chars` | First message after the intro card. |
|
||||
| `later_dump_completed` | browser | live | — | A brain dump sent later from the composer. |
|
||||
| `expert_recommended` | browser | live | `template_id`, `position`, `team_source` | A recommended expert card is shown. |
|
||||
| `expert_recommendation_clicked` | browser | live | `template_id`, `position` | A recommended expert card is clicked. |
|
||||
| `hire_all_clicked` | browser | live | `count` (experts this click hires), `already_hired`, `recommended`, `retry` | The hire step's primary button is clicked to hire every expert still unhired, or with `retry` the ones a batch failed to hire. |
|
||||
| `hire_step_continued` | browser | live | `hired`, `recommended`, `via` (`auto` after a full batch, `continue`, `later` for "I'll add members later") | The hire step is left. |
|
||||
| `tab_intro_shown` | browser | live | `tab` | A tab intro card is shown. |
|
||||
| `tab_intro_cta_clicked` | browser | live | `tab`, `cta` | Its primary CTA is used. |
|
||||
| `tab_intro_dismissed` | browser | live | `tab` | It is dismissed any other way. |
|
||||
| `integration_connected` | backend | live | `provider`, `credential_type`, `method` | A credential is stored (OAuth, key, device code). |
|
||||
| `credential_oauth_started` | backend | live | `provider` | The backend issues an OAuth login URL (`GET /api/integrations/{provider}/login`). Not Codex. |
|
||||
| `credential_oauth_exchange_failed` | backend | live | `provider`, `status_code` (unset for an unexpected error), `failure_class` (`invalid_state`, `provider_unavailable`, `token_exchange`, `credential_merge`), `detail` (redacted, at most 200 characters) | `POST /api/integrations/{provider}/callback` returns an error, on any path. Not Codex. |
|
||||
| `credential_card_never_rendered` | browser | live | `provider`, `failure_class` | A provider is missing from the provider map. |
|
||||
| `credential_oauth_popup_blocked` | browser | live | `provider`, `failure_class` | Both the popup and the new tab were blocked. |
|
||||
| `credential_oauth_flow_timed_out` | browser | live | `provider`, `failure_class` | The OAuth flow timed out. |
|
||||
| `credential_scope_shortfall_blocked_selection` | browser | live | `provider`, `failure_class` | The granted scopes are narrower than required. |
|
||||
| `credential_proceed_stuck_after_connect` | browser | live | `provider`, `failure_class` | A connected credential never reached the card. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ---------------------------------------------- | ------- | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `onboarding_step_viewed` | browser | live | `step` (`team`, `autopilot`, `role`, `pain_points`, `connect`, `hire`, `preparing`) | A wizard step is shown (once per tab). The paywall step reports `paywall_viewed` instead. |
|
||||
| `onboarding_completed` | backend | live | — | `ONBOARDING_COMPLETE` is first recorded for the user (`complete_onboarding_step`). |
|
||||
| `brain_dump_started` | browser | live | `prompt_set` (the role's "Otto might ask" set) | Recording starts. |
|
||||
| `brain_dump_completed` | browser | live | `input_mode`, `duration_secs` and `finalize_latency_ms` (voice) or `chars` (typed), `prompt_set`, `prompts_seen` (distinct live prompts shown) | A dump was accepted and the wizard advances. `finalize_latency_ms` is the `finalizeBrainDump()` round trip, timed after the upload flush. |
|
||||
| `brain_dump_canceled` | browser | live | — | Recording is cancelled. |
|
||||
| `brain_dump_skipped` | browser | live | `prompt_set`, `prompts_seen` | The step is skipped. |
|
||||
| `brain_dump_recovery_shown` | browser | live | `parts` | A saved partial recording is offered back. |
|
||||
| `brain_dump_recovery_used` | browser | live | — | The saved recording is used. |
|
||||
| `brain_dump_retry` | browser | live | `attempt` | A failed finalize is retried. |
|
||||
| `brain_dump_restarted` | browser | live | — | Recording starts over. |
|
||||
| `brain_dump_download` | browser | live | — | The recording is downloaded after a failure. |
|
||||
| `brain_dump_permission_denied` | browser | live | — | Microphone permission is refused, on the first ask and again on each refused retry. |
|
||||
| `brain_dump_mic_failed` | browser | live | `error_name` | The microphone fails to open, for any reason, a refused permission included. Sent again on each failed retry, so count people, not events. |
|
||||
| `brain_dump_typed_fallback` | browser | live | `reason` (`chose_to_type`, `permission_denied`), `prompt_set` | The user types instead of speaking. `permission_denied` is sent once per visit, however many retries are refused. |
|
||||
| `brain_dump_prompt_advanced` | browser | live | `via` (`auto`, `click`), `screen` (`rest`, `typing`, `recording`), `prompt_index`, `prompt_set` | The "Otto might ask" question moves on, by its timer (rest on narrow screens and typing only) or "Another question". Not sent for the first question. |
|
||||
| `brain_dump_permission_retried` | browser | live | `result` (`granted`, `denied`, `unavailable`), `via` (`retry_link`, `record_instead`, `permission_change`), `permission_state` (`prompt`, `granted`, `denied`, `unknown`) | After a refused microphone, the user asks for it again, or allows the site in its settings (`permission_change`). Sent before `brain_dump_started`. |
|
||||
| `transcription_failed` | browser | live | `error_code`, `finalize_latency_ms` | Finalize returns a failure. |
|
||||
| `welcome_dialog_closed` | browser | live | — | The first-landing welcome dialog closes. |
|
||||
| `capability_card_viewed` | browser | live | `card_index`, `deck` | A capability card is shown. |
|
||||
| `capability_cards_completed` | browser | live | `card_index`, `deck` | The deck is finished. |
|
||||
| `capability_cards_skipped` | browser | live | `card_index`, `deck` | The deck is skipped. |
|
||||
| `intro_path` | browser | live | `path` | The intro path (A/B) is chosen. |
|
||||
| `intro_followup_sent` | browser | live | `chars` | First message after the intro card. |
|
||||
| `later_dump_completed` | browser | live | — | A brain dump sent later from the composer. |
|
||||
| `expert_recommended` | browser | live | `template_id`, `position`, `team_source` | A recommended expert card is shown. |
|
||||
| `expert_recommendation_clicked` | browser | live | `template_id`, `position` | A recommended expert card is clicked. |
|
||||
| `hire_all_clicked` | browser | live | `count` (experts this click hires), `already_hired`, `recommended`, `retry` | The hire step's primary button is clicked to hire every expert still unhired, or with `retry` the ones a batch failed to hire. |
|
||||
| `hire_step_continued` | browser | live | `hired`, `recommended`, `via` (`auto` after a full batch, `continue`, `later` for "I'll add members later") | The hire step is left. |
|
||||
| `tab_intro_shown` | browser | live | `tab` | A tab intro card is shown. |
|
||||
| `tab_intro_cta_clicked` | browser | live | `tab`, `cta` | Its primary CTA is used. |
|
||||
| `tab_intro_dismissed` | browser | live | `tab` | It is dismissed any other way. |
|
||||
| `integration_connected` | backend | live | `provider`, `credential_type`, `method` | A credential is stored (OAuth, key, device code). |
|
||||
| `credential_oauth_started` | backend | live | `provider` | The backend issues an OAuth login URL (`GET /api/integrations/{provider}/login`). Not Codex. |
|
||||
| `credential_oauth_exchange_failed` | backend | live | `provider`, `status_code` (unset for an unexpected error), `failure_class` (`invalid_state`, `provider_unavailable`, `token_exchange`, `credential_merge`), `detail` (redacted, at most 200 characters) | `POST /api/integrations/{provider}/callback` returns an error, on any path. Not Codex. |
|
||||
| `credential_card_never_rendered` | browser | live | `provider`, `failure_class` | A provider is missing from the provider map. |
|
||||
| `credential_oauth_popup_blocked` | browser | live | `provider`, `failure_class` | Both the popup and the new tab were blocked. |
|
||||
| `credential_oauth_flow_timed_out` | browser | live | `provider`, `failure_class` | The OAuth flow timed out. |
|
||||
| `credential_scope_shortfall_blocked_selection` | browser | live | `provider`, `failure_class` | The granted scopes are narrower than required. |
|
||||
| `credential_proceed_stuck_after_connect` | browser | live | `provider`, `failure_class` | A connected credential never reached the card. |
|
||||
|
||||
## Engagement
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `agent_run_started` | backend | live | `graph_id`, `graph_exec_id`, `via` (`manual`, `api`, `copilot`), `via_ref`, `preset_id`; an expert's workflow run adds `expert_id` and `kind: workflow_run` | A person starts an agent run. |
|
||||
| `chat_message_sent` | backend | live | `chat_session_id`, `origin` (`web`, or the bot platform the chat was opened from: `slack`, `discord`, ...), `kind: chat_turn`, `message_length`; `expert_id` in an expert chat | A person sends a message in an Autopilot or expert chat. |
|
||||
| `agent_run_finished` | backend | live | `status` (`completed`, `failed`), `graph_id`, `graph_exec_id`, `via`, `expert_id`, `cost_cents`, `duration_seconds`, `is_subgraph_run`; `failure_reason` when failed | A run reaches COMPLETED or FAILED (sub-graph and automated runs included). A top-level expert run is `expert_id` set and `is_subgraph_run` false. Deduplicated on `graph_exec_id`, so a requeue or resume that finishes the same run again sends no second event. |
|
||||
| `schedule_created` | backend | live | `schedule_id`, `target` (`agent`, `autopilot`, `expert`), `expert_id`, `cron`, `is_recurring`, `run_at`, `graph_id`, `chat_session_id` | Any schedule is registered, from any surface. |
|
||||
| `chat_tool_called` | backend | live | `chat_session_id`, `tool_name`, `tool_call_id` | The copilot calls a tool. |
|
||||
| `chat_outcome` | backend | live | `outcome_type` (`agent_run_success`, `schedule_created`), `chat_session_id`; `agent_run_success`: `graph_id`, `execution_id`, `library_agent_id`; `schedule_created`: `target` (`agent`: `graph_id`, `schedule_id`, `cron`, `library_agent_id`; `followup`: `schedule_id`, `target_chat_session_id`, `is_recurring`) | A moment of value in a chat: the copilot started a (non-dry) run or created a schedule for the user. `agent_run_started` / `schedule_created` still count the run or schedule itself. |
|
||||
| `chat_library_check_outcome` | backend | live | `chat_session_id`, `outcome`, `matches_count`, `top_score` | The create-agent library check ends. |
|
||||
| `chat_read_screen_shadow_decision` | backend | live | `tool_name`, `tool_call_id`, `source_kind` (`web`, `file`, `shell`, `mcp`, `block`, `workflow`, `chat`, `skill`, …), `chat_session_id`, `trace_id` (the reply's trace, the `$ai_trace_id` of its generations and spans, shared by the subtasks it spawns; the tool call's `$ai_span` with the same `tool_call_id` names the turn), `expert_id`, `verdict` (`allow`, `hold`, `judge_error`), `reason_code`, `judge` (`jev`, `content`; null when the shadow itself failed), `llm_model`, `jev_model`, `rubric_version`, `latency_ms`, `impersonated`, `judgement_id`, `sample_rate` | With `copilot-read-screen` serving `shadow`, the prompt-injection check judges an outside read in a sampled interactive chat (`backend/copilot/gate/shadow.py`). Nothing is held. No content, URL or judge wording is sent. |
|
||||
| `voice_mode_started` | browser | live | `entry` | Voice mode is switched on. |
|
||||
| `voice_mode_stopped` | browser | live | `turns`, `state` | Switched off by the user. |
|
||||
| `voice_mode_timed_out` | browser | live | `turns`, `state` | Closed by the silence timeout. |
|
||||
| `voice_turn_sent` | browser | live | `turn_index`, `transcript_chars`, `transcribe_latency_ms` | A spoken turn is sent. |
|
||||
| `voice_turn_dropped` | browser | live | `reason` (`vad_misfire`, `transcribe_failed`, `filler_or_empty`, `interrupted`); `transcribe_latency_ms` with `filler_or_empty`; `turn_index` and `first_sound_latency_ms` (null when nothing played yet) with `interrupted` | A spoken turn is discarded, or with `interrupted` a sent turn's reply is cut off by switching voice mode off or leaving the page. A sent turn ends in exactly one of `voice_turn_completed` or this. |
|
||||
| `voice_turn_completed` | browser | live | `turn_index`, `first_sound_latency_ms` (null when nothing played) | The mic reopens after the reply. |
|
||||
| `voice_transcribe_retried` | browser | live | `turn_index` | A failed transcription is retried. |
|
||||
| `voice_recording_downloaded` | browser | live | `turn_index` | The audio is downloaded instead. |
|
||||
| `voice_mode_permission_denied` | browser | live | `stage` | Microphone permission is refused. |
|
||||
| `voice_mode_error` | browser | live | `stage` | The VAD, synthesis or send failed. |
|
||||
| `experts_section_viewed` | browser | live | — | The marketplace experts shelf renders with results. |
|
||||
| `expert_profile_opened` | browser | live | `template_id` | An expert profile page opens. |
|
||||
| `hire_started` | browser | live | `template_id`, `surface` (`onboarding`, `expert_page`) | A hire button is clicked, or once per expert of the hire step's batch. |
|
||||
| `hire_flow_abandoned` | browser | live | `template_id`, `stage` | The hire dialog is closed before hiring. |
|
||||
| `expert_hired` | backend | live | `expert_id`, `template_id`, `name`, `failed_preloads_count`, `surface` (`onboarding`, `expert_page`, `copilot`; unset for other API callers) | An expert is hired or an archived one revived, from any surface (`experts_db.hire_expert`). Sent once the hire's background setup settles (`_run_hire_setup`), so `failed_preloads_count` is known; a revival with nothing to set up sends it at once. An idempotent re-hire of an active expert does not fire. |
|
||||
| `hire_failed` | backend | live | `template_id`, `failed_preloads_count` | Hiring raised. |
|
||||
| `expert_thread_created` | browser | live | `expert_id` | A new expert chat is created. |
|
||||
| `writing_style_added` | backend | live | `expert_id` | A writing style is saved on an expert. |
|
||||
| `workflow_installed_on_expert` | backend | live | `expert_id`, `workflow_source` (`library`, `marketplace`), `library_agent_id` or `store_listing_version_id` | A workflow is attached to an expert. |
|
||||
| `home_viewed` | browser | live | — | The home dashboard renders with data. |
|
||||
| `home_attention_actioned` | browser | live | `kind`, `action` | A "needs you" item is approved or declined. |
|
||||
| `home_team_member_clicked` | browser | live | `expert_id` | A team member row is clicked. |
|
||||
| `home_suggestion_clicked` | browser | live | `position` (0-based among the rows shown), `surface` (`intro`, `home`), `remaining` (open rows after this one) | A suggested first task is clicked, on the greeting or under the Home composer. |
|
||||
| `home_suggestion_dismissed` | browser | live | `position` (null for `all`), `surface`, `remaining`, `scope` (`one`, `all`) | A suggested first task is hidden, or "Hide all" is used. An Undo sends nothing. |
|
||||
| `listing_added_to_library` | backend | live | `store_listing_version_id`, `graph_id`, `library_agent_id` | A marketplace agent is added to the library for the first time. Only a user adding a listing counts (the library route and the add-to-library block). Restoring an entry the user already had does not, and neither does a system install: an add inside another write's transaction, an expert's hire preloads, or attaching a marketplace workflow to an expert (that is `workflow_installed_on_expert`). |
|
||||
| `listing_downloaded` | backend | live | `store_listing_version_id`, `graph_id` | A signed-in user downloads a marketplace agent file. Signed-out downloads have no user and are not sent. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ---------------------------------- | ------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `agent_run_started` | backend | live | `graph_id`, `graph_exec_id`, `via` (`manual`, `api`, `copilot`), `via_ref`, `preset_id`; an expert's workflow run adds `expert_id` and `kind: workflow_run` | A person starts an agent run. |
|
||||
| `chat_message_sent` | backend | live | `chat_session_id`, `origin` (`web`, or the bot platform the chat was opened from: `slack`, `discord`, ...), `kind: chat_turn`, `message_length`; `expert_id` in an expert chat | A person sends a message in an Autopilot or expert chat. |
|
||||
| `agent_run_finished` | backend | live | `status` (`completed`, `failed`), `graph_id`, `graph_exec_id`, `via`, `expert_id`, `cost_cents`, `duration_seconds`, `is_subgraph_run`; `failure_reason` when failed | A run reaches COMPLETED or FAILED (sub-graph and automated runs included). A top-level expert run is `expert_id` set and `is_subgraph_run` false. Deduplicated on `graph_exec_id`, so a requeue or resume that finishes the same run again sends no second event. |
|
||||
| `schedule_created` | backend | live | `schedule_id`, `target` (`agent`, `autopilot`, `expert`), `expert_id`, `cron`, `is_recurring`, `run_at`, `graph_id`, `chat_session_id` | Any schedule is registered, from any surface. |
|
||||
| `chat_tool_called` | backend | live | `chat_session_id`, `tool_name`, `tool_call_id` | The copilot calls a tool. |
|
||||
| `chat_outcome` | backend | live | `outcome_type` (`agent_run_success`, `schedule_created`), `chat_session_id`; `agent_run_success`: `graph_id`, `execution_id`, `library_agent_id`; `schedule_created`: `target` (`agent`: `graph_id`, `schedule_id`, `cron`, `library_agent_id`; `followup`: `schedule_id`, `target_chat_session_id`, `is_recurring`) | A moment of value in a chat: the copilot started a (non-dry) run or created a schedule for the user. `agent_run_started` / `schedule_created` still count the run or schedule itself. |
|
||||
| `chat_library_check_outcome` | backend | live | `chat_session_id`, `outcome`, `matches_count`, `top_score` | The create-agent library check ends. |
|
||||
| `chat_read_screen_shadow_decision` | backend | live | `tool_name`, `tool_call_id`, `source_kind` (`web`, `file`, `shell`, `mcp`, `block`, `workflow`, `chat`, `skill`, …), `chat_session_id`, `trace_id` (the reply's trace, the `$ai_trace_id` of its generations and spans, shared by the subtasks it spawns; the tool call's `$ai_span` with the same `tool_call_id` names the turn), `expert_id`, `verdict` (`allow`, `hold`, `judge_error`), `reason_code`, `judge` (`jev`, `content`; null when the shadow itself failed), `llm_model`, `jev_model`, `rubric_version`, `latency_ms`, `impersonated`, `judgement_id`, `sample_rate` | With `copilot-read-screen` serving `shadow`, the prompt-injection check judges an outside read in a sampled interactive chat (`backend/copilot/gate/shadow.py`). Nothing is held. No content, URL or judge wording is sent. |
|
||||
| `voice_mode_started` | browser | live | `entry` | Voice mode is switched on. |
|
||||
| `voice_mode_stopped` | browser | live | `turns`, `state` | Switched off by the user. |
|
||||
| `voice_mode_timed_out` | browser | live | `turns`, `state` | Closed by the silence timeout. |
|
||||
| `voice_turn_sent` | browser | live | `turn_index`, `transcript_chars`, `transcribe_latency_ms` | A spoken turn is sent. |
|
||||
| `voice_turn_dropped` | browser | live | `reason` (`vad_misfire`, `transcribe_failed`, `filler_or_empty`, `interrupted`); `transcribe_latency_ms` with `filler_or_empty`; `turn_index` and `first_sound_latency_ms` (null when nothing played yet) with `interrupted` | A spoken turn is discarded, or with `interrupted` a sent turn's reply is cut off by switching voice mode off or leaving the page. A sent turn ends in exactly one of `voice_turn_completed` or this. |
|
||||
| `voice_turn_completed` | browser | live | `turn_index`, `first_sound_latency_ms` (null when nothing played) | The mic reopens after the reply. |
|
||||
| `voice_transcribe_retried` | browser | live | `turn_index` | A failed transcription is retried. |
|
||||
| `voice_recording_downloaded` | browser | live | `turn_index` | The audio is downloaded instead. |
|
||||
| `voice_mode_permission_denied` | browser | live | `stage` | Microphone permission is refused. |
|
||||
| `voice_mode_error` | browser | live | `stage` | The VAD, synthesis or send failed. |
|
||||
| `experts_section_viewed` | browser | live | — | The marketplace experts shelf renders with results. |
|
||||
| `expert_profile_opened` | browser | live | `template_id` | An expert profile page opens. |
|
||||
| `hire_started` | browser | live | `template_id`, `surface` (`onboarding`, `expert_page`) | A hire button is clicked, or once per expert of the hire step's batch. |
|
||||
| `hire_flow_abandoned` | browser | live | `template_id`, `stage` | The hire dialog is closed before hiring. |
|
||||
| `expert_hired` | backend | live | `expert_id`, `template_id`, `name`, `failed_preloads_count`, `surface` (`onboarding`, `expert_page`, `copilot`; unset for other API callers) | An expert is hired or an archived one revived, from any surface (`experts_db.hire_expert`). Sent once the hire's background setup settles (`_run_hire_setup`), so `failed_preloads_count` is known; a revival with nothing to set up sends it at once. An idempotent re-hire of an active expert does not fire. |
|
||||
| `hire_failed` | backend | live | `template_id`, `failed_preloads_count` | Hiring raised. |
|
||||
| `expert_thread_created` | browser | live | `expert_id` | A new expert chat is created. |
|
||||
| `writing_style_added` | backend | live | `expert_id` | A writing style is saved on an expert. |
|
||||
| `workflow_installed_on_expert` | backend | live | `expert_id`, `workflow_source` (`library`, `marketplace`), `library_agent_id` or `store_listing_version_id` | A workflow is attached to an expert. |
|
||||
| `home_viewed` | browser | live | — | The home dashboard renders with data. |
|
||||
| `home_attention_actioned` | browser | live | `kind`, `action` | A "needs you" item is approved or declined. |
|
||||
| `home_team_member_clicked` | browser | live | `expert_id` | A team member row is clicked. |
|
||||
| `home_suggestion_clicked` | browser | live | `position` (0-based among the rows shown), `surface` (`intro`, `home`), `remaining` (open rows after this one) | A suggested first task is clicked, on the greeting or under the Home composer. |
|
||||
| `home_suggestion_dismissed` | browser | live | `position` (null for `all`), `surface`, `remaining`, `scope` (`one`, `all`) | A suggested first task is hidden, or "Hide all" is used. An Undo sends nothing. |
|
||||
| `listing_added_to_library` | backend | live | `store_listing_version_id`, `graph_id`, `library_agent_id` | A marketplace agent is added to the library for the first time. Only a user adding a listing counts (the library route and the add-to-library block). Restoring an entry the user already had does not, and neither does a system install: an add inside another write's transaction, an expert's hire preloads, or attaching a marketplace workflow to an expert (that is `workflow_installed_on_expert`). |
|
||||
| `listing_downloaded` | backend | live | `store_listing_version_id`, `graph_id` | A signed-in user downloads a marketplace agent file. Signed-out downloads have no user and are not sent. |
|
||||
|
||||
## Monetization
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `paywall_viewed` | browser | live | `surface` (`onboarding`, `paywall_gate`, `billing`) | A paywall or plan picker is shown (once per tab per surface): the onboarding subscription step, the in-app paywall modal, the billing page's plan card. The pricing arm comes from PostHog's own `$feature/...` properties. |
|
||||
| `plan_selected` | browser | live | `subscription_tier` (`PRO`, `MAX`, `BUSINESS` for Team), `billing_cycle`, `surface`; `pricing_variant` on `onboarding`, only when PostHog has assigned an arm (omitted while flags load and for users outside the rollout, who are not in `control`) | A plan is picked: a plan card on either paywall, or Upgrade / Downgrade on the billing page. |
|
||||
| `billing_portal_opened` | browser | live | `surface` (`billing` for the plan card, `billing_payment_method` for the payment method card) | The Stripe billing portal is opened. |
|
||||
| `checkout_started` | backend | live | `checkout_kind` (`subscription`, `top_up`, `trial`), `subscription_tier`, `billing_cycle`, `surface` (sent by the client; unset for the low-credit top-up dialog and the legacy `/profile/credits` page), `$feature/<experiment>` for every recorded experiment arm | A Stripe Checkout session is created and its URL returned (subscription, credit top-up or trial card setup). A paid plan changed in place opens no Checkout and sends nothing. |
|
||||
| `checkout_abandoned` | browser | live | `checkout_kind`, `surface` | The user returns from Stripe Checkout without paying (the cancel URL: `subscription=cancelled`, `topup=cancel`, `trial=cancelled`, or the paywall modal's `paywall_checkout=cancelled` marker). Browser-sent: Stripe only reports the expiry a day later. A trial abandonment is sent once per tab per surface until the next trial checkout starts, so a refresh on the cancel URL does not count twice but a second real abandonment does. |
|
||||
| `trial_offer_viewed` | browser | live | `trial_offer_version`, `subscription_tier`, `trial_duration_days`, `surface` | A trial offer card is shown. |
|
||||
| `trial_cancel_popup_viewed` | browser | live | `days_left` (whole days of trial access left, rounded up; `1` under a day) | The "Cancellation confirmed" popup opens, once, right after a cancel that sets the trial to cancel at its end, so trial access continues until then. A reload never reopens it. |
|
||||
| `trial_resume_clicked` | browser | live | `days_left` | Resume trial is clicked on a trial set to cancel at its end, in the popup or on the trial card. Sent on the click; `trial_resumed` (backend) confirms it took effect. |
|
||||
| `trial_subscribe_now_clicked` | browser | live | `days_left` | Subscribe now is clicked in the cancellation popup. It opens Billing. There, the trial's own plan converts the trial at once on the saved card (`trial_converted`, no `checkout_started`); any other plan, or a card that asks for authentication, goes through Stripe Checkout (`checkout_started`), which ends the trial without a `trial_ended`. |
|
||||
| `trial_started` | backend | live | `trial_id`, `trial_offer_version`, `subscription_tier`, `billing_cycle`, `trial_duration_days` | The trial starts. Like every trial lifecycle event, it is sent only when its notification email is queued. |
|
||||
| `trial_ending` | backend | live | as above | The reminder window opens. |
|
||||
| `trial_canceled` | backend | live | as above | The trial is set to cancel at its end: the cancellation is scheduled, trial access continues until the end date, and the card is not charged. |
|
||||
| `trial_resumed` | backend | live | as above | A trial set to cancel is resumed before its end date, so it converts to paid at its end as first agreed. |
|
||||
| `payment_failed` | backend | live | as above | The conversion charge fails. |
|
||||
| `trial_converted` | backend | live | as above | The trial converts to paid. |
|
||||
| `trial_ended` | backend | live | as above | The trial ends without converting. Not sent when the trial subscription ends because another plan was bought while the trial was set to cancel; that plan's `checkout_started` and `payment_succeeded` cover it. |
|
||||
| `subscription_changed` | backend | live | `change_type: upgrade`, `previous_subscription_tier`, `subscription_tier`, `billing_cycle` | A paid tier upgrade takes effect. Downgrades and cancellations are not sent here yet. |
|
||||
| `payment_succeeded` | backend | live | `subscription_tier`, `billing_cycle`, `amount_cents` (the invoice's `amount_paid`), `currency` | A subscription invoice is paid. Deduplicated on the invoice id: Stripe delivers both `invoice.payment_succeeded` and `invoice_payment.paid` for one invoice, and redelivers either, so revenue is counted once. |
|
||||
| `topup_completed` | backend | live | `amount_credits`, `top_up_type`, `amount_cents` (the Checkout total or the auto top-up charge), `currency` | Credits are bought. |
|
||||
| `subscription_cancellation_scheduled` | backend | live | `subscription_tier` | A paid plan is set to cancel at period end. |
|
||||
| `subscription_ended` | backend | live | `subscription_tier`, `billing_cycle`, `reason` (Stripe `cancellation_details.reason`: `cancellation_requested`, `payment_failed`, `payment_disputed`; `payment_failed` too when our own payment-failure handler cancelled the subscription because the balance could not cover a failed renewal, which Stripe would stamp `cancellation_requested`) | A paid subscription ends (Stripe `customer.subscription.deleted`), once per subscription, alongside the ended email. A trial that ends unconverted is `trial_ended` instead; a trial ended by buying another plan sends neither. |
|
||||
| `subscription_tier_reconciled` | backend | live | `direction`, `previous_subscription_tier`, `subscription_tier`, `via` | Ops signal: Stripe and the stored tier disagreed. Not a user action; keep out of funnels. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ------------------------------------- | ------- | ------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `paywall_viewed` | browser | live | `surface` (`onboarding`, `paywall_gate`, `billing`) | A paywall or plan picker is shown (once per tab per surface): the onboarding subscription step, the in-app paywall modal, the billing page's plan card. The pricing arm comes from PostHog's own `$feature/...` properties. |
|
||||
| `plan_selected` | browser | live | `subscription_tier` (`PRO`, `MAX`, `BUSINESS` for Team), `billing_cycle`, `surface`; `pricing_variant` on `onboarding`, only when PostHog has assigned an arm (omitted while flags load and for users outside the rollout, who are not in `control`) | A plan is picked: a plan card on either paywall, or Upgrade / Downgrade on the billing page. |
|
||||
| `billing_portal_opened` | browser | live | `surface` (`billing` for the plan card, `billing_payment_method` for the payment method card) | The Stripe billing portal is opened. |
|
||||
| `checkout_started` | backend | live | `checkout_kind` (`subscription`, `top_up`, `trial`), `subscription_tier`, `billing_cycle`, `surface` (sent by the client; unset for the low-credit top-up dialog and the legacy `/profile/credits` page), `$feature/<experiment>` for every recorded experiment arm | A Stripe Checkout session is created and its URL returned (subscription, credit top-up or trial card setup). A paid plan changed in place opens no Checkout and sends nothing. |
|
||||
| `checkout_abandoned` | browser | live | `checkout_kind`, `surface` | The user returns from Stripe Checkout without paying (the cancel URL: `subscription=cancelled`, `topup=cancel`, `trial=cancelled`, or the paywall modal's `paywall_checkout=cancelled` marker). Browser-sent: Stripe only reports the expiry a day later. A trial abandonment is sent once per tab per surface until the next trial checkout starts, so a refresh on the cancel URL does not count twice but a second real abandonment does. |
|
||||
| `trial_offer_viewed` | browser | live | `trial_offer_version`, `subscription_tier`, `trial_duration_days`, `surface` | A trial offer card is shown. |
|
||||
| `trial_cancel_popup_viewed` | browser | live | `days_left` (whole days of trial access left, rounded up; `1` under a day) | The "Cancellation confirmed" popup opens, once, right after a cancel that sets the trial to cancel at its end, so trial access continues until then. A reload never reopens it. |
|
||||
| `trial_resume_clicked` | browser | live | `days_left` | Resume trial is clicked on a trial set to cancel at its end, in the popup or on the trial card. Sent on the click; `trial_resumed` (backend) confirms it took effect. |
|
||||
| `trial_subscribe_now_clicked` | browser | live | `days_left` | Subscribe now is clicked in the cancellation popup. It opens Billing. There, the trial's own plan converts the trial at once on the saved card (`trial_converted`, no `checkout_started`); any other plan, or a card that asks for authentication, goes through Stripe Checkout (`checkout_started`), which ends the trial without a `trial_ended`. |
|
||||
| `trial_started` | backend | live | `trial_id`, `trial_offer_version`, `subscription_tier`, `billing_cycle`, `trial_duration_days` | The trial starts. Like every trial lifecycle event, it is sent only when its notification email is queued. |
|
||||
| `trial_ending` | backend | live | as above | The reminder window opens. |
|
||||
| `trial_canceled` | backend | live | as above | The trial is set to cancel at its end: the cancellation is scheduled, trial access continues until the end date, and the card is not charged. |
|
||||
| `trial_resumed` | backend | live | as above | A trial set to cancel is resumed before its end date, so it converts to paid at its end as first agreed. |
|
||||
| `payment_failed` | backend | live | as above | The conversion charge fails. |
|
||||
| `trial_converted` | backend | live | as above | The trial converts to paid. |
|
||||
| `trial_ended` | backend | live | as above | The trial ends without converting. Not sent when the trial subscription ends because another plan was bought while the trial was set to cancel; that plan's `checkout_started` and `payment_succeeded` cover it. |
|
||||
| `subscription_changed` | backend | live | `change_type: upgrade`, `previous_subscription_tier`, `subscription_tier`, `billing_cycle` | A paid tier upgrade takes effect. Downgrades and cancellations are not sent here yet. |
|
||||
| `payment_succeeded` | backend | live | `subscription_tier`, `billing_cycle`, `amount_cents` (the invoice's `amount_paid`), `currency` | A subscription invoice is paid. Deduplicated on the invoice id: Stripe delivers both `invoice.payment_succeeded` and `invoice_payment.paid` for one invoice, and redelivers either, so revenue is counted once. |
|
||||
| `topup_completed` | backend | live | `amount_credits`, `top_up_type`, `amount_cents` (the Checkout total or the auto top-up charge), `currency` | Credits are bought. |
|
||||
| `subscription_cancellation_scheduled` | backend | live | `subscription_tier` | A paid plan is set to cancel at period end. |
|
||||
| `subscription_ended` | backend | live | `subscription_tier`, `billing_cycle`, `reason` (Stripe `cancellation_details.reason`: `cancellation_requested`, `payment_failed`, `payment_disputed`; `payment_failed` too when our own payment-failure handler cancelled the subscription because the balance could not cover a failed renewal, which Stripe would stamp `cancellation_requested`) | A paid subscription ends (Stripe `customer.subscription.deleted`), once per subscription, alongside the ended email. A trial that ends unconverted is `trial_ended` instead; a trial ended by buying another plan sends neither. |
|
||||
| `subscription_tier_reconciled` | backend | live | `direction`, `previous_subscription_tier`, `subscription_tier`, `via` | Ops signal: Stripe and the stored tier disagreed. Not a user action; keep out of funnels. |
|
||||
|
||||
The paywall funnel in PostHog is `paywall_viewed` → `plan_selected` →
|
||||
`checkout_started` → `payment_succeeded`, with `checkout_abandoned`
|
||||
@@ -453,15 +455,15 @@ sent or dropped this way, not while it is held.
|
||||
|
||||
## Retention
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `schedule_fired` | backend | live | `schedule_id`, `target`, `expert_id`, `graph_id`, `graph_exec_id` or `chat_session_id` | A schedule produces work. |
|
||||
| `trigger_fired` | backend | live | `webhook_id`, `graph_id`, `graph_exec_id`, `expert_id`, `preset_id`, `target` | A webhook produces a run. |
|
||||
| `briefing_generated` | backend | live | `run_count`, `decision_count`, `has_content` | A morning briefing is composed (or found empty). |
|
||||
| `briefing_delivered` | backend | live | `briefing_id` | It is posted to the user's thread. |
|
||||
| `briefing_opened` | browser | live | — | The briefing renders on home. Not the plan's `briefing_opened_in_chat`, which is opening it in the chat thread. |
|
||||
| `briefing_outcome_clicked` | browser | live | `status` | An outcome row in the briefing is clicked. |
|
||||
| `expert_fired` | backend | live | `expert_id` | An expert is fired. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| -------------------------- | ------- | ------ | -------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
|
||||
| `schedule_fired` | backend | live | `schedule_id`, `target`, `expert_id`, `graph_id`, `graph_exec_id` or `chat_session_id` | A schedule produces work. |
|
||||
| `trigger_fired` | backend | live | `webhook_id`, `graph_id`, `graph_exec_id`, `expert_id`, `preset_id`, `target` | A webhook produces a run. |
|
||||
| `briefing_generated` | backend | live | `run_count`, `decision_count`, `has_content` | A morning briefing is composed (or found empty). |
|
||||
| `briefing_delivered` | backend | live | `briefing_id` | It is posted to the user's thread. |
|
||||
| `briefing_opened` | browser | live | — | The briefing renders on home. Not the plan's `briefing_opened_in_chat`, which is opening it in the chat thread. |
|
||||
| `briefing_outcome_clicked` | browser | live | `status` | An outcome row in the briefing is clicked. |
|
||||
| `expert_fired` | backend | live | `expert_id` | An expert is fired. |
|
||||
|
||||
Return visits are `$pageview`; account-level retention is computed in
|
||||
`retention_task_weekly` and `user_lifecycle`, not sent as events.
|
||||
@@ -472,12 +474,12 @@ Postmark's webhooks for the mail the platform sends itself
|
||||
(`api/features/postmark/`). MailerLite's mail (the onboarding tour, the
|
||||
changelog) is not covered.
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `email_delivered` | backend | live | `email_type`, `message_stream`, `message_id` | The receiving mail server accepts the email (Postmark's Delivery webhook). |
|
||||
| `email_opened` | backend | live | `email_type`, `message_stream`, `message_id`, `first_open` | The email's tracking pixel loads (Open webhook). Mail apps that preload images, such as Apple Mail, report opens nobody made, so read it as an upper bound. |
|
||||
| `email_clicked` | backend | live | `email_type`, `message_stream`, `message_id`, `link` | A link in the email is followed (Click webhook). |
|
||||
| `email_bounced` | backend | live | `email_type`, `message_stream`, `message_id`, `bounce_type` | The email bounces (Bounce webhook), soft bounces included. A hard bounce also marks the address unverified. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ----------------- | ------- | ------ | ----------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `email_delivered` | backend | live | `email_type`, `message_stream`, `message_id` | The receiving mail server accepts the email (Postmark's Delivery webhook). |
|
||||
| `email_opened` | backend | live | `email_type`, `message_stream`, `message_id`, `first_open` | The email's tracking pixel loads (Open webhook). Mail apps that preload images, such as Apple Mail, report opens nobody made, so read it as an upper bound. |
|
||||
| `email_clicked` | backend | live | `email_type`, `message_stream`, `message_id`, `link` | A link in the email is followed (Click webhook). |
|
||||
| `email_bounced` | backend | live | `email_type`, `message_stream`, `message_id`, `bounce_type` | The email bounces (Bounce webhook), soft bounces included. A hard bounce also marks the address unverified. |
|
||||
|
||||
- **`email_type`** is the Postmark tag the email was sent with: the
|
||||
notification type in lower case (`briefing`, `alert`, `verdict`, `ops`,
|
||||
@@ -521,12 +523,12 @@ Each AutoPilot reply is one trace, whose id is the turn id of the reply
|
||||
same trace. No event carries a tool's input or output, or an error message:
|
||||
they can hold Gmail or Drive content (Google Limited Use).
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `$ai_generation` | OpenRouter Broadcast | live | set by OpenRouter from the request (`backend/copilot/llm_attribution.py`): `distinct_id` = the request's `user` (the platform user id), `$ai_session_id` = its `session_id` (the chat session id), `$ai_span_name` = `trace.generation_name` (`copilot-title`, `copilot-sdk`, `copilot-baseline`), `$ai_trace_id` = `trace.trace_id` (the reply's trace: the id of the executor turn that made the call, or for a subtask the trace of the turn that spawned it, so all calls behind one reply share it; absent for a call made outside a turn), `metadata_environment` (`local`, `dev`, `prod`), `metadata_impersonated`; `metadata_revision` (the deployed build's git sha, `config.sentry_release`) when it is set; model, tokens, cost and latency from OpenRouter | An AutoPilot LLM call goes through OpenRouter: a session title, an SDK turn (main agent, sub-agents and the CLI's compaction, via `CLAUDE_CODE_EXTRA_BODY`) or a baseline turn. |
|
||||
| `$ai_metric` | backend | live | `$ai_metric_name` (`user-feedback`, `copy`), `$ai_metric_value` (`1` thumbs up or copy, `0` thumbs down), `$ai_session_id` and `chat_session_id` (the chat session id), `message_id` (the reply's row id), `message_sequence`, `feedback_id` (the stored rating, shared with its Langfuse score), `impersonated`; `$ai_trace_id` when the reply row records its trace (`ChatMessage.traceId`, replies written since SECRT-2979); `$ai_feedback_text` when a thumbs-down has a comment | A reply is rated (`POST /api/chat/sessions/{id}/feedback`, `copilot/feedback.py`). Rating a reply again sends another event; the latest per `feedback_id` stands. |
|
||||
| `$ai_trace` | backend | live | `$ai_trace_id` (the turn id), `$ai_span_name` = `copilot-turn`, `$ai_session_id` and `chat_session_id`, `$ai_latency` (seconds), `$ai_is_error`, `$ai_error` (the error code, when failed), `turn_id`, `outcome` (`completed`, `cancelled`, `failed`), `engine` (`sdk`, `baseline`, ...), `turn_origin` (`chat`, `scheduled`, `system`; `spawned` on a subtask's span), `tool_call_count`, `impersonated` | A reply's turn ends, if it reached its engine (`copilot/turn_tracing.py`, `copilot/ai_spans.py`). A redelivered turn sends it again under the same event uuid (dedup key = the turn id), so PostHog keeps one; likewise a subtask's span. |
|
||||
| `$ai_span` | backend | live | `$ai_trace_id`, `$ai_span_id`, `$ai_parent_id`, `$ai_span_name`, `$ai_session_id` and `chat_session_id`, `$ai_latency` (seconds), `$ai_is_error`, `impersonated`. A **tool call**: `$ai_span_name` = the tool's name, `tool_call_id`, `cancelled` (platform tools) or `builtin` (the SDK CLI's own tools, sub-agents' calls included; the high-volume lookups `TodoWrite`, `Read`, `Glob`, `Grep`, `LS` and `NotebookRead` get no span, and no built-in gets one with `CHAT_POSTHOG_BUILTIN_TOOL_SPANS=false`), `$ai_is_error` true when it raised, a gate refused it or it answered with an error, but not when it stopped for an approval card or a sign-in, `$ai_error` = the exception's type name when a platform tool raised (never its message); its parent is the turn (the trace root, or the subtask's span) or the tool that called it. A built-in tool's span is parented to the turn, not to the `Task` span of the sub-agent that called it. A **subtask**: `$ai_span_name` = `copilot-subtask`, `$ai_span_id` = its turn id, parent = the spawning tool's span, which its envelope keeps, so a child that waited in the turn queue or a subtask woken by an approval stays in the trace and under the span its spawner gave it (the trace root for one queued or held before this was recorded), `depth`, plus the `$ai_trace` turn properties, with `turn_origin` = `spawned` (or `scheduled` when the subtask's own turn runs unattended) | A platform tool call ends (`BaseTool.execute`, both engines), an SDK built-in tool call ends (Pre/PostToolUse hooks), or a spawned turn ends. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ---------------- | -------------------- | ------ | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$ai_generation` | OpenRouter Broadcast | live | set by OpenRouter from the request (`backend/copilot/llm_attribution.py`): `distinct_id` = the request's `user` (the platform user id), `$ai_session_id` = its `session_id` (the chat session id), `$ai_span_name` = `trace.generation_name` (`copilot-title`, `copilot-sdk`, `copilot-baseline`), `$ai_trace_id` = `trace.trace_id` (the reply's trace: the id of the executor turn that made the call, or for a subtask the trace of the turn that spawned it, so all calls behind one reply share it; absent for a call made outside a turn), `metadata_environment` (`local`, `dev`, `prod`), `metadata_impersonated`; `metadata_revision` (the deployed build's git sha, `config.sentry_release`) when it is set; model, tokens, cost and latency from OpenRouter | An AutoPilot LLM call goes through OpenRouter: a session title, an SDK turn (main agent, sub-agents and the CLI's compaction, via `CLAUDE_CODE_EXTRA_BODY`) or a baseline turn. |
|
||||
| `$ai_metric` | backend | live | `$ai_metric_name` (`user-feedback`, `copy`), `$ai_metric_value` (`1` thumbs up or copy, `0` thumbs down), `$ai_session_id` and `chat_session_id` (the chat session id), `message_id` (the reply's row id), `message_sequence`, `feedback_id` (the stored rating, shared with its Langfuse score), `impersonated`; `$ai_trace_id` when the reply row records its trace (`ChatMessage.traceId`, replies written since SECRT-2979); `$ai_feedback_text` when a thumbs-down has a comment | A reply is rated (`POST /api/chat/sessions/{id}/feedback`, `copilot/feedback.py`). Rating a reply again sends another event; the latest per `feedback_id` stands. |
|
||||
| `$ai_trace` | backend | live | `$ai_trace_id` (the turn id), `$ai_span_name` = `copilot-turn`, `$ai_session_id` and `chat_session_id`, `$ai_latency` (seconds), `$ai_is_error`, `$ai_error` (the error code, when failed), `turn_id`, `outcome` (`completed`, `cancelled`, `failed`), `engine` (`sdk`, `baseline`, ...), `turn_origin` (`chat`, `scheduled`, `system`; `spawned` on a subtask's span), `tool_call_count`, `impersonated` | A reply's turn ends, if it reached its engine (`copilot/turn_tracing.py`, `copilot/ai_spans.py`). A redelivered turn sends it again under the same event uuid (dedup key = the turn id), so PostHog keeps one; likewise a subtask's span. |
|
||||
| `$ai_span` | backend | live | `$ai_trace_id`, `$ai_span_id`, `$ai_parent_id`, `$ai_span_name`, `$ai_session_id` and `chat_session_id`, `$ai_latency` (seconds), `$ai_is_error`, `impersonated`. A **tool call**: `$ai_span_name` = the tool's name, `tool_call_id`, `cancelled` (platform tools) or `builtin` (the SDK CLI's own tools, sub-agents' calls included; the high-volume lookups `TodoWrite`, `Read`, `Glob`, `Grep`, `LS` and `NotebookRead` get no span, and no built-in gets one with `CHAT_POSTHOG_BUILTIN_TOOL_SPANS=false`), `$ai_is_error` true when it raised, a gate refused it or it answered with an error, but not when it stopped for an approval card or a sign-in, `$ai_error` = the exception's type name when a platform tool raised (never its message); its parent is the turn (the trace root, or the subtask's span) or the tool that called it. A built-in tool's span is parented to the turn, not to the `Task` span of the sub-agent that called it. A **subtask**: `$ai_span_name` = `copilot-subtask`, `$ai_span_id` = its turn id, parent = the spawning tool's span, which its envelope keeps, so a child that waited in the turn queue or a subtask woken by an approval stays in the trace and under the span its spawner gave it (the trace root for one queued or held before this was recorded), `depth`, plus the `$ai_trace` turn properties, with `turn_origin` = `spawned` (or `scheduled` when the subtask's own turn runs unattended) | A platform tool call ends (`BaseTool.execute`, both engines), an SDK built-in tool call ends (Pre/PostToolUse hooks), or a spawned turn ends. |
|
||||
|
||||
What joins them:
|
||||
|
||||
@@ -564,10 +566,10 @@ user or chat when their client goes through OpenRouter.
|
||||
|
||||
## Experiments
|
||||
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| `$feature_flag_called` | browser (posthog-js) | live | set by PostHog | A PostHog flag is read (`useExperiment`). |
|
||||
| `feature_flag_mismatched` | browser | live | `flag`, `launchdarkly` (`value`, `resolved`), `posthog` (`value`, `resolved`) | Ops signal: with both flag vendors configured, LaunchDarkly and PostHog resolved a flag to different values (`useDualFlag`). Not a user action; keep out of funnels. |
|
||||
| Event | Sender | Status | Required properties | Fires when |
|
||||
| ------------------------- | -------------------- | ------ | ----------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `$feature_flag_called` | browser (posthog-js) | live | set by PostHog | A PostHog flag is read (`useExperiment`). |
|
||||
| `feature_flag_mismatched` | browser | live | `flag`, `launchdarkly` (`value`, `resolved`), `posthog` (`value`, `resolved`) | Ops signal: with both flag vendors configured, LaunchDarkly and PostHog resolved a flag to different values (`useDualFlag`). Not a user action; keep out of funnels. |
|
||||
|
||||
Arms are also stored in the database (`analytics.experiment_assignment`),
|
||||
so an experiment can be read in PostHog and Looker alike.
|
||||
@@ -582,14 +584,14 @@ events above mark the moment something happens; these properties hold the
|
||||
person's current state, so a cohort or breakdown can filter on them without
|
||||
replaying events.
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| `subscription_status` | One of the values below. Worked out from the current trial and Stripe state, never from the last event received. |
|
||||
| `signup_at` | When the user row was created. |
|
||||
| `trial_started_at` | When the trial started. |
|
||||
| `subscription_started_at` | When the person first became a paying subscriber: the earliest start of any paid subscription, kept across a resubscribe after a gap. For a converted trial, the conversion (its first successful charge), not the trial start. |
|
||||
| `subscription_canceled_at` | When the current or last paid subscription was canceled, while `subscription_canceled` or `subscription_ended`. |
|
||||
| `subscription_ended_at` | When it ended, while `subscription_ended`. |
|
||||
| Property | Value |
|
||||
| -------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `subscription_status` | One of the values below. Worked out from the current trial and Stripe state, never from the last event received. |
|
||||
| `signup_at` | When the user row was created. |
|
||||
| `trial_started_at` | When the trial started. |
|
||||
| `subscription_started_at` | When the person first became a paying subscriber: the earliest start of any paid subscription, kept across a resubscribe after a gap. For a converted trial, the conversion (its first successful charge), not the trial start. |
|
||||
| `subscription_canceled_at` | When the current or last paid subscription was canceled, while `subscription_canceled` or `subscription_ended`. |
|
||||
| `subscription_ended_at` | When it ended, while `subscription_ended`. |
|
||||
|
||||
Dates are ISO-8601 UTC strings of the lifecycle moment, not of the sync. A
|
||||
date that doesn't apply to the current status is `$unset`, never sent as
|
||||
@@ -598,16 +600,16 @@ null.
|
||||
`subscription_status` counts only **paying** subscribers as `subscribed`
|
||||
(SECRT-2795). First match wins:
|
||||
|
||||
| Value | Meaning |
|
||||
| --- | --- |
|
||||
| `subscription_canceled` | A live paid subscription set to cancel; active until the period ends. |
|
||||
| `subscribed` | A live (`active`) paid subscription. |
|
||||
| `trial_canceled` | A running trial set to cancel (keeps access until its end, back to `in_trial` on resume), or a trial that ended unpaid because the user canceled it. |
|
||||
| `in_trial` | A running trial, ours or a Stripe-native one; includes the hour between the trial's end and its first charge. |
|
||||
| `payment_failed` | The latest paid subscription is `past_due` or `unpaid`, or a trial's first charge failed and Stripe is still retrying it. Access is lost meanwhile. |
|
||||
| `subscription_ended` | Has paid before, and no paid subscription is live. |
|
||||
| `trial_expired` | A trial that ended without a payment and without the user canceling it: it ran out, or its first charge never cleared and Stripe gave up. |
|
||||
| `signed` | Anything else, including a signup that never started a trial, and a former payer now on a free (fully discounted) subscription, who keeps `subscription_started_at`. |
|
||||
| Value | Meaning |
|
||||
| ----------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `subscription_canceled` | A live paid subscription set to cancel; active until the period ends. |
|
||||
| `subscribed` | A live (`active`) paid subscription. |
|
||||
| `trial_canceled` | A running trial set to cancel (keeps access until its end, back to `in_trial` on resume), or a trial that ended unpaid because the user canceled it. |
|
||||
| `in_trial` | A running trial, ours or a Stripe-native one; includes the hour between the trial's end and its first charge. |
|
||||
| `payment_failed` | The latest paid subscription is `past_due` or `unpaid`, or a trial's first charge failed and Stripe is still retrying it. Access is lost meanwhile. |
|
||||
| `subscription_ended` | Has paid before, and no paid subscription is live. |
|
||||
| `trial_expired` | A trial that ended without a payment and without the user canceling it: it ran out, or its first charge never cleared and Stripe gave up. |
|
||||
| `signed` | Anything else, including a signup that never started a trial, and a former payer now on a free (fully discounted) subscription, who keeps `subscription_started_at`. |
|
||||
|
||||
- **Paid** means the subscription charges something. A subscription whose
|
||||
discounts take the whole price (a 100% coupon, or an amount off at least the
|
||||
@@ -649,10 +651,10 @@ the profile is submitted at the Preparing step (`POST /api/onboarding/profile`,
|
||||
`product_analytics.set_onboarding_role`), so funnels and retention can be split
|
||||
by role. Someone who left the wizard before Preparing has neither property.
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| `onboarding_role` | The pick, as labelled in the wizard: `Founder / CEO`, `Operations`, `Sales / BD`, `Marketing`, `Product / PM`, `Engineering`, `HR / People` or `Other`. |
|
||||
| `onboarding_role_other` | What was typed after picking `Other` (trimmed, at most 100 characters); null for any other pick. |
|
||||
| Property | Value |
|
||||
| ----------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `onboarding_role` | The pick, as labelled in the wizard: `Founder / CEO`, `Operations`, `Sales / BD`, `Marketing`, `Product / PM`, `Engineering`, `HR / People` or `Other`. |
|
||||
| `onboarding_role_other` | What was typed after picking `Other` (trimmed, at most 100 characters); null for any other pick. |
|
||||
|
||||
The pick is kept on `UserOnboarding`, not read back from the AutoPilot
|
||||
business understanding, whose copy AutoPilot rewrites. `poetry run cli
|
||||
@@ -671,16 +673,16 @@ checkout routes and the completed-checkout webhook. Unlike
|
||||
MailerLite, PostHog also gets accounts that opted out of marketing or that a
|
||||
signal places in Iran or Russia: it is analytics, not marketing.
|
||||
|
||||
| Property | Value |
|
||||
| --- | --- |
|
||||
| `email_type` | `business`, `personal` (a webmail provider) or `education`. |
|
||||
| `is_internal` | `true` for our own team (an admin or an `@agpt.co` email), else `false`; see [Our own team and impersonation](#our-own-team-and-impersonation). Every account, not only openers. |
|
||||
| `signin_method` | `google` or `email`; `google` wins for an account that has both. |
|
||||
| `checkout_opened_at` | When the account first opened Stripe checkout. |
|
||||
| `country` | The country's English name, from the strongest source seen (see `country_source`). |
|
||||
| `country_code` | The same country as an ISO 3166-1 alpha-2 code. |
|
||||
| `country_source` | `stripe` (the billing address), `ip` (the visitor's IP country at checkout) or `timezone` (the browser's). |
|
||||
| `exclude_de_at` | `yes` when any signal points at Germany or Austria, else `no`. |
|
||||
| Property | Value |
|
||||
| -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `email_type` | `business`, `personal` (a webmail provider) or `education`. |
|
||||
| `is_internal` | `true` for our own team (an admin or an `@agpt.co` email), else `false`; see [Our own team and impersonation](#our-own-team-and-impersonation). Every account, not only openers. |
|
||||
| `signin_method` | `google` or `email`; `google` wins for an account that has both. |
|
||||
| `checkout_opened_at` | When the account first opened Stripe checkout. |
|
||||
| `country` | The country's English name, from the strongest source seen (see `country_source`). |
|
||||
| `country_code` | The same country as an ISO 3166-1 alpha-2 code. |
|
||||
| `country_source` | `stripe` (the billing address), `ip` (the visitor's IP country at checkout) or `timezone` (the browser's). |
|
||||
| `exclude_de_at` | `yes` when any signal points at Germany or Austria, else `no`. |
|
||||
|
||||
MailerLite reads the subscriber before each write so it never makes a value
|
||||
worse; PostHog can't be read on the way, so `$set_once`, which only fills in
|
||||
@@ -701,20 +703,20 @@ No longer sent (SECRT-2722, SECRT-2723). The names stay reserved: the pin tests
|
||||
one comes back, because reusing it would splice a different action onto the
|
||||
history PostHog already holds.
|
||||
|
||||
| Event | Was sent by | Read instead |
|
||||
| --- | --- | --- |
|
||||
| `subscription_trial_checkout_started` | browser | `checkout_started` with `checkout_kind: trial`, sent by the backend once the trial's Checkout session exists; `surface` is the same `onboarding` / `billing`. `trial_offer_version` is dropped; `trial_started` carries it. |
|
||||
| `hire_completed` | backend | `expert_hired` (same hire; it now also skips idempotent re-hires). |
|
||||
| `hire_flow_completed` | browser | `expert_hired` with `surface: expert_page`. `elapsed_ms` is PostHog's time to convert from `hire_started`; `voice_picked` is dropped. |
|
||||
| `onboarding_expert_hired` | browser | `expert_hired` with `surface: onboarding`. The card `position` is on `expert_recommendation_clicked`. |
|
||||
| `copilot_message_sent` | backend | `chat_message_sent`, which carries `message_length`. |
|
||||
| `expert_run_completed` | backend | `agent_run_finished` with `expert_id` set and `is_subgraph_run: false`. |
|
||||
| `finalize_latency_ms` | browser | The `finalize_latency_ms` property on `brain_dump_completed` and `transcription_failed`. |
|
||||
| `voice_transcribe_latency_ms` | browser | The `transcribe_latency_ms` property on `voice_turn_sent` (and `voice_turn_dropped` with `reason: filler_or_empty`). |
|
||||
| `voice_first_sound_latency_ms` | browser | The `first_sound_latency_ms` property on `voice_turn_completed`, or on `voice_turn_dropped` with `reason: interrupted` for a reply that was cut off. |
|
||||
| `experiment_exposed` | browser | Nothing: `useLaunchDarklyExperiment` had no caller and was deleted. PostHog-bucketed experiments use `$feature_flag_called`. |
|
||||
| `copilot_trigger_setup` | backend | Nothing: never sent (no caller). |
|
||||
| `intro_card_dismissed`, `raise_door_clicked`, `intro_start_with_autopilot` | browser | Nothing: declared, never sent. |
|
||||
| Event | Was sent by | Read instead |
|
||||
| -------------------------------------------------------------------------- | ----------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `subscription_trial_checkout_started` | browser | `checkout_started` with `checkout_kind: trial`, sent by the backend once the trial's Checkout session exists; `surface` is the same `onboarding` / `billing`. `trial_offer_version` is dropped; `trial_started` carries it. |
|
||||
| `hire_completed` | backend | `expert_hired` (same hire; it now also skips idempotent re-hires). |
|
||||
| `hire_flow_completed` | browser | `expert_hired` with `surface: expert_page`. `elapsed_ms` is PostHog's time to convert from `hire_started`; `voice_picked` is dropped. |
|
||||
| `onboarding_expert_hired` | browser | `expert_hired` with `surface: onboarding`. The card `position` is on `expert_recommendation_clicked`. |
|
||||
| `copilot_message_sent` | backend | `chat_message_sent`, which carries `message_length`. |
|
||||
| `expert_run_completed` | backend | `agent_run_finished` with `expert_id` set and `is_subgraph_run: false`. |
|
||||
| `finalize_latency_ms` | browser | The `finalize_latency_ms` property on `brain_dump_completed` and `transcription_failed`. |
|
||||
| `voice_transcribe_latency_ms` | browser | The `transcribe_latency_ms` property on `voice_turn_sent` (and `voice_turn_dropped` with `reason: filler_or_empty`). |
|
||||
| `voice_first_sound_latency_ms` | browser | The `first_sound_latency_ms` property on `voice_turn_completed`, or on `voice_turn_dropped` with `reason: interrupted` for a reply that was cut off. |
|
||||
| `experiment_exposed` | browser | Nothing: `useLaunchDarklyExperiment` had no caller and was deleted. PostHog-bucketed experiments use `$feature_flag_called`. |
|
||||
| `copilot_trigger_setup` | backend | Nothing: never sent (no caller). |
|
||||
| `intro_card_dismissed`, `raise_door_clicked`, `intro_start_with_autopilot` | browser | Nothing: declared, never sent. |
|
||||
|
||||
## Renamed
|
||||
|
||||
@@ -722,46 +724,46 @@ Renamed once to the analytics plan's names (SECRT-2722). The old names are
|
||||
reserved like removed ones; an insight on the old name must be pointed at the
|
||||
new one.
|
||||
|
||||
| Old name | New name | Filter to keep the old meaning |
|
||||
| --- | --- | --- |
|
||||
| `run_agent` | `agent_run_started` | `expert_id` is not set |
|
||||
| `run_expert` (`kind: workflow_run`) | `agent_run_started` | `expert_id` is set |
|
||||
| `run_autopilot` | `chat_message_sent` | `expert_id` is not set |
|
||||
| `run_expert` (`kind: chat_turn`) | `chat_message_sent` | `expert_id` is set |
|
||||
| `agent_run_completed` | `agent_run_finished` | `status = completed` |
|
||||
| `agent_run_failed` | `agent_run_finished` | `status = failed` |
|
||||
| `copilot_tool_called` | `chat_tool_called` | — |
|
||||
| `copilot_library_check_outcome` | `chat_library_check_outcome` | — |
|
||||
| `copilot_agent_run_success` | `chat_outcome` | `outcome_type = agent_run_success`. Dry runs no longer count; `graph_name` is dropped. |
|
||||
| `copilot_agent_scheduled` | `chat_outcome` | `outcome_type = schedule_created` and `target = agent`. `graph_name` and `schedule_name` are dropped. |
|
||||
| `copilot_followup_scheduled` | `chat_outcome` | `outcome_type = schedule_created` and `target = followup`. `chat_session_id` is now the chat that scheduled it; the destination is `target_chat_session_id`. |
|
||||
| `subscription_trial_offer_viewed` | `trial_offer_viewed` | — |
|
||||
| `subscription_trial_started` | `trial_started` | — |
|
||||
| `subscription_trial_ending` | `trial_ending` | — |
|
||||
| `subscription_trial_canceled` | `trial_canceled` | — |
|
||||
| `subscription_trial_resumed` | `trial_resumed` | — |
|
||||
| `subscription_trial_converted` | `trial_converted` | — |
|
||||
| `subscription_trial_ended` | `trial_ended` | — |
|
||||
| `subscription_trial_payment_failed` | `payment_failed` | — (only trial conversion charges send it today) |
|
||||
| `subscription_upgraded` | `subscription_changed` | `change_type = upgrade` |
|
||||
| `subscription_payment_success` | `payment_succeeded` | — |
|
||||
| `credit_topup_success` | `topup_completed` | — |
|
||||
| `subscription_tier_reconciliation_discrepancy` | `subscription_tier_reconciled` | — |
|
||||
| `feature_flag_mismatch` | `feature_flag_mismatched` | — (not in the analytics plan; renamed to the `object_action` past-tense form before it reached production) |
|
||||
| Old name | New name | Filter to keep the old meaning |
|
||||
| ---------------------------------------------- | ------------------------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------ |
|
||||
| `run_agent` | `agent_run_started` | `expert_id` is not set |
|
||||
| `run_expert` (`kind: workflow_run`) | `agent_run_started` | `expert_id` is set |
|
||||
| `run_autopilot` | `chat_message_sent` | `expert_id` is not set |
|
||||
| `run_expert` (`kind: chat_turn`) | `chat_message_sent` | `expert_id` is set |
|
||||
| `agent_run_completed` | `agent_run_finished` | `status = completed` |
|
||||
| `agent_run_failed` | `agent_run_finished` | `status = failed` |
|
||||
| `copilot_tool_called` | `chat_tool_called` | — |
|
||||
| `copilot_library_check_outcome` | `chat_library_check_outcome` | — |
|
||||
| `copilot_agent_run_success` | `chat_outcome` | `outcome_type = agent_run_success`. Dry runs no longer count; `graph_name` is dropped. |
|
||||
| `copilot_agent_scheduled` | `chat_outcome` | `outcome_type = schedule_created` and `target = agent`. `graph_name` and `schedule_name` are dropped. |
|
||||
| `copilot_followup_scheduled` | `chat_outcome` | `outcome_type = schedule_created` and `target = followup`. `chat_session_id` is now the chat that scheduled it; the destination is `target_chat_session_id`. |
|
||||
| `subscription_trial_offer_viewed` | `trial_offer_viewed` | — |
|
||||
| `subscription_trial_started` | `trial_started` | — |
|
||||
| `subscription_trial_ending` | `trial_ending` | — |
|
||||
| `subscription_trial_canceled` | `trial_canceled` | — |
|
||||
| `subscription_trial_resumed` | `trial_resumed` | — |
|
||||
| `subscription_trial_converted` | `trial_converted` | — |
|
||||
| `subscription_trial_ended` | `trial_ended` | — |
|
||||
| `subscription_trial_payment_failed` | `payment_failed` | — (only trial conversion charges send it today) |
|
||||
| `subscription_upgraded` | `subscription_changed` | `change_type = upgrade` |
|
||||
| `subscription_payment_success` | `payment_succeeded` | — |
|
||||
| `credit_topup_success` | `topup_completed` | — |
|
||||
| `subscription_tier_reconciliation_discrepancy` | `subscription_tier_reconciled` | — |
|
||||
| `feature_flag_mismatch` | `feature_flag_mismatched` | — (not in the analytics plan; renamed to the `object_action` past-tense form before it reached production) |
|
||||
|
||||
### Renamed properties
|
||||
|
||||
Renamed in the same release as the events, to the analytics plan's envelope,
|
||||
so filters and breakdowns break once. Values are unchanged unless noted.
|
||||
|
||||
| Old property | New property | On |
|
||||
| --- | --- | --- |
|
||||
| `session_id` | `chat_session_id` | `chat_message_sent`, `chat_tool_called`, `chat_outcome`, `chat_library_check_outcome`, `schedule_created`, `schedule_fired` |
|
||||
| `target_session_id` | `target_chat_session_id` | `chat_outcome` (`target = followup`) |
|
||||
| `trigger` | `via` | `agent_run_started`, `agent_run_finished` |
|
||||
| `trigger_ref` | `via_ref` | `agent_run_started` |
|
||||
| `surface` (`chat`, or the bot platform) | `origin` (`web`, or the bot platform) | `chat_message_sent`. The plan's `origin` is the channel. |
|
||||
| `origin` (`interactive`) | removed | `chat_message_sent`. Automation turns are not sent, so it was always `interactive`. |
|
||||
| Old property | New property | On |
|
||||
| --------------------------------------- | ------------------------------------- | --------------------------------------------------------------------------------------------------------------------------- |
|
||||
| `session_id` | `chat_session_id` | `chat_message_sent`, `chat_tool_called`, `chat_outcome`, `chat_library_check_outcome`, `schedule_created`, `schedule_fired` |
|
||||
| `target_session_id` | `target_chat_session_id` | `chat_outcome` (`target = followup`) |
|
||||
| `trigger` | `via` | `agent_run_started`, `agent_run_finished` |
|
||||
| `trigger_ref` | `via_ref` | `agent_run_started` |
|
||||
| `surface` (`chat`, or the bot platform) | `origin` (`web`, or the bot platform) | `chat_message_sent`. The plan's `origin` is the channel. |
|
||||
| `origin` (`interactive`) | removed | `chat_message_sent`. Automation turns are not sent, so it was always `interactive`. |
|
||||
|
||||
## Differences from the analytics plan
|
||||
|
||||
|
||||
Reference in new issue
Block a user