diff --git a/crm/api/__init__.py b/crm/api/__init__.py
index 533a30c0..f0909118 100644
--- a/crm/api/__init__.py
+++ b/crm/api/__init__.py
@@ -2,6 +2,7 @@ from bs4 import BeautifulSoup
import frappe
from frappe.translate import get_all_translations
from frappe.utils import cstr
+from frappe.utils.telemetry import POSTHOG_HOST_FIELD, POSTHOG_PROJECT_FIELD
@frappe.whitelist(allow_guest=True)
@@ -44,4 +45,13 @@ def get_user_signature():
content = ""
if (cstr(_signature) or signature):
content = f'
{signature}
' - return content \ No newline at end of file + return content + +@frappe.whitelist() +def get_posthog_settings(): + return { + "posthog_project_id": frappe.conf.get(POSTHOG_PROJECT_FIELD), + "posthog_host": frappe.conf.get(POSTHOG_HOST_FIELD), + "enable_telemetry": frappe.get_system_settings("enable_telemetry"), + "telemetry_site_age": frappe.utils.telemetry.site_age(), + } \ No newline at end of file diff --git a/frontend/src/components/Activities/Activities.vue b/frontend/src/components/Activities/Activities.vue index d8e66fa5..58a67cae 100644 --- a/frontend/src/components/Activities/Activities.vue +++ b/frontend/src/components/Activities/Activities.vue @@ -437,6 +437,7 @@ import { globalStore } from '@/stores/global' import { usersStore } from '@/stores/users' import { contactsStore } from '@/stores/contacts' import { whatsappEnabled } from '@/composables/settings' +import { capture } from '@/telemetry' import { Button, Tooltip, createResource } from 'frappe-ui' import { useElementVisibility } from '@vueuse/core' import { @@ -552,6 +553,7 @@ onMounted(() => { function sendTemplate(template) { showWhatsappTemplates.value = false + capture('send_whatsapp_template', { doctype: props.doctype }) createResource({ url: 'crm.api.whatsapp.send_whatsapp_template', params: { diff --git a/frontend/src/components/Activities/WhatsAppArea.vue b/frontend/src/components/Activities/WhatsAppArea.vue index 07d8c42d..fd7509bb 100644 --- a/frontend/src/components/Activities/WhatsAppArea.vue +++ b/frontend/src/components/Activities/WhatsAppArea.vue @@ -170,9 +170,9 @@ import DoubleCheckIcon from '@/components/Icons/DoubleCheckIcon.vue' import DocumentIcon from '@/components/Icons/DocumentIcon.vue' import ReactIcon from '@/components/Icons/ReactIcon.vue' import { dateFormat } from '@/utils' +import { capture } from '@/telemetry' import { Tooltip, Dropdown, createResource } from 'frappe-ui' import { ref } from 'vue' -import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue' const props = defineProps({ messages: Array, @@ -219,6 +219,7 @@ function reactOnMessage(name, emoji) { }, auto: true, onSuccess() { + capture('whatsapp_react_on_message') list.value.reload() }, }) diff --git a/frontend/src/components/Activities/WhatsAppBox.vue b/frontend/src/components/Activities/WhatsAppBox.vue index 70f19fa9..133ad121 100644 --- a/frontend/src/components/Activities/WhatsAppBox.vue +++ b/frontend/src/components/Activities/WhatsAppBox.vue @@ -39,6 +39,7 @@ () => { content += emoji $refs.textarea.$el.focus() + capture('whatsapp_emoji_added') } " > @@ -65,8 +66,8 @@ diff --git a/frontend/src/components/CallUI.vue b/frontend/src/components/CallUI.vue index 693257de..da5c1945 100644 --- a/frontend/src/components/CallUI.vue +++ b/frontend/src/components/CallUI.vue @@ -197,6 +197,7 @@ import { Device } from '@twilio/voice-sdk' import { useDraggable, useWindowSize } from '@vueuse/core' import { globalStore } from '@/stores/global' import { contactsStore } from '@/stores/contacts' +import { capture } from '@/telemetry' import { Avatar, call } from 'frappe-ui' import { onMounted, ref, watch } from 'vue' @@ -403,6 +404,8 @@ async function makeOutgoingCall(number) { showCallPopup.value = true callStatus.value = 'initiating' + capture('make_outgoing_call') + _call.on('messageReceived', (message) => { let info = message.content callStatus.value = info.CallStatus diff --git a/frontend/src/components/CommentBox.vue b/frontend/src/components/CommentBox.vue index ebdf00c9..8b61d09b 100644 --- a/frontend/src/components/CommentBox.vue +++ b/frontend/src/components/CommentBox.vue @@ -92,6 +92,7 @@ import AttachmentIcon from '@/components/Icons/AttachmentIcon.vue' import AttachmentItem from '@/components/AttachmentItem.vue' import { usersStore } from '@/stores/users' import { TextEditorBubbleMenu, TextEditor, FileUploader } from 'frappe-ui' +import { capture } from '@/telemetry' import { EditorContent } from '@tiptap/vue-3' import { ref, computed, defineModel } from 'vue' @@ -139,6 +140,7 @@ function appendEmoji() { editor.value.commands.insertContent(emoji.value) editor.value.commands.focus() emoji.value = '' + capture('emoji_inserted_in_comment', { emoji: emoji.value }) } function removeAttachment(attachment) { diff --git a/frontend/src/components/CommunicationArea.vue b/frontend/src/components/CommunicationArea.vue index 3bab4737..384b75fc 100644 --- a/frontend/src/components/CommunicationArea.vue +++ b/frontend/src/components/CommunicationArea.vue @@ -88,6 +88,7 @@ import EmailEditor from '@/components/EmailEditor.vue' import CommentBox from '@/components/CommentBox.vue' import CommentIcon from '@/components/Icons/CommentIcon.vue' import Email2Icon from '@/components/Icons/Email2Icon.vue' +import { capture } from '@/telemetry' import { usersStore } from '@/stores/users' import { useStorage } from '@vueuse/core' import { call, createResource } from 'frappe-ui' @@ -176,6 +177,10 @@ async function sendMail() { let subject = newEmailEditor.value.subject let cc = newEmailEditor.value.ccEmails || [] let bcc = newEmailEditor.value.bccEmails || [] + + if (attachments.value.length) { + capture('email_attachments_added') + } await call('frappe.core.doctype.communication.email.make', { recipients: recipients.join(', '), attachments: attachments.value.map((x) => x.name), @@ -200,6 +205,7 @@ async function sendComment() { comment_by: getUser()?.full_name || undefined, }) if (comment && attachments.value.length) { + capture('comment_attachments_added') await call('crm.api.comment.add_attachments', { name: comment.name, attachments: attachments.value.map((x) => x.name), @@ -214,6 +220,7 @@ async function submitEmail() { newEmail.value = '' reload.value = true emit('scroll') + capture('email_sent', { doctype: props.doctype }) } async function submitComment() { @@ -223,6 +230,7 @@ async function submitComment() { newComment.value = '' reload.value = true emit('scroll') + capture('comment_sent', { doctype: props.doctype }) } function toggleEmailBox() { diff --git a/frontend/src/components/EmailEditor.vue b/frontend/src/components/EmailEditor.vue index 4e7cc5aa..b33f22df 100644 --- a/frontend/src/components/EmailEditor.vue +++ b/frontend/src/components/EmailEditor.vue @@ -175,6 +175,7 @@ import AttachmentItem from '@/components/AttachmentItem.vue' import MultiselectInput from '@/components/Controls/MultiselectInput.vue' import EmailTemplateSelectorModal from '@/components/Modals/EmailTemplateSelectorModal.vue' import { TextEditorBubbleMenu, TextEditor, FileUploader, call } from 'frappe-ui' +import { capture } from '@/telemetry' import { validateEmail } from '@/utils' import Paragraph from '@tiptap/extension-paragraph' import { EditorContent } from '@tiptap/vue-3' @@ -273,12 +274,14 @@ async function applyEmailTemplate(template) { editor.value.commands.setContent(data.message) } showEmailTemplateSelectorModal.value = false + capture('email_template_applied', { doctype: props.doctype }) } function appendEmoji() { editor.value.commands.insertContent(emoji.value) editor.value.commands.focus() emoji.value = '' + capture('emoji_inserted_in_email', { emoji: emoji.value }) } function toggleCC() { diff --git a/frontend/src/components/ListBulkActions.vue b/frontend/src/components/ListBulkActions.vue index b58c02e6..e37b36c9 100644 --- a/frontend/src/components/ListBulkActions.vue +++ b/frontend/src/components/ListBulkActions.vue @@ -21,6 +21,7 @@ import EditValueModal from '@/components/Modals/EditValueModal.vue' import AssignmentModal from '@/components/Modals/AssignmentModal.vue' import { setupListActions, createToast } from '@/utils' import { globalStore } from '@/stores/global' +import { capture } from '@/telemetry' import { call } from 'frappe-ui' import { ref, onMounted } from 'vue' import { useRouter } from 'vue-router' @@ -69,6 +70,7 @@ function convertToDeal(selections, unselectAll) { label: __('Convert'), variant: 'solid', onClick: (close) => { + capture('bulk_convert_to_deal') Array.from(selections).forEach((name) => { call('crm.fcrm.doctype.crm_lead.crm_lead.convert_to_deal', { lead: name, @@ -103,6 +105,7 @@ function deleteValues(selections, unselectAll) { variant: 'solid', theme: 'red', onClick: (close) => { + capture('bulk_delete') call('frappe.desk.reportview.delete_items', { items: JSON.stringify(Array.from(selections)), doctype: props.doctype, @@ -145,6 +148,7 @@ function clearAssignemnts(selections, unselectAll) { variant: 'solid', theme: 'red', onClick: (close) => { + capture('bulk_clear_assignment') call('frappe.desk.form.assign_to.remove_multiple', { doctype: props.doctype, names: JSON.stringify(Array.from(selections)), diff --git a/frontend/src/components/Modals/AssignmentModal.vue b/frontend/src/components/Modals/AssignmentModal.vue index c6650bf8..d11663fa 100644 --- a/frontend/src/components/Modals/AssignmentModal.vue +++ b/frontend/src/components/Modals/AssignmentModal.vue @@ -80,6 +80,7 @@ import UserAvatar from '@/components/UserAvatar.vue' import Link from '@/components/Controls/Link.vue' import { usersStore } from '@/stores/users' +import { capture } from '@/telemetry' import { Tooltip, call } from 'frappe-ui' import { ref, computed, onMounted } from 'vue' @@ -161,6 +162,7 @@ function updateAssignees() { if (addedAssignees.length) { if (props.docs.size) { + capture('bulk_assign_to', { doctype: props.doctype }) call('frappe.desk.form.assign_to.add_multiple', { doctype: props.doctype, name: JSON.stringify(Array.from(props.docs)), @@ -171,6 +173,7 @@ function updateAssignees() { emit('reload') }) } else { + capture('assign_to', { doctype: props.doctype }) call('frappe.desk.form.assign_to.add', { doctype: props.doctype, name: props.doc.name, diff --git a/frontend/src/components/Modals/ContactModal.vue b/frontend/src/components/Modals/ContactModal.vue index 36ce459a..ca412f67 100644 --- a/frontend/src/components/Modals/ContactModal.vue +++ b/frontend/src/components/Modals/ContactModal.vue @@ -92,6 +92,7 @@ import CertificateIcon from '@/components/Icons/CertificateIcon.vue' import EditIcon from '@/components/Icons/EditIcon.vue' import Dropdown from '@/components/frappe-ui/Dropdown.vue' import { usersStore } from '@/stores/users' +import { capture } from '@/telemetry' import { call, createResource } from 'frappe-ui' import { ref, nextTick, watch, computed } from 'vue' import { createToast } from '@/utils' @@ -160,7 +161,10 @@ async function callInsertDoc() { ..._contact.value, }, }) - doc.name && handleContactUpdate(doc) + if (doc.name) { + capture('contact_created') + handleContactUpdate(doc) + } } function handleContactUpdate(doc) { diff --git a/frontend/src/components/Modals/DealModal.vue b/frontend/src/components/Modals/DealModal.vue index 9988a3e8..61b89b3d 100644 --- a/frontend/src/components/Modals/DealModal.vue +++ b/frontend/src/components/Modals/DealModal.vue @@ -61,6 +61,7 @@ import EditIcon from '@/components/Icons/EditIcon.vue' import Fields from '@/components/Fields.vue' import { usersStore } from '@/stores/users' import { statusesStore } from '@/stores/statuses' +import { capture } from '@/telemetry' import { Switch, createResource } from 'frappe-ui' import { computed, ref, reactive, onMounted, nextTick } from 'vue' import { useRouter } from 'vue-router' @@ -201,6 +202,7 @@ function createDeal() { isDealCreating.value = true }, onSuccess(name) { + capture('deal_created') isDealCreating.value = false show.value = false router.push({ name: 'Deal', params: { dealId: name } }) diff --git a/frontend/src/components/Modals/EditValueModal.vue b/frontend/src/components/Modals/EditValueModal.vue index 039f4eca..9a39e3af 100644 --- a/frontend/src/components/Modals/EditValueModal.vue +++ b/frontend/src/components/Modals/EditValueModal.vue @@ -36,6 +36,7 @@ diff --git a/frontend/src/components/Settings/SidePanelModal.vue b/frontend/src/components/Settings/SidePanelModal.vue index 92d93a70..401d71cb 100644 --- a/frontend/src/components/Settings/SidePanelModal.vue +++ b/frontend/src/components/Settings/SidePanelModal.vue @@ -74,6 +74,7 @@ import Section from '@/components/Section.vue' import SectionFields from '@/components/SectionFields.vue' import SidePanelLayoutBuilder from '@/components/Settings/SidePanelLayoutBuilder.vue' import { useDebounceFn } from '@vueuse/core' +import { capture } from '@/telemetry' import { Dialog, Badge, Switch, call, createResource } from 'frappe-ui' import { ref, watch, onMounted, nextTick } from 'vue' @@ -143,6 +144,7 @@ function saveChanges() { ).then(() => { loading.value = false show.value = false + capture('side_panel_layout_builder', { doctype: _doctype.value }) emit('reload') }) } diff --git a/frontend/src/main.js b/frontend/src/main.js index 66d44ea2..b0209480 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -1,10 +1,5 @@ import './index.css' -import { createApp } from 'vue' -import router from './router' -import App from './App.vue' -import { createPinia } from 'pinia' - import { FrappeUI, Button, @@ -19,9 +14,14 @@ import { frappeRequest, FeatherIcon, } from 'frappe-ui' -import translationPlugin from './translation' +import { createApp } from 'vue' +import { createPinia } from 'pinia' import { createDialog } from './utils/dialogs' import { initSocket } from './socket' +import router from './router' +import translationPlugin from './translation' +import { posthogPlugin } from './telemetry' +import App from './App.vue' let globalComponents = { Button, @@ -45,6 +45,7 @@ app.use(FrappeUI) app.use(pinia) app.use(router) app.use(translationPlugin) +app.use(posthogPlugin) for (let key in globalComponents) { app.component(key, globalComponents[key]) } @@ -61,7 +62,7 @@ if (import.meta.env.DEV) { socket = initSocket() app.config.globalProperties.$socket = socket app.mount('#app') - } + }, ) } else { socket = initSocket() diff --git a/frontend/src/pages/Lead.vue b/frontend/src/pages/Lead.vue index d2e51893..042ba3ac 100644 --- a/frontend/src/pages/Lead.vue +++ b/frontend/src/pages/Lead.vue @@ -312,6 +312,7 @@ import { organizationsStore } from '@/stores/organizations' import { statusesStore } from '@/stores/statuses' import { usersStore } from '@/stores/users' import { whatsappEnabled, callEnabled } from '@/composables/settings' +import { capture } from '@/telemetry' import { createResource, FileUploader, @@ -587,6 +588,7 @@ async function convertToDeal(updated) { }, ) if (deal) { + capture('convert_lead_to_deal') if (updated) { await organizations.reload() await contacts.reload() diff --git a/frontend/src/stores/statuses.js b/frontend/src/stores/statuses.js index 402c0d67..040627bb 100644 --- a/frontend/src/stores/statuses.js +++ b/frontend/src/stores/statuses.js @@ -1,7 +1,8 @@ +import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue' +import { capture } from '@/telemetry' import { defineStore } from 'pinia' import { createListResource } from 'frappe-ui' import { reactive, h } from 'vue' -import IndicatorIcon from '@/components/Icons/IndicatorIcon.vue' export const statusesStore = defineStore('crm-statuses', () => { let leadStatusesByName = reactive({}) @@ -103,6 +104,7 @@ export const statusesStore = defineStore('crm-statuses', () => { class: statusesByName[status].iconColorClass, }), onClick: () => { + capture('status_changed', { doctype, status }) action && action('status', statusesByName[status].name) }, }) diff --git a/frontend/src/telemetry.ts b/frontend/src/telemetry.ts new file mode 100644 index 00000000..2a9a58b7 --- /dev/null +++ b/frontend/src/telemetry.ts @@ -0,0 +1,97 @@ +import '../../../frappe/frappe/public/js/lib/posthog.js' +import { createResource } from 'frappe-ui' +import { computed } from 'vue' + +declare global { + interface Window { + posthog: any + } +} +type PosthogSettings = { + posthog_project_id: string + posthog_host: string + enable_telemetry: boolean + telemetry_site_age: number +} +interface CaptureOptions { + data: { + user: string + [key: string]: string | number | boolean | object + } +} + +let posthog: typeof window.posthog = window.posthog + +// Posthog Settings +let posthogSettings = createResource({ + url: 'crm.api.get_posthog_settings', + cache: 'posthog_settings', + onSuccess: (ps: PosthogSettings) => initPosthog(ps), +}) + +let isTelemetryEnabled = () => { + if (!posthogSettings.data) return false + + return ( + posthogSettings.data.enable_telemetry && + posthogSettings.data.posthog_project_id && + posthogSettings.data.posthog_host + ) +} + +// Posthog Initialization +function initPosthog(ps: PosthogSettings) { + if (!isTelemetryEnabled()) return + + posthog.init(ps.posthog_project_id, { + api_host: ps.posthog_host, + person_profiles: 'identified_only', + autocapture: false, + capture_pageview: true, + capture_pageleave: true, + enable_heatmaps: false, + disable_session_recording: false, + loaded: (ph: typeof posthog) => { + window.posthog = ph + ph.identify(window.location.hostname) + }, + }) +} + +// Posthog Functions +function capture( + event: string, + options: CaptureOptions = { data: { user: '' } }, +) { + if (!isTelemetryEnabled()) return + window.posthog.capture(`crm_${event}`, options) +} + +function startRecording() { + if (!isTelemetryEnabled()) return + if (window.posthog?.__loaded) { + window.posthog.startSessionRecording() + } +} + +function stopRecording() { + if (!isTelemetryEnabled()) return + if (window.posthog?.__loaded && window.posthog.sessionRecordingStarted()) { + window.posthog.stopSessionRecording() + } +} + +// Posthog Plugin +function posthogPlugin(app: any) { + app.config.globalProperties.posthog = posthog + if (!window.posthog?.length) posthogSettings.fetch() +} + +export { + posthog, + posthogSettings, + posthogPlugin, + capture, + startRecording, + stopRecording, +}