Skip to content

add posthog events on various user actions #208

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 4 commits into from
Feb 25, 2025
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
23 changes: 21 additions & 2 deletions packages/backend/src/connectionManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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>;
Expand All @@ -22,6 +23,10 @@ type JobPayload = {
config: ConnectionConfig,
};

type JobResult = {
repoCount: number
}

export class ConnectionManager implements IConnectionManager {
private worker: Worker;
private queue: Queue<JobPayload>;
Expand Down Expand Up @@ -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;

Expand All @@ -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 }
Expand Down
19 changes: 11 additions & 8 deletions packages/backend/src/posthogEvents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
9 changes: 1 addition & 8 deletions packages/backend/src/repoManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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>) {
Expand Down
17 changes: 15 additions & 2 deletions packages/web/src/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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";
Expand Down Expand Up @@ -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,
Expand Down
13 changes: 10 additions & 3 deletions packages/web/src/app/[domain]/components/configEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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>[],
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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"],
Expand Down Expand Up @@ -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"
Expand Down Expand Up @@ -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),
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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),
Expand All @@ -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),
Expand All @@ -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);
Expand Down Expand Up @@ -168,6 +176,9 @@ export default function SharedConnectionCreationForm<T>({
}
}
},
captureEvent,
"set-secret",
type,
form.getValues("config"),
view,
{
Expand All @@ -193,6 +204,7 @@ export default function SharedConnectionCreationForm<T>({
<FormControl>
<ConfigEditor<T>
ref={editorRef}
type={type}
value={value}
onChange={onConfigChange}
actions={quickActions ?? []}
Expand Down
Loading