add posthog events on various user actions (#208)

* add page view event support

* add posthog events

* nit: remove unused import

* feedback
This commit is contained in:
Michael Sukkarieh 2025-02-24 17:06:29 -08:00 committed by GitHub
parent ce52f651be
commit de44c81cfa
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
41 changed files with 886 additions and 244 deletions

View file

@ -7,6 +7,7 @@ import os from 'os';
import { Redis } from 'ioredis';
import { RepoData, compileGithubConfig, compileGitlabConfig, compileGiteaConfig, compileGerritConfig } from "./repoCompileUtils.js";
import { BackendError, BackendException } from "@sourcebot/error";
import { captureEvent } from "./posthog.js";
interface IConnectionManager {
scheduleConnectionSync: (connection: Connection) => Promise<void>;
@ -22,6 +23,10 @@ type JobPayload = {
config: ConnectionConfig,
};
type JobResult = {
repoCount: number
}
export class ConnectionManager implements IConnectionManager {
private worker: Worker;
private queue: Queue<JobPayload>;
@ -217,10 +222,14 @@ export class ConnectionManager implements IConnectionManager {
const totalUpsertDuration = performance.now() - totalUpsertStart;
this.logger.info(`Upserted ${repoData.length} repos in ${totalUpsertDuration}ms`);
});
return {
repoCount: repoData.length,
};
}
private async onSyncJobCompleted(job: Job<JobPayload>) {
private async onSyncJobCompleted(job: Job<JobPayload>, result: JobResult) {
this.logger.info(`Connection sync job ${job.id} completed`);
const { connectionId } = job.data;
@ -233,14 +242,24 @@ export class ConnectionManager implements IConnectionManager {
syncedAt: new Date()
}
})
captureEvent('backend_connection_sync_job_completed', {
connectionId: connectionId,
repoCount: result.repoCount,
});
}
private async onSyncJobFailed(job: Job | undefined, err: unknown) {
this.logger.info(`Connection sync job failed with error: ${err}`);
if (job) {
const { connectionId } = job.data;
captureEvent('backend_connection_sync_job_failed', {
connectionId: connectionId,
error: err instanceof BackendException ? err.code : 'UNKNOWN',
});
// We may have pushed some metadata during the execution of the job, so we make sure to not overwrite the metadata here
const { connectionId } = job.data;
let syncStatusMetadata: Record<string, unknown> = (await this.db.connection.findUnique({
where: { id: connectionId },
select: { syncStatusMetadata: true }

View file

@ -5,17 +5,20 @@ export type PosthogEventMap = {
vcs: string;
codeHost?: string;
},
repo_synced: {
vcs: string;
codeHost?: string;
fetchDuration_s?: number;
cloneDuration_s?: number;
indexDuration_s?: number;
},
repo_deleted: {
vcs: string;
codeHost?: string;
}
},
//////////////////////////////////////////////////////////////////
backend_connection_sync_job_failed: {
connectionId: number,
error: string,
},
backend_connection_sync_job_completed: {
connectionId: number,
repoCount: number,
},
//////////////////////////////////////////////////////////////////
}
export type PosthogEvent = keyof PosthogEventMap;

View file

@ -10,6 +10,7 @@ import { cloneRepository, fetchRepository } from "./git.js";
import { existsSync, rmSync, readdirSync } from 'fs';
import { indexGitRepository } from "./zoekt.js";
import os from 'os';
import { BackendException } from "@sourcebot/error";
interface IRepoManager {
blockingPollLoop: () => void;
@ -308,14 +309,6 @@ export class RepoManager implements IRepoManager {
indexDuration_s = stats!.indexDuration_s;
fetchDuration_s = stats!.fetchDuration_s;
cloneDuration_s = stats!.cloneDuration_s;
captureEvent('repo_synced', {
vcs: 'git',
codeHost: repo.external_codeHostType,
indexDuration_s,
fetchDuration_s,
cloneDuration_s,
});
}
private async onIndexJobCompleted(job: Job<JobPayload>) {

View file

@ -2,7 +2,7 @@
import Ajv from "ajv";
import { auth } from "./auth";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription } from "@/lib/serviceError";
import { notAuthenticated, notFound, ServiceError, unexpectedError, orgInvalidSubscription, secretAlreadyExists } from "@/lib/serviceError";
import { prisma } from "@/prisma";
import { StatusCodes } from "http-status-codes";
import { ErrorCode } from "@/lib/errorCodes";
@ -14,7 +14,7 @@ import { gerritSchema } from "@sourcebot/schemas/v3/gerrit.schema";
import { GithubConnectionConfig, GitlabConnectionConfig, GiteaConnectionConfig, GerritConnectionConfig, ConnectionConfig } from "@sourcebot/schemas/v3/connection.type";
import { encrypt } from "@sourcebot/crypto"
import { getConnection, getLinkedRepos } from "./data/connection";
import { ConnectionSyncStatus, Prisma, Invite, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { ConnectionSyncStatus, Prisma, OrgRole, Connection, Repo, Org, RepoIndexingStatus, StripeSubscriptionStatus } from "@sourcebot/db";
import { headers } from "next/headers"
import { getStripe } from "@/lib/stripe"
import { getUser } from "@/data/user";
@ -184,6 +184,19 @@ export const createSecret = async (key: string, value: string, domain: string):
withOrgMembership(session, domain, async ({ orgId }) => {
try {
const encrypted = encrypt(value);
const existingSecret = await prisma.secret.findUnique({
where: {
orgId_key: {
orgId,
key,
}
}
});
if (existingSecret) {
return secretAlreadyExists();
}
await prisma.secret.create({
data: {
orgId,

View file

@ -19,7 +19,9 @@ import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { Schema } from "ajv";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { PosthogEvent, PosthogEventMap } from "@/lib/posthogEvents";
import { CodeHostType } from "@/lib/utils";
export type QuickActionFn<T> = (previous: T) => T;
export type QuickAction<T> = {
name: string;
@ -29,6 +31,7 @@ export type QuickAction<T> = {
interface ConfigEditorProps<T> {
value: string;
type: CodeHostType;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
onChange: (...event: any[]) => void;
actions: QuickAction<T>[],
@ -102,8 +105,8 @@ export const isConfigValidJson = (config: string) => {
}
const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCodeMirrorRef>) => {
const { value, onChange, actions, schema } = props;
const { value, type, onChange, actions, schema } = props;
const captureEvent = useCaptureEvent();
const editorRef = useRef<ReactCodeMirrorRef>(null);
useImperativeHandle(
forwardedRef,
@ -159,6 +162,10 @@ const ConfigEditor = <T,>(props: ConfigEditorProps<T>, forwardedRef: Ref<ReactCo
disabled={!isConfigValidJson(value)}
onClick={(e) => {
e.preventDefault();
captureEvent('wa_config_editor_quick_action_pressed', {
name,
type,
});
if (editorRef.current?.view) {
onQuickAction(fn, value, editorRef.current.view, {
focusEditor: true,

View file

@ -30,7 +30,7 @@ import githubPatCreation from "@/public/github_pat_creation.png"
import { CodeHostType } from "@/lib/utils";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";
import { isDefined } from '@/lib/utils'
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface SecretComboBoxProps {
isDisabled: boolean;
codeHostType: CodeHostType;
@ -47,6 +47,7 @@ export const SecretCombobox = ({
const [searchFilter, setSearchFilter] = useState("");
const domain = useDomain();
const [isCreateSecretDialogOpen, setIsCreateSecretDialogOpen] = useState(false);
const captureEvent = useCaptureEvent();
const { data: secrets, isLoading, refetch } = useQuery({
queryKey: ["secrets"],
@ -154,7 +155,12 @@ export const SecretCombobox = ({
<Button
variant="ghost"
size="sm"
onClick={() => setIsCreateSecretDialogOpen(true)}
onClick={() => {
setIsCreateSecretDialogOpen(true);
captureEvent('wa_secret_combobox_import_secret_pressed', {
type: codeHostType,
});
}}
className={cn(
"w-full justify-start gap-1.5 p-2",
secrets && !isServiceError(secrets) && secrets.length > 0 && "my-2"
@ -187,10 +193,17 @@ const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHostType
const [showValue, setShowValue] = useState(false);
const domain = useDomain();
const { toast } = useToast();
const captureEvent = useCaptureEvent();
const formSchema = z.object({
key: z.string().min(1).refine(async (key) => {
const doesSecretExist = await checkIfSecretExists(key, domain);
if(!isServiceError(doesSecretExist)) {
captureEvent('wa_secret_combobox_import_secret_fail', {
type: codeHostType,
error: "A secret with this key already exists.",
});
}
return isServiceError(doesSecretExist) || !doesSecretExist;
}, "A secret with this key already exists."),
value: z.string().min(1),
@ -211,15 +224,22 @@ const ImportSecretDialog = ({ open, onOpenChange, onSecretCreated, codeHostType
toast({
description: `❌ Failed to create secret`
});
captureEvent('wa_secret_combobox_import_secret_fail', {
type: codeHostType,
error: response.message,
});
} else {
toast({
description: `✅ Secret created successfully!`
});
captureEvent('wa_secret_combobox_import_secret_success', {
type: codeHostType,
});
form.reset();
onOpenChange(false);
onSecretCreated(data.key);
}
}, [domain, toast, onOpenChange, onSecretCreated, form]);
}, [domain, toast, onOpenChange, onSecretCreated, form, codeHostType, captureEvent]);
const codeHostSpecificStep = useMemo(() => {
switch (codeHostType) {

View file

@ -21,6 +21,7 @@ import { Loader2 } from "lucide-react";
import { ReactCodeMirrorRef } from "@uiw/react-codemirror";
import { SecretCombobox } from "./secretCombobox";
import strings from "@/lib/strings";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface SharedConnectionCreationFormProps<T> {
type: CodeHostType;
@ -51,7 +52,7 @@ export default function SharedConnectionCreationForm<T>({
const { toast } = useToast();
const domain = useDomain();
const editorRef = useRef<ReactCodeMirrorRef>(null);
const captureEvent = useCaptureEvent();
const formSchema = useMemo(() => {
return z.object({
name: z.string().min(1),
@ -64,7 +65,7 @@ export default function SharedConnectionCreationForm<T>({
return checkIfSecretExists(secretKey, domain);
}, { message: "Secret not found" }),
});
}, [schema]);
}, [schema, domain]);
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -78,13 +79,20 @@ export default function SharedConnectionCreationForm<T>({
toast({
description: `❌ Failed to create connection. Reason: ${response.message}`
});
captureEvent('wa_create_connection_fail', {
type: type,
error: response.message,
});
} else {
toast({
description: `✅ Connection created successfully.`
});
captureEvent('wa_create_connection_success', {
type: type,
});
onCreated?.(response.id);
}
}, [domain, toast, type, onCreated]);
}, [domain, toast, type, onCreated, captureEvent]);
const onConfigChange = useCallback((value: string) => {
form.setValue("config", value);
@ -168,6 +176,9 @@ export default function SharedConnectionCreationForm<T>({
}
}
},
captureEvent,
"set-secret",
type,
form.getValues("config"),
view,
{
@ -193,6 +204,7 @@ export default function SharedConnectionCreationForm<T>({
<FormControl>
<ConfigEditor<T>
ref={editorRef}
type={type}
value={value}
onChange={onConfigChange}
actions={quickActions ?? []}

View file

@ -7,6 +7,7 @@ import { useDomain } from "@/hooks/useDomain";
import { getConnectionFailedRepos, getConnections } from "@/actions";
import { useState, useEffect } from "react";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
enum ConnectionErrorType {
SYNC_FAILED = "SYNC_FAILED",
@ -23,6 +24,7 @@ interface Error {
export const ErrorNavIndicator = () => {
const domain = useDomain();
const [errors, setErrors] = useState<Error[]>([]);
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchErrors = async () => {
@ -39,15 +41,25 @@ export const ErrorNavIndicator = () => {
}
const failedRepos = await getConnectionFailedRepos(connection.id, domain);
if (!isServiceError(failedRepos) && failedRepos.length > 0) {
errors.push({
connectionId: connection.id,
connectionName: connection.name,
numRepos: failedRepos.length,
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
if (!isServiceError(failedRepos)) {
if (failedRepos.length > 0) {
errors.push({
connectionId: connection.id,
connectionName: connection.name,
numRepos: failedRepos.length,
errorType: ConnectionErrorType.REPO_INDEXING_FAILED
});
}
} else {
captureEvent('wa_error_nav_job_fetch_fail', {
error: failedRepos.errorCode,
});
}
}
} else {
captureEvent('wa_error_nav_connection_fetch_fail', {
error: connections.errorCode,
});
}
setErrors(prevErrors => {
// Only update if the errors have actually changed
@ -62,14 +74,14 @@ export const ErrorNavIndicator = () => {
};
fetchErrors();
}, [domain]);
}, [domain, captureEvent]);
if (errors.length === 0) return null;
return (
<Link href={`/${domain}/connections`}>
<HoverCard>
<HoverCardTrigger asChild>
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_error_nav_pressed', {})}>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_error_nav_hover', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-700 rounded-full text-red-700 dark:text-red-400 text-xs font-medium hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors cursor-pointer">
<CircleXIcon className="h-4 w-4" />
{errors.reduce((acc, error) => acc + (error.numRepos || 0), 0) > 0 && (
@ -93,7 +105,7 @@ export const ErrorNavIndicator = () => {
.filter(e => e.errorType === 'SYNC_FAILED')
.slice(0, 10)
.map(error => (
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`}>
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-red-50 dark:bg-red-900/20
rounded-md text-sm text-red-700 dark:text-red-300
border border-red-200/50 dark:border-red-800/50
@ -125,7 +137,7 @@ export const ErrorNavIndicator = () => {
.filter(e => e.errorType === 'REPO_INDEXING_FAILED')
.slice(0, 10)
.map(error => (
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`}>
<Link key={error.connectionName} href={`/${domain}/connections/${error.connectionId}`} onClick={() => captureEvent('wa_error_nav_job_pressed', {})}>
<div className="flex items-center justify-between px-3 py-2
bg-red-50 dark:bg-red-900/20 rounded-md
border border-red-200/50 dark:border-red-800/50

View file

@ -12,6 +12,7 @@ import { ErrorNavIndicator } from "./errorNavIndicator";
import { WarningNavIndicator } from "./warningNavIndicator";
import { ProgressNavIndicator } from "./progressNavIndicator";
import { SourcebotLogo } from "@/app/components/sourcebotLogo";
import { TrialNavIndicator } from "./trialNavIndicator";
const SOURCEBOT_DISCORD_URL = "https://discord.gg/6Fhp27x7Pb";
const SOURCEBOT_GITHUB_URL = "https://github.com/sourcebot-dev/sourcebot";
@ -89,16 +90,7 @@ export const NavigationMenu = async ({
<ProgressNavIndicator />
<WarningNavIndicator />
<ErrorNavIndicator />
{!isServiceError(subscription) && subscription && subscription.status === "trialing" && (
<Link href={`/${domain}/settings/billing`}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
<span>
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in trial
</span>
</div>
</Link>
)}
<TrialNavIndicator subscription={subscription} />
<form
action={async () => {
"use server";

View file

@ -7,7 +7,7 @@ import { useEffect, useState } from "react";
import { useDomain } from "@/hooks/useDomain";
import { getConnectionInProgressRepos, getConnections } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface InProgress {
connectionId: number;
repoId: number;
@ -18,6 +18,7 @@ interface InProgress {
export const ProgressNavIndicator = () => {
const domain = useDomain();
const [inProgressJobs, setInProgressJobs] = useState<InProgress[]>([]);
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchInProgressJobs = async () => {
@ -31,6 +32,10 @@ export const ProgressNavIndicator = () => {
connectionId: connection.id,
...repo
})));
} else {
captureEvent('wa_progress_nav_job_fetch_fail', {
error: inProgressRepos.errorCode,
});
}
}
setInProgressJobs(prevJobs => {
@ -42,20 +47,24 @@ export const ProgressNavIndicator = () => {
);
return jobsChanged ? allInProgressRepos : prevJobs;
});
} else {
captureEvent('wa_progress_nav_connection_fetch_fail', {
error: connections.errorCode,
});
}
};
fetchInProgressJobs();
}, [domain]);
}, [domain, captureEvent]);
if (inProgressJobs.length === 0) {
return null;
}
return (
<Link href={`/${domain}/connections`}>
<HoverCard>
<HoverCardTrigger asChild>
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_progress_nav_pressed', {})}>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_progress_nav_hover', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-green-50 dark:bg-green-900/20 border border-green-200 dark:border-green-700 rounded-full text-green-700 dark:text-green-400 text-xs font-medium hover:bg-green-100 dark:hover:bg-green-900/30 transition-colors cursor-pointer">
<Loader2Icon className="h-4 w-4 animate-spin" />
<span>{inProgressJobs.length}</span>
@ -72,7 +81,7 @@ export const ProgressNavIndicator = () => {
</p>
<div className="flex flex-col gap-2 pl-4">
{inProgressJobs.slice(0, 10).map(item => (
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`}>
<Link key={item.repoId} href={`/${domain}/connections/${item.connectionId}`} onClick={() => captureEvent('wa_progress_nav_job_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-green-50 dark:bg-green-900/20
rounded-md text-sm text-green-700 dark:text-green-300
border border-green-200/50 dark:border-green-800/50

View file

@ -0,0 +1,41 @@
"use client";
import Link from "next/link";
import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { ServiceError } from "@/lib/serviceError";
interface Props {
subscription: {
status: string;
nextBillingDate: number;
} | null | ServiceError;
}
export const TrialNavIndicator = ({ subscription }: Props) => {
const domain = useDomain();
const captureEvent = useCaptureEvent();
if (isServiceError(subscription)) {
captureEvent('wa_trial_nav_subscription_fetch_fail', {
error: subscription.errorCode,
});
return null;
}
if (!subscription || subscription.status !== "trialing") {
return null;
}
return (
<Link href={`/${domain}/upgrade`} onClick={() => captureEvent('wa_trial_nav_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-700 rounded-full text-blue-700 dark:text-blue-400 text-xs font-medium hover:bg-blue-100 dark:hover:bg-blue-900/30 transition-colors cursor-pointer">
<span className="inline-block w-2 h-2 bg-blue-400 dark:bg-blue-500 rounded-full"></span>
<span>
{Math.ceil((subscription.nextBillingDate * 1000 - Date.now()) / (1000 * 60 * 60 * 24))} days left in trial
</span>
</div>
</Link>
);
};

View file

@ -9,7 +9,7 @@ import { useState } from "react";
import { useEffect } from "react";
import { isServiceError } from "@/lib/utils";
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface Warning {
connectionId?: number;
connectionName?: string;
@ -18,6 +18,7 @@ interface Warning {
export const WarningNavIndicator = () => {
const domain = useDomain();
const [warnings, setWarnings] = useState<Warning[]>([]);
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchWarnings = async () => {
@ -33,7 +34,12 @@ export const WarningNavIndicator = () => {
}
}
}
} else {
captureEvent('wa_warning_nav_connection_fetch_fail', {
error: connections.errorCode,
});
}
setWarnings(prevWarnings => {
// Only update if the warnings have actually changed
const warningsChanged = prevWarnings.length !== warnings.length ||
@ -46,16 +52,16 @@ export const WarningNavIndicator = () => {
};
fetchWarnings();
}, [domain]);
}, [domain, captureEvent]);
if (warnings.length === 0) {
return null;
}
return (
<Link href={`/${domain}/connections`}>
<HoverCard>
<HoverCardTrigger asChild>
<Link href={`/${domain}/connections`} onClick={() => captureEvent('wa_warning_nav_pressed', {})}>
<HoverCard openDelay={50}>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_warning_nav_hover', {})}>
<div className="flex items-center gap-2 px-3 py-1.5 bg-yellow-50 dark:bg-yellow-900/20 border border-yellow-200 dark:border-yellow-700 rounded-full text-yellow-700 dark:text-yellow-400 text-xs font-medium hover:bg-yellow-100 dark:hover:bg-yellow-900/30 transition-colors cursor-pointer">
<AlertTriangleIcon className="h-4 w-4" />
<span>{warnings.length}</span>
@ -72,7 +78,7 @@ export const WarningNavIndicator = () => {
</p>
<div className="flex flex-col gap-2 pl-4">
{warnings.slice(0, 10).map(warning => (
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`}>
<Link key={warning.connectionName} href={`/${domain}/connections/${warning.connectionId}`} onClick={() => captureEvent('wa_warning_nav_connection_pressed', {})}>
<div className="flex items-center gap-2 px-3 py-2 bg-yellow-50 dark:bg-yellow-900/20
rounded-md text-sm text-yellow-700 dark:text-yellow-300
border border-yellow-200/50 dark:border-yellow-800/50

View file

@ -19,6 +19,7 @@ import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface DeleteConnectionSettingProps {
connectionId: number;
@ -32,6 +33,7 @@ export const DeleteConnectionSetting = ({
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const handleDelete = useCallback(() => {
setIsDialogOpen(false);
@ -42,10 +44,14 @@ export const DeleteConnectionSetting = ({
toast({
description: `❌ Failed to delete connection. Reason: ${response.message}`
});
captureEvent('wa_connection_delete_fail', {
error: response.errorCode,
});
} else {
toast({
description: `✅ Connection deleted successfully.`
});
captureEvent('wa_connection_delete_success', {});
router.replace(`/${domain}/connections`);
router.refresh();
}

View file

@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react"
import { Prisma } from "@sourcebot/db"
import { RetrySyncButton } from "./retrySyncButton"
import { SyncStatusMetadataSchema } from "@/lib/syncStatusMetadataSchema"
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface NotFoundWarningProps {
syncStatusMetadata: Prisma.JsonValue
onSecretsClick: () => void
@ -12,6 +12,8 @@ interface NotFoundWarningProps {
}
export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connectionId, domain, connectionType }: NotFoundWarningProps) => {
const captureEvent = useCaptureEvent();
const parseResult = SyncStatusMetadataSchema.safeParse(syncStatusMetadata);
if (!parseResult.success || !parseResult.data.notFound) {
return null;
@ -21,6 +23,8 @@ export const NotFoundWarning = ({ syncStatusMetadata, onSecretsClick, connection
if (notFound.users.length === 0 && notFound.orgs.length === 0 && notFound.repos.length === 0) {
return null;
} else {
captureEvent('wa_connection_not_found_warning_displayed', {});
}
return (

View file

@ -5,6 +5,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagRepoForIndex } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetryRepoIndexButtonProps {
repoId: number;
@ -12,6 +13,8 @@ interface RetryRepoIndexButtonProps {
}
export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
@ -23,10 +26,14 @@ export const RetryRepoIndexButton = ({ repoId, domain }: RetryRepoIndexButtonPro
toast({
description: `❌ Failed to flag repository for indexing.`,
});
captureEvent('wa_repo_retry_index_fail', {
error: result.errorCode,
});
} else {
toast({
description: "✅ Repository flagged for indexing.",
});
captureEvent('wa_repo_retry_index_success', {});
}
}}
>

View file

@ -5,6 +5,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagRepoForIndex, getConnectionFailedRepos } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetryAllFailedReposButtonProps {
connectionId: number;
@ -12,17 +13,23 @@ interface RetryAllFailedReposButtonProps {
}
export const RetryAllFailedReposButton = ({ connectionId, domain }: RetryAllFailedReposButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
size="sm"
className="ml-2"
onClick={async () => {
captureEvent('wa_connection_retry_all_failed_repos_pressed', {});
const failedRepos = await getConnectionFailedRepos(connectionId, domain);
if (isServiceError(failedRepos)) {
toast({
description: `❌ Failed to get failed repositories.`,
});
captureEvent('wa_connection_retry_all_failed_repos_fetch_fail', {
error: failedRepos.errorCode,
});
return;
}
@ -42,14 +49,22 @@ export const RetryAllFailedReposButton = ({ connectionId, domain }: RetryAllFail
toast({
description: `⚠️ ${successCount} repositories flagged for indexing, ${failureCount} failed.`,
});
captureEvent('wa_connection_retry_all_failed_repos_fail', {
successCount,
failureCount,
});
} else if (successCount > 0) {
toast({
description: `${successCount} repositories flagged for indexing.`,
});
captureEvent('wa_connection_retry_all_failed_repos_success', {
successCount,
});
} else {
toast({
description: " No failed repositories to retry.",
});
captureEvent('wa_connection_retry_all_failed_no_repos', {});
}
}}
>

View file

@ -5,6 +5,7 @@ import { ReloadIcon } from "@radix-ui/react-icons"
import { toast } from "@/components/hooks/use-toast";
import { flagConnectionForSync } from "@/actions";
import { isServiceError } from "@/lib/utils";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface RetrySyncButtonProps {
connectionId: number;
@ -12,6 +13,8 @@ interface RetrySyncButtonProps {
}
export const RetrySyncButton = ({ connectionId, domain }: RetrySyncButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
variant="outline"
@ -23,10 +26,14 @@ export const RetrySyncButton = ({ connectionId, domain }: RetrySyncButtonProps)
toast({
description: `❌ Failed to flag connection for sync.`,
});
captureEvent('wa_connection_retry_sync_fail', {
error: result.errorCode,
});
} else {
toast({
description: "✅ Connection flagged for sync.",
});
captureEvent('wa_connection_retry_sync_success', {});
}
}}
>

View file

@ -28,6 +28,7 @@ import { DisplayConnectionError } from "./components/connectionError"
import { NotFoundWarning } from "./components/notFoundWarning"
import { RetrySyncButton } from "./components/retrySyncButton"
import { RetryAllFailedReposButton } from "./components/retryAllFailedReposButton"
import useCaptureEvent from "@/hooks/useCaptureEvent";
export default function ConnectionManagementPage() {
const params = useParams()
@ -38,8 +39,10 @@ export default function ConnectionManagementPage() {
const [linkedRepos, setLinkedRepos] = useState<Repo[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const captureEvent = useCaptureEvent();
const handleSecretsNavigation = () => {
captureEvent('wa_connection_secrets_navigation_pressed', {});
router.push(`/${params.domain}/secrets`)
}
@ -174,7 +177,7 @@ export default function ConnectionManagementPage() {
<div className="flex items-center gap-2 mt-2">
{connection.syncStatus === "FAILED" ? (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<HoverCardTrigger asChild onMouseEnter={() => captureEvent('wa_connection_failed_status_hover', {})}>
<div className="flex items-center">
<span className="inline-flex items-center rounded-full bg-red-50 px-2 py-1 text-xs font-medium text-red-400 ring-1 ring-inset ring-red-600/20 cursor-help hover:text-red-600 hover:bg-red-100 transition-colors duration-200">
{connection.syncStatus}

View file

@ -1,11 +1,11 @@
import { Button } from "@/components/ui/button";
import { getDisplayTime } from "@/lib/utils";
import { useMemo } from "react";
import { ConnectionIcon } from "../connectionIcon";
import { ConnectionSyncStatus, Prisma } from "@sourcebot/db";
import { StatusIcon } from "../statusIcon";
import { AlertTriangle, CircleX} from "lucide-react";
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { ConnectionListItemErrorIndicator } from "./connectionListItemErrorIndicator";
import { ConnectionListItemWarningIndicator } from "./connectionListItemWarningIndicator";
import { ConnectionListItemManageButton } from "./connectionListItemManageButton";
const convertSyncStatus = (status: ConnectionSyncStatus) => {
switch (status) {
@ -83,92 +83,13 @@ export const ConnectionListItem = ({
<p className="font-medium">{name}</p>
<span className="text-sm text-muted-foreground">{`Edited ${getDisplayTime(editedAt)}`}</span>
</div>
{failedRepos && failedRepos.length > 0 && (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<CircleX
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
onClick={() => window.location.href = `connections/${id}`}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
</div>
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
<p>
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
</p>
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
<div className="flex flex-col gap-1.5">
{failedRepos.slice(0, 10).map(repo => (
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
))}
{failedRepos.length > 10 && (
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
And {failedRepos.length - 10} more...
</span>
)}
</div>
</div>
<p className="text-xs">
Navigate to the connection for more details and to retry indexing.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
)}
{(notFoundData && displayNotFoundWarning) && (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<AlertTriangle
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
onClick={() => window.location.href = `connections/${id}`}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
Some requested references couldn&apos;t be found. Verify the details below and ensure your connection is using a {" "}
<button
onClick={() => window.location.href = `secrets`}
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
>
valid access token
</button>{" "}
that has access to any private references.
</p>
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
{notFoundData.users.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
</li>
)}
{notFoundData.orgs.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
</li>
)}
{notFoundData.repos.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
</li>
)}
</ul>
</div>
</HoverCardContent>
</HoverCard>
)}
<ConnectionListItemErrorIndicator failedRepos={failedRepos} connectionId={id} />
<ConnectionListItemWarningIndicator
notFoundData={notFoundData}
connectionId={id}
type={type}
displayWarning={displayNotFoundWarning}
/>
</div>
<div className="flex flex-row items-center">
<StatusIcon
@ -186,14 +107,7 @@ export const ConnectionListItem = ({
)
}
</p>
<Button
variant="outline"
size={"sm"}
className="ml-4"
onClick={() => window.location.href = `connections/${id}`}
>
Manage
</Button>
<ConnectionListItemManageButton id={id} />
</div>
</div>
)

View file

@ -0,0 +1,62 @@
'use client'
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { CircleX } from "lucide-react";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface ConnectionListItemErrorIndicatorProps {
failedRepos: { repoId: number; repoName: string; }[] | undefined;
connectionId: string;
}
export const ConnectionListItemErrorIndicator = ({
failedRepos,
connectionId
}: ConnectionListItemErrorIndicatorProps) => {
const captureEvent = useCaptureEvent()
if (!failedRepos || failedRepos.length === 0) return null;
return (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<CircleX
className="h-5 w-5 text-red-700 dark:text-red-400 cursor-help hover:text-red-600 dark:hover:text-red-300 transition-colors"
onClick={() => {
captureEvent('wa_connection_list_item_error_pressed', {})
window.location.href = `connections/${connectionId}`
}}
onMouseEnter={() => captureEvent('wa_connection_list_item_error_hover', {})}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-red-200 dark:border-red-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-red-200 dark:border-red-800">
<CircleX className="h-4 w-4 text-red-700 dark:text-red-400" />
<h3 className="text-sm font-semibold text-red-700 dark:text-red-400">Failed to Index Repositories</h3>
</div>
<div className="text-sm text-red-600/90 dark:text-red-300/90 space-y-3">
<p>
{failedRepos.length} {failedRepos.length === 1 ? 'repository' : 'repositories'} failed to index. This is likely due to temporary server load.
</p>
<div className="space-y-2 text-sm bg-red-50 dark:bg-red-900/20 rounded-md p-3 border border-red-200/50 dark:border-red-800/50">
<div className="flex flex-col gap-1.5">
{failedRepos.slice(0, 10).map(repo => (
<span key={repo.repoId} className="text-red-700 dark:text-red-300">{repo.repoName}</span>
))}
{failedRepos.length > 10 && (
<span className="text-red-600/75 dark:text-red-400/75 text-xs pt-1">
And {failedRepos.length - 10} more...
</span>
)}
</div>
</div>
<p className="text-xs">
Navigate to the connection for more details and to retry indexing.
</p>
</div>
</div>
</HoverCardContent>
</HoverCard>
);
};

View file

@ -0,0 +1,32 @@
'use client'
import { Button } from "@/components/ui/button";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
interface ConnectionListItemManageButtonProps {
id: string;
}
export const ConnectionListItemManageButton = ({
id
}: ConnectionListItemManageButtonProps) => {
const captureEvent = useCaptureEvent()
const router = useRouter();
const domain = useDomain();
return (
<Button
variant="outline"
size={"sm"}
className="ml-4"
onClick={() => {
captureEvent('wa_connection_list_item_manage_pressed', {})
router.push(`/${domain}/connections/${id}`)
}}
>
Manage
</Button>
);
};

View file

@ -0,0 +1,78 @@
'use client'
import { HoverCard, HoverCardContent, HoverCardTrigger } from "@/components/ui/hover-card";
import { AlertTriangle } from "lucide-react";
import { NotFoundData } from "@/lib/syncStatusMetadataSchema";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface ConnectionListItemWarningIndicatorProps {
notFoundData: NotFoundData | null;
connectionId: string;
type: string;
displayWarning: boolean;
}
export const ConnectionListItemWarningIndicator = ({
notFoundData,
connectionId,
type,
displayWarning
}: ConnectionListItemWarningIndicatorProps) => {
const captureEvent = useCaptureEvent()
if (!notFoundData || !displayWarning) return null;
return (
<HoverCard openDelay={50}>
<HoverCardTrigger asChild>
<AlertTriangle
className="h-5 w-5 text-yellow-700 dark:text-yellow-400 cursor-help hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
onClick={() => {
captureEvent('wa_connection_list_item_warning_pressed', {})
window.location.href = `connections/${connectionId}`
}}
onMouseEnter={() => captureEvent('wa_connection_list_item_warning_hover', {})}
/>
</HoverCardTrigger>
<HoverCardContent className="w-80 border border-yellow-200 dark:border-yellow-800 rounded-lg">
<div className="flex flex-col space-y-3">
<div className="flex items-center gap-2 pb-2 border-b border-yellow-200 dark:border-yellow-800">
<AlertTriangle className="h-4 w-4 text-yellow-700 dark:text-yellow-400" />
<h3 className="text-sm font-semibold text-yellow-700 dark:text-yellow-400">Unable to fetch all references</h3>
</div>
<p className="text-sm text-yellow-600/90 dark:text-yellow-300/90">
Some requested references couldn&apos;t be found. Verify the details below and ensure your connection is using a {" "}
<button
onClick={() => window.location.href = `secrets`}
className="font-medium text-yellow-700 dark:text-yellow-400 hover:text-yellow-600 dark:hover:text-yellow-300 transition-colors"
>
valid access token
</button>{" "}
that has access to any private references.
</p>
<ul className="space-y-2 text-sm bg-yellow-50 dark:bg-yellow-900/20 rounded-md p-3 border border-yellow-200/50 dark:border-yellow-800/50">
{notFoundData.users.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">Users:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.users.join(', ')}</span>
</li>
)}
{notFoundData.orgs.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Groups" : "Organizations"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.orgs.join(', ')}</span>
</li>
)}
{notFoundData.repos.length > 0 && (
<li className="flex items-center gap-2">
<span className="font-medium text-yellow-700 dark:text-yellow-400">{type === "gitlab" ? "Projects" : "Repositories"}:</span>
<span className="text-yellow-700 dark:text-yellow-300">{notFoundData.repos.join(', ')}</span>
</li>
)}
</ul>
</div>
</HoverCardContent>
</HoverCard>
);
};

View file

@ -12,6 +12,7 @@ import { Check, Loader2 } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { useRouter } from "next/navigation";
import { TEAM_FEATURES } from "@/lib/constants";
import useCaptureEvent from "@/hooks/useCaptureEvent";
export const Checkout = () => {
const domain = useDomain();
@ -20,6 +21,7 @@ export const Checkout = () => {
const errorMessage = useNonEmptyQueryParam('errorMessage');
const [isLoading, setIsLoading] = useState(false);
const router = useRouter();
const captureEvent = useCaptureEvent();
useEffect(() => {
if (errorCode === ErrorCode.STRIPE_CHECKOUT_ERROR && errorMessage) {
@ -27,8 +29,11 @@ export const Checkout = () => {
description: `⚠️ Stripe checkout failed with error: ${errorMessage}`,
variant: "destructive",
});
captureEvent('wa_onboard_checkout_fail', {
error: errorMessage,
});
}
}, [errorCode, errorMessage, toast]);
}, [errorCode, errorMessage, toast, captureEvent]);
const onCheckout = useCallback(() => {
setIsLoading(true);
@ -39,14 +44,18 @@ export const Checkout = () => {
description: `❌ Stripe checkout failed with error: ${response.message}`,
variant: "destructive",
})
captureEvent('wa_onboard_checkout_fail', {
error: response.errorCode,
});
} else {
router.push(response.url);
captureEvent('wa_onboard_checkout_success', {});
}
})
.finally(() => {
setIsLoading(false);
});
}, [domain, router, toast]);
}, [domain, router, toast, captureEvent]);
return (
<div className="flex flex-col items-center justify-center max-w-md my-auto">

View file

@ -14,7 +14,7 @@ import { useRouter } from "next/navigation";
import { useCallback } from "react";
import { OnboardingSteps } from "@/lib/constants";
import { Button } from "@/components/ui/button";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface ConnectCodeHostProps {
nextStep: OnboardingSteps;
}
@ -22,6 +22,7 @@ interface ConnectCodeHostProps {
export const ConnectCodeHost = ({ nextStep }: ConnectCodeHostProps) => {
const [selectedCodeHost, setSelectedCodeHost] = useState<CodeHostType | null>(null);
const router = useRouter();
const captureEvent = useCaptureEvent();
const onCreated = useCallback(() => {
router.push(`?step=${nextStep}`);
}, [nextStep, router]);
@ -101,11 +102,17 @@ const CodeHostButton = ({
logo,
onClick,
}: CodeHostButtonProps) => {
const captureEvent = useCaptureEvent();
return (
<Button
className="flex flex-col items-center justify-center p-4 w-24 h-24 cursor-pointer gap-2"
variant="outline"
onClick={onClick}
onClick={() => {
captureEvent('wa_connect_code_host_button_pressed', {
name,
})
onClick();
}}
>
<Image src={logo.src} alt={name} className={cn("w-8 h-8", logo.className)} />
<p className="text-sm font-medium">{name}</p>

View file

@ -16,7 +16,7 @@ import { useDomain } from "@/hooks/useDomain";
import { useToast } from "@/components/hooks/use-toast";
import { OnboardingSteps } from "@/lib/constants";
import { useRouter } from "next/navigation";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface InviteTeamProps {
nextStep: OnboardingSteps;
}
@ -25,6 +25,7 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
resolver: zodResolver(inviteMemberFormSchema),
@ -48,17 +49,27 @@ export const InviteTeam = ({ nextStep }: InviteTeamProps) => {
toast({
description: `❌ Failed to invite members. Reason: ${response.message}`
});
captureEvent('wa_onboard_invite_team_invite_fail', {
error: response.errorCode,
num_emails: data.emails.length,
});
} else {
toast({
description: `✅ Successfully invited ${data.emails.length} members`
});
captureEvent('wa_onboard_invite_team_invite_success', {
num_emails: data.emails.length,
});
onComplete();
}
}, [domain, toast, onComplete]);
}, [domain, toast, onComplete, captureEvent]);
const onSkip = useCallback(() => {
captureEvent('wa_onboard_invite_team_skip', {
num_emails: form.getValues().emails.length,
});
onComplete();
}, [onComplete]);
}, [onComplete, form, captureEvent]);
return (
<Card className="p-12 w-[500px]">

View file

@ -0,0 +1,30 @@
'use client';
import Link from "next/link";
import { OnboardingSteps } from "@/lib/constants";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface SkipOnboardingButtonProps {
currentStep: OnboardingSteps;
lastRequiredStep: OnboardingSteps;
}
export const SkipOnboardingButton = ({ currentStep, lastRequiredStep }: SkipOnboardingButtonProps) => {
const captureEvent = useCaptureEvent();
const handleClick = () => {
captureEvent('wa_onboard_skip_onboarding', {
step: currentStep
});
};
return (
<Link
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
href={`?step=${lastRequiredStep}`}
onClick={handleClick}
>
Skip onboarding
</Link>
);
};

View file

@ -4,11 +4,10 @@ import { OnboardingSteps } from "@/lib/constants";
import { notFound, redirect } from "next/navigation";
import { ConnectCodeHost } from "./components/connectCodeHost";
import { InviteTeam } from "./components/inviteTeam";
import Link from "next/link";
import { CompleteOnboarding } from "./components/completeOnboarding";
import { Checkout } from "./components/checkout";
import { LogoutEscapeHatch } from "@/app/components/logoutEscapeHatch";
import { SkipOnboardingButton } from "./components/skipOnboardingButton";
interface OnboardProps {
params: {
domain: string
@ -21,6 +20,7 @@ interface OnboardProps {
export default async function Onboard({ params, searchParams }: OnboardProps) {
const org = await getOrgFromDomain(params.domain);
if (!org) {
notFound();
}
@ -54,12 +54,10 @@ export default async function Onboard({ params, searchParams }: OnboardProps) {
<ConnectCodeHost
nextStep={OnboardingSteps.InviteTeam}
/>
<Link
className="text-sm text-muted-foreground underline cursor-pointer mt-12"
href={`?step=${lastRequiredStep}`}
>
Skip onboarding
</Link>
<SkipOnboardingButton
currentStep={step as OnboardingSteps}
lastRequiredStep={lastRequiredStep}
/>
</>
)}
{step === OnboardingSteps.InviteTeam && (

View file

@ -14,7 +14,9 @@ import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { deleteSecret } from "../../../actions"
import { useDomain } from "@/hooks/useDomain";
import useCaptureEvent from "@/hooks/useCaptureEvent";
import { PosthogEvent } from "@/lib/posthogEvents";
import { ErrorCode } from "@/lib/errorCodes";
const formSchema = z.object({
key: z.string().min(2).max(40),
value: z.string().min(2),
@ -29,6 +31,7 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
const [secrets, setSecrets] = useState<{ createdAt: Date; key: string; }[]>(initialSecrets);
const { toast } = useToast();
const domain = useDomain();
const captureEvent = useCaptureEvent();
useEffect(() => {
const fetchSecretKeys = async () => {
@ -54,19 +57,35 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
const handleCreateSecret = async (values: { key: string, value: string }) => {
const res = await createSecret(values.key, values.value, domain);
if (isServiceError(res)) {
toast({
description: `❌ Failed to create secret`
if (res.errorCode === ErrorCode.SECRET_ALREADY_EXISTS) {
toast({
description: `❌ Secret with key ${values.key} already exists`
});
} else {
toast({
description: `❌ Failed to create secret`
});
}
captureEvent('wa_secret_created_fail', {
key: values.key,
error: res.errorCode,
});
return;
} else {
toast({
description: `✅ Secret created successfully!`
});
captureEvent('wa_secret_created_success', {
key: values.key,
});
}
const keys = await getSecrets(domain);
if (isServiceError(keys)) {
console.error("Failed to fetch secrets");
captureEvent('wa_secret_fetch_fail', {
error: keys.errorCode,
});
} else {
setSecrets(keys);
@ -82,11 +101,18 @@ export const SecretsTable = ({ initialSecrets }: SecretsTableProps) => {
toast({
description: `❌ Failed to delete secret`
});
captureEvent('wa_secret_deleted_fail', {
key: key,
error: res.errorCode,
});
return;
} else {
toast({
description: `✅ Secret deleted successfully!`
});
captureEvent('wa_secret_deleted_success', {
key: key,
});
}
const keys = await getSecrets(domain);

View file

@ -14,7 +14,7 @@ import { useForm } from "react-hook-form"
import { zodResolver } from "@hookform/resolvers/zod"
import * as z from "zod"
import { useToast } from "@/components/hooks/use-toast";
import useCaptureEvent from "@/hooks/useCaptureEvent";
const formSchema = z.object({
email: z.string().email("Please enter a valid email address"),
})
@ -28,6 +28,7 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
const [billingEmail, setBillingEmail] = useState<string>("")
const [isLoading, setIsLoading] = useState(false)
const { toast } = useToast()
const captureEvent = useCaptureEvent();
const form = useForm<z.infer<typeof formSchema>>({
resolver: zodResolver(formSchema),
@ -41,10 +42,14 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
const email = await getSubscriptionBillingEmail(domain)
if (!isServiceError(email)) {
setBillingEmail(email)
} else {
captureEvent('wa_billing_email_fetch_fail', {
error: email.errorCode,
})
}
}
fetchBillingEmail()
}, [domain])
}, [domain, captureEvent])
const onSubmit = async (values: z.infer<typeof formSchema>) => {
setIsLoading(true)
@ -56,10 +61,14 @@ export function ChangeBillingEmailCard({ currentUserRole }: ChangeBillingEmailCa
toast({
description: "✅ Billing email updated successfully!",
})
captureEvent('wa_billing_email_updated_success', {})
} else {
toast({
description: "❌ Failed to update billing email. Please try again.",
})
captureEvent('wa_billing_email_updated_fail', {
error: result.message,
})
}
setIsLoading(false)
}

View file

@ -7,23 +7,28 @@ import { Button } from "@/components/ui/button"
import { getCustomerPortalSessionLink } from "@/actions"
import { useDomain } from "@/hooks/useDomain";
import { OrgRole } from "@sourcebot/db";
import useCaptureEvent from "@/hooks/useCaptureEvent";
export function ManageSubscriptionButton({ currentUserRole }: { currentUserRole: OrgRole }) {
const [isLoading, setIsLoading] = useState(false)
const router = useRouter()
const domain = useDomain();
const captureEvent = useCaptureEvent();
const redirectToCustomerPortal = async () => {
setIsLoading(true)
try {
const session = await getCustomerPortalSessionLink(domain)
if (isServiceError(session)) {
console.log("Failed to create portal session: ", session)
captureEvent('wa_manage_subscription_button_create_portal_session_fail', {
error: session.errorCode,
})
} else {
router.push(session)
captureEvent('wa_manage_subscription_button_create_portal_session_success', {})
}
} catch (error) {
console.error("Error creating portal session:", error)
captureEvent('wa_manage_subscription_button_create_portal_session_fail', {
error: "Unknown error",
})
} finally {
setIsLoading(false)
}

View file

@ -16,7 +16,7 @@ import { useDomain } from "@/hooks/useDomain";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
import useCaptureEvent from "@/hooks/useCaptureEvent";
export const inviteMemberFormSchema = z.object({
emails: z.array(z.object({
email: z.string().email()
@ -37,6 +37,7 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
const domain = useDomain();
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const form = useForm<z.infer<typeof inviteMemberFormSchema>>({
resolver: zodResolver(inviteMemberFormSchema),
@ -58,6 +59,10 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
toast({
description: `❌ Failed to invite members. Reason: ${res.message}`
});
captureEvent('wa_invite_member_card_invite_fail', {
error: res.errorCode,
num_emails: data.emails.length,
});
} else {
form.reset();
router.push(`?tab=invites`);
@ -65,12 +70,15 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
toast({
description: `✅ Successfully invited ${data.emails.length} members`
});
captureEvent('wa_invite_member_card_invite_success', {
num_emails: data.emails.length,
});
}
})
.finally(() => {
setIsLoading(false);
});
}, [domain, form, toast, router]);
}, [domain, form, toast, router, captureEvent]);
return (
<>
@ -151,7 +159,9 @@ export const InviteMemberCard = ({ currentUserRole }: InviteMemberCardProps) =>
</div>
</div>
<AlertDialogFooter>
<AlertDialogCancel>Cancel</AlertDialogCancel>
<AlertDialogCancel onClick={() => captureEvent('wa_invite_member_card_invite_cancel', {
num_emails: form.getValues().emails.length,
})}>Cancel</AlertDialogCancel>
<AlertDialogAction
onClick={() => onSubmit(form.getValues())}
>

View file

@ -16,7 +16,7 @@ import { useCallback, useMemo, useState } from "react";
import { cancelInvite } from "@/actions";
import { useRouter } from "next/navigation";
import { useDomain } from "@/hooks/useDomain";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface Invite {
id: string;
email: string;
@ -36,6 +36,7 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
const { toast } = useToast();
const router = useRouter();
const domain = useDomain();
const captureEvent = useCaptureEvent();
const filteredInvites = useMemo(() => {
return invites
@ -59,14 +60,18 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
toast({
description: `❌ Failed to cancel invite. Reason: ${response.message}`
})
captureEvent('wa_invites_list_cancel_invite_fail', {
error: response.errorCode,
})
} else {
toast({
description: `✅ Invite cancelled successfully.`
})
captureEvent('wa_invites_list_cancel_invite_success', {})
router.refresh();
}
});
}, [domain, toast, router]);
}, [domain, toast, router, captureEvent]);
return (
<div className="w-full mx-auto space-y-6">
@ -126,11 +131,13 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
toast({
description: `✅ Copied invite link for ${invite.email} to clipboard`
})
captureEvent('wa_invites_list_copy_invite_link_success', {})
})
.catch(() => {
toast({
description: "❌ Failed to copy invite link"
})
captureEvent('wa_invites_list_copy_invite_link_fail', {})
})
}}
>
@ -152,11 +159,13 @@ export const InvitesList = ({ invites, currentUserRole }: InviteListProps) => {
toast({
description: `✅ Email copied to clipboard.`
})
captureEvent('wa_invites_list_copy_email_success', {})
})
.catch(() => {
toast({
description: `❌ Failed to copy email.`
})
captureEvent('wa_invites_list_copy_email_fail', {})
})
}}
>

View file

@ -15,6 +15,7 @@ import { transferOwnership, removeMemberFromOrg, leaveOrg } from "@/actions";
import { isServiceError } from "@/lib/utils";
import { useToast } from "@/components/hooks/use-toast";
import { useRouter } from "next/navigation";
import useCaptureEvent from "@/hooks/useCaptureEvent";
type Member = {
id: string
@ -44,6 +45,7 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
const [isTransferOwnershipDialogOpen, setIsTransferOwnershipDialogOpen] = useState(false)
const [isLeaveOrgDialogOpen, setIsLeaveOrgDialogOpen] = useState(false)
const router = useRouter();
const captureEvent = useCaptureEvent();
const filteredMembers = useMemo(() => {
return members
@ -68,10 +70,14 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
toast({
description: `❌ Failed to remove member. Reason: ${response.message}`
})
captureEvent('wa_members_list_remove_member_fail', {
error: response.errorCode,
})
} else {
toast({
description: `✅ Member removed successfully.`
})
captureEvent('wa_members_list_remove_member_success', {})
router.refresh();
}
});
@ -84,14 +90,18 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
toast({
description: `❌ Failed to transfer ownership. Reason: ${response.message}`
})
captureEvent('wa_members_list_transfer_ownership_fail', {
error: response.errorCode,
})
} else {
toast({
description: `✅ Ownership transferred successfully.`
})
captureEvent('wa_members_list_transfer_ownership_success', {})
router.refresh();
}
});
}, [domain, toast, router]);
}, [domain, toast, router, captureEvent]);
const onLeaveOrg = useCallback(() => {
leaveOrg(domain)
@ -100,10 +110,14 @@ export const MembersList = ({ members, currentUserId, currentUserRole, orgName }
toast({
description: `❌ Failed to leave organization. Reason: ${response.message}`
})
captureEvent('wa_members_list_leave_org_fail', {
error: response.errorCode,
})
} else {
toast({
description: `✅ You have left the organization.`
})
captureEvent('wa_members_list_leave_org_success', {})
router.push("/");
}
});

View file

@ -3,9 +3,16 @@
import { ENTERPRISE_FEATURES } from "@/lib/constants";
import { UpgradeCard } from "./upgradeCard";
import Link from "next/link";
import useCaptureEvent from "@/hooks/useCaptureEvent";
export const EnterpriseUpgradeCard = () => {
const captureEvent = useCaptureEvent();
const onClick = () => {
captureEvent('wa_enterprise_upgrade_card_pressed', {});
}
return (
<Link href="mailto:team@sourcebot.dev?subject=Enterprise%20Pricing%20Inquiry">
<UpgradeCard
@ -15,6 +22,7 @@ export const EnterpriseUpgradeCard = () => {
priceDescription="tailored to your needs"
features={ENTERPRISE_FEATURES}
buttonText="Contact Us"
onClick={onClick}
/>
</Link>
)

View file

@ -8,6 +8,7 @@ import { isServiceError } from "@/lib/utils";
import { useCallback, useState } from "react";
import { useRouter } from "next/navigation";
import { TEAM_FEATURES } from "@/lib/constants";
import useCaptureEvent from "@/hooks/useCaptureEvent";
interface TeamUpgradeCardProps {
buttonText: string;
@ -18,8 +19,10 @@ export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => {
const { toast } = useToast();
const router = useRouter();
const [isLoading, setIsLoading] = useState(false);
const captureEvent = useCaptureEvent();
const onClick = useCallback(() => {
captureEvent('wa_team_upgrade_card_pressed', {});
setIsLoading(true);
createStripeCheckoutSession(domain)
.then((response) => {
@ -28,14 +31,18 @@ export const TeamUpgradeCard = ({ buttonText }: TeamUpgradeCardProps) => {
description: `❌ Stripe checkout failed with error: ${response.message}`,
variant: "destructive",
});
captureEvent('wa_team_upgrade_checkout_fail', {
error: response.errorCode,
});
} else {
router.push(response.url);
captureEvent('wa_team_upgrade_checkout_success', {});
}
})
.finally(() => {
setIsLoading(false);
});
}, [domain, router, toast]);
}, [domain, router, toast, captureEvent]);
return (
<UpgradeCard

View file

@ -2,7 +2,7 @@ import type { Metadata } from "next";
import "./globals.css";
import { ThemeProvider } from "next-themes";
import { QueryClientProvider } from "./queryClientProvider";
import { PHProvider } from "./posthogProvider";
import { PostHogProvider } from "./posthogProvider";
import { Toaster } from "@/components/ui/toaster";
import { TooltipProvider } from "@/components/ui/tooltip";
import { SessionProvider } from "next-auth/react";
@ -26,7 +26,7 @@ export default function RootLayout({
<body>
<Toaster />
<SessionProvider>
<PHProvider>
<PostHogProvider>
<ThemeProvider
attribute="class"
defaultTheme="system"
@ -39,7 +39,7 @@ export default function RootLayout({
</TooltipProvider>
</QueryClientProvider>
</ThemeProvider>
</PHProvider>
</PostHogProvider>
</SessionProvider>
</body>
</html>

View file

@ -13,26 +13,35 @@ import { Loader2 } from "lucide-react"
import { useToast } from "@/components/hooks/use-toast"
import { useRouter } from "next/navigation";
import { Card } from "@/components/ui/card"
import useCaptureEvent from "@/hooks/useCaptureEvent";
const onboardingFormSchema = z.object({
name: z.string()
.min(2, { message: "Organization name must be at least 3 characters long." })
.max(30, { message: "Organization name must be at most 30 characters long." }),
domain: z.string()
.min(2, { message: "Organization domain must be at least 3 characters long." })
.max(20, { message: "Organization domain must be at most 20 characters long." })
.regex(/^[a-z][a-z-]*[a-z]$/, {
message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.",
})
.refine(async (domain) => {
const doesDomainExist = await checkIfOrgDomainExists(domain);
return isServiceError(doesDomainExist) || !doesDomainExist;
}, "This domain is already taken."),
})
export function OrgCreateForm() {
const { toast } = useToast();
const router = useRouter();
const captureEvent = useCaptureEvent();
const onboardingFormSchema = z.object({
name: z.string()
.min(2, { message: "Organization name must be at least 3 characters long." })
.max(30, { message: "Organization name must be at most 30 characters long." }),
domain: z.string()
.min(2, { message: "Organization domain must be at least 3 characters long." })
.max(20, { message: "Organization domain must be at most 20 characters long." })
.regex(/^[a-z][a-z-]*[a-z]$/, {
message: "Domain must start and end with a letter, and can only contain lowercase letters and dashes.",
})
.refine(async (domain) => {
const doesDomainExist = await checkIfOrgDomainExists(domain);
if (!isServiceError(doesDomainExist)) {
captureEvent('wa_onboard_org_create_fail', {
error: "Domain already exists",
})
}
return isServiceError(doesDomainExist) || !doesDomainExist;
}, "This domain is already taken."),
})
const form = useForm<z.infer<typeof onboardingFormSchema>>({
resolver: zodResolver(onboardingFormSchema),
defaultValues: {
@ -48,8 +57,12 @@ export function OrgCreateForm() {
toast({
description: `❌ Failed to create organization. Reason: ${response.message}`
})
captureEvent('wa_onboard_org_create_fail', {
error: response.errorCode,
})
} else {
router.push(`/${data.domain}/onboard`);
captureEvent('wa_onboard_org_create_success', {})
}
}, [router, toast]);

View file

@ -1,43 +1,71 @@
'use client'
import { NEXT_PUBLIC_POSTHOG_PAPIK, NEXT_PUBLIC_POSTHOG_UI_HOST, NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED } from '@/lib/environment.client'
import posthog from 'posthog-js'
import { PostHogProvider } from 'posthog-js/react'
import { usePostHog } from 'posthog-js/react'
import { PostHogProvider as PHProvider } from 'posthog-js/react'
import { resolveServerPath } from './api/(client)/client'
import { isDefined } from '@/lib/utils'
import { usePathname, useSearchParams } from "next/navigation"
import { useEffect, Suspense } from "react"
if (typeof window !== 'undefined') {
if (!NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED && isDefined(NEXT_PUBLIC_POSTHOG_PAPIK)) {
// @see next.config.mjs for path rewrites to the "/ingest" route.
const posthogHostPath = resolveServerPath('/ingest');
const POSTHOG_ENABLED = isDefined(NEXT_PUBLIC_POSTHOG_PAPIK) && !NEXT_PUBLIC_SOURCEBOT_TELEMETRY_DISABLED;
posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK, {
api_host: posthogHostPath,
ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST,
person_profiles: 'identified_only',
capture_pageview: false, // Disable automatic pageview capture
autocapture: false, // Disable automatic event capture
// eslint-disable-next-line @typescript-eslint/no-explicit-any
sanitize_properties: (properties: Record<string, any>, _event: string) => {
// https://posthog.com/docs/libraries/js#config
if (properties['$current_url']) {
properties['$current_url'] = null;
}
if (properties['$ip']) {
properties['$ip'] = null;
}
return properties;
function PostHogPageView() {
const pathname = usePathname()
const searchParams = useSearchParams()
const posthog = usePostHog()
// Track pageviews
useEffect(() => {
if (pathname && posthog) {
let url = window.origin + pathname
if (searchParams.toString()) {
url = url + `?${searchParams.toString()}`
}
});
} else {
console.log("PostHog telemetry disabled");
}
posthog.capture('$pageview', { '$current_url': url })
}
}, [pathname, searchParams, posthog])
return null
}
export function PHProvider({
children,
}: {
children: React.ReactNode
}) {
return <PostHogProvider client={posthog}>{children}</PostHogProvider>
export function PostHogProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
if (POSTHOG_ENABLED) {
// @see next.config.mjs for path rewrites to the "/ingest" route.
const posthogHostPath = resolveServerPath('/ingest');
posthog.init(NEXT_PUBLIC_POSTHOG_PAPIK!, {
api_host: posthogHostPath,
ui_host: NEXT_PUBLIC_POSTHOG_UI_HOST,
capture_pageview: false, // Disable automatic pageview capture
autocapture: false, // Disable automatic event capture
// eslint-disable-next-line @typescript-eslint/no-explicit-any
/* @nocheckin HANDLE SELF HOSTED CASE
person_profiles: 'identified_only',
sanitize_properties: (properties: Record<string, any>, _event: string) => {
// https://posthog.com/docs/libraries/js#config
if (properties['$current_url']) {
properties['$current_url'] = null;
}
if (properties['$ip']) {
properties['$ip'] = null;
}
return properties;
}
*/
});
} else {
console.log("PostHog telemetry disabled");
}
}, [])
return (
<PHProvider client={posthog}>
<PostHogPageView />
{children}
</PHProvider>
)
}

View file

@ -17,4 +17,5 @@ export enum ErrorCode {
OWNER_CANNOT_LEAVE_ORG = 'OWNER_CANNOT_LEAVE_ORG',
INVALID_INVITE = 'INVALID_INVITE',
STRIPE_CHECKOUT_ERROR = 'STRIPE_CHECKOUT_ERROR',
SECRET_ALREADY_EXISTS = 'SECRET_ALREADY_EXISTS',
}

View file

@ -25,6 +25,200 @@ export type PosthogEventMap = {
fileLanguages: string[]
},
share_link_created: {},
}
////////////////////////////////////////////////////////////////
wa_secret_created_success: {
key: string,
},
wa_secret_deleted_success: {
key: string,
},
wa_secret_deleted_fail: {
key: string,
error: string,
},
wa_secret_created_fail: {
key: string,
error: string,
},
wa_secret_fetch_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_warning_nav_connection_fetch_fail: {
error: string,
},
wa_warning_nav_hover: {},
wa_warning_nav_pressed: {},
wa_warning_nav_connection_pressed: {},
//////////////////////////////////////////////////////////////////
wa_error_nav_connection_fetch_fail: {
error: string,
},
wa_error_nav_hover: {},
wa_error_nav_pressed: {},
wa_error_nav_job_pressed: {},
wa_error_nav_job_fetch_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_progress_nav_connection_fetch_fail: {
error: string,
},
wa_progress_nav_job_fetch_fail: {
error: string,
},
wa_progress_nav_hover: {},
wa_progress_nav_pressed: {},
wa_progress_nav_job_pressed: {},
//////////////////////////////////////////////////////////////////
wa_trial_nav_pressed: {},
wa_trial_nav_subscription_fetch_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_connection_list_item_error_hover: {},
wa_connection_list_item_error_pressed: {},
wa_connection_list_item_warning_hover: {},
wa_connection_list_item_warning_pressed: {},
//////////////////////////////////////////////////////////////////
wa_connection_list_item_manage_pressed: {},
//////////////////////////////////////////////////////////////////
wa_create_connection_success: {
type: string,
},
wa_create_connection_fail: {
type: string,
error: string,
},
//////////////////////////////////////////////////////////////////
wa_config_editor_quick_action_pressed: {
name: string,
type: string,
},
//////////////////////////////////////////////////////////////////
wa_secret_combobox_import_secret_pressed: {
type: string,
},
wa_secret_combobox_import_secret_success: {
type: string,
},
wa_secret_combobox_import_secret_fail: {
type: string,
error: string,
},
//////////////////////////////////////////////////////////////////
wa_billing_email_updated_success: {},
wa_billing_email_updated_fail: {
error: string,
},
wa_billing_email_fetch_fail: {
error: string,
},
wa_manage_subscription_button_create_portal_session_success: {},
wa_manage_subscription_button_create_portal_session_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_invite_member_card_invite_success: {
num_emails: number,
},
wa_invite_member_card_invite_fail: {
error: string,
num_emails: number,
},
wa_invite_member_card_invite_cancel: {
num_emails: number,
},
//////////////////////////////////////////////////////////////////
wa_onboard_skip_onboarding: {
step: string,
},
wa_onboard_invite_team_invite_success: {
num_emails: number,
},
wa_onboard_invite_team_invite_fail: {
error: string,
num_emails: number,
},
wa_onboard_invite_team_skip: {
num_emails: number,
},
//////////////////////////////////////////////////////////////////
wa_members_list_remove_member_success: {},
wa_members_list_remove_member_fail: {
error: string,
},
wa_members_list_transfer_ownership_success: {},
wa_members_list_transfer_ownership_fail: {
error: string,
},
wa_members_list_leave_org_success: {},
wa_members_list_leave_org_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_invites_list_cancel_invite_success: {},
wa_invites_list_cancel_invite_fail: {
error: string,
},
wa_invites_list_copy_invite_link_success: {},
wa_invites_list_copy_invite_link_fail: {},
wa_invites_list_copy_email_success: {},
wa_invites_list_copy_email_fail: {},
//////////////////////////////////////////////////////////////////
wa_onboard_org_create_success: {},
wa_onboard_org_create_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_connect_code_host_button_pressed: {
name: string,
},
//////////////////////////////////////////////////////////////////
wa_onboard_checkout_success: {},
wa_onboard_checkout_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_team_upgrade_card_pressed: {},
wa_team_upgrade_checkout_success: {},
wa_team_upgrade_checkout_fail: {
error: string,
},
wa_enterprise_upgrade_card_pressed: {},
//////////////////////////////////////////////////////////////////
wa_connection_delete_success: {},
wa_connection_delete_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_connection_failed_status_hover: {},
wa_connection_retry_sync_success: {},
wa_connection_retry_sync_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
wa_connection_not_found_warning_displayed: {},
wa_connection_secrets_navigation_pressed: {},
//////////////////////////////////////////////////////////////////
wa_connection_retry_all_failed_repos_pressed: {},
wa_connection_retry_all_failed_repos_fetch_fail: {
error: string,
},
wa_connection_retry_all_failed_repos_fail: {
successCount: number,
failureCount: number,
},
wa_connection_retry_all_failed_repos_success: {
successCount: number,
},
wa_connection_retry_all_failed_no_repos: {},
//////////////////////////////////////////////////////////////////
wa_repo_retry_index_success: {},
wa_repo_retry_index_fail: {
error: string,
},
//////////////////////////////////////////////////////////////////
}
export type PosthogEvent = keyof PosthogEventMap;

View file

@ -99,4 +99,12 @@ export const orgInvalidSubscription = (): ServiceError => {
errorCode: ErrorCode.ORG_INVALID_SUBSCRIPTION,
message: "Invalid subscription",
}
}
export const secretAlreadyExists = (): ServiceError => {
return {
statusCode: StatusCodes.CONFLICT,
errorCode: ErrorCode.SECRET_ALREADY_EXISTS,
message: "Secret already exists",
}
}