1
0
forked from test/crm

Merge pull request #316 from shariquerik/posthog-telemetry

feat: Init posthog telemetry to analyse crm usage
This commit is contained in:
Shariq Ansari 2024-08-16 20:36:46 +05:30 committed by GitHub
commit 73d9231bbe
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
28 changed files with 209 additions and 28 deletions

View File

@ -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'<br><p class="signature">{signature}</p>'
return content
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(),
}

View File

@ -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: {

View File

@ -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()
},
})

View File

@ -39,6 +39,7 @@
() => {
content += emoji
$refs.textarea.$el.focus()
capture('whatsapp_emoji_added')
}
"
>
@ -65,8 +66,8 @@
<script setup>
import IconPicker from '@/components/IconPicker.vue'
import SmileIcon from '@/components/Icons/SmileIcon.vue'
import { capture } from '@/telemetry'
import { createResource, Textarea, FileUploader, Dropdown } from 'frappe-ui'
import FeatherIcon from 'frappe-ui/src/components/FeatherIcon.vue'
import { ref, nextTick, watch } from 'vue'
const props = defineProps({
@ -92,6 +93,7 @@ function uploadFile(file) {
whatsapp.value.attach = file.file_url
whatsapp.value.content_type = fileType.value
sendWhatsAppMessage()
capture('whatsapp_upload_file')
}
function sendTextMessage(event) {
@ -99,6 +101,7 @@ function sendTextMessage(event) {
sendWhatsAppMessage()
textarea.value.$el.blur()
content.value = ''
capture('whatsapp_send_message')
}
async function sendWhatsAppMessage() {

View File

@ -39,6 +39,8 @@
<script setup>
import AppsIcon from '@/components/Icons/AppsIcon.vue'
import { Popover, createResource } from 'frappe-ui'
import { onUnmounted } from 'vue';
import { stopRecording } from '@/telemetry';
const props = defineProps({
active: Boolean,
@ -70,4 +72,8 @@ const apps = createResource({
return _apps
},
})
onUnmounted(() => {
stopRecording()
})
</script>

View File

@ -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

View File

@ -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) {

View File

@ -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() {

View File

@ -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() {

View File

@ -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)),

View File

@ -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,

View File

@ -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) {

View File

@ -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 } })

View File

@ -36,6 +36,7 @@
<script setup>
import Link from '@/components/Controls/Link.vue'
import Autocomplete from '@/components/frappe-ui/Autocomplete.vue'
import { capture } from '@/telemetry'
import { FormControl, call, createResource, TextEditor, DatePicker } from 'frappe-ui'
import { ref, computed, onMounted, h } from 'vue'
@ -115,6 +116,7 @@ function updateValues() {
newValue.value = ''
loading.value = false
show.value = false
capture('bulk_update', { doctype: props.doctype })
emit('reload')
})
}

View File

@ -96,6 +96,7 @@
</template>
<script setup>
import { capture } from '@/telemetry'
import { Checkbox, Select, TextEditor, call } from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
@ -171,7 +172,10 @@ async function callInsertDoc() {
..._emailTemplate.value,
},
})
doc.name && handleEmailTemplateUpdate(doc)
if (doc.name) {
capture('email_template_created', { doctype: doc.reference_doctype })
handleEmailTemplateUpdate(doc)
}
}
function handleEmailTemplateUpdate(doc) {

View File

@ -46,6 +46,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 { createResource } from 'frappe-ui'
import { computed, onMounted, ref, reactive, nextTick } from 'vue'
import { useRouter } from 'vue-router'
@ -153,6 +154,7 @@ function createNewLead() {
isLeadCreating.value = true
},
onSuccess(data) {
capture('lead_created')
isLeadCreating.value = false
show.value = false
router.push({ name: 'Lead', params: { leadId: data.name } })

View File

@ -66,6 +66,7 @@
<script setup>
import ArrowUpRightIcon from '@/components/Icons/ArrowUpRightIcon.vue'
import { capture } from '@/telemetry'
import { TextEditor, call } from 'frappe-ui'
import { ref, nextTick, watch } from 'vue'
import { useRouter } from 'vue-router'
@ -124,6 +125,7 @@ async function updateNote() {
},
})
if (d.name) {
capture('note_created')
notes.value?.reload()
emit('after', d, true)
}

View File

@ -67,6 +67,7 @@ import OrganizationsIcon from '@/components/Icons/OrganizationsIcon.vue'
import TerritoryIcon from '@/components/Icons/TerritoryIcon.vue'
import { usersStore } from '@/stores/users'
import { formatNumberIntoCurrency } from '@/utils'
import { capture } from '@/telemetry'
import { call, FeatherIcon, createResource } from 'frappe-ui'
import { ref, nextTick, watch, computed, h } from 'vue'
import { useRouter } from 'vue-router'
@ -157,7 +158,10 @@ async function callInsertDoc() {
},
})
loading.value = false
doc.name && handleOrganizationUpdate(doc)
if (doc.name) {
capture('organization_created')
handleOrganizationUpdate(doc)
}
}
function handleOrganizationUpdate(doc, renamed = false) {

View File

@ -118,6 +118,7 @@ import UserAvatar from '@/components/UserAvatar.vue'
import Link from '@/components/Controls/Link.vue'
import { taskStatusOptions, taskPriorityOptions } from '@/utils'
import { usersStore } from '@/stores/users'
import { capture } from '@/telemetry'
import { TextEditor, Dropdown, Tooltip, call, DateTimePicker } from 'frappe-ui'
import { ref, watch, nextTick, onMounted } from 'vue'
import { useRouter } from 'vue-router'
@ -199,6 +200,7 @@ async function updateTask() {
},
})
if (d.name) {
capture('task_created')
tasks.value.reload()
}
}

View File

@ -75,7 +75,6 @@ const templates = createListResource({
filters: { status: 'APPROVED', for_doctype: ['in', [props.doctype, '']] },
orderBy: 'modified desc',
pageLength: 99999,
auto: true,
})
onMounted(() => {

View File

@ -18,10 +18,7 @@
<div class="flex gap-1">
<Tooltip :text="__('Mark all as read')">
<div>
<Button
variant="ghost"
@click="() => notificationsStore().mark_as_read.reload()"
>
<Button variant="ghost" @click="() => markAllAsRead()">
<template #icon>
<MarkAsDoneIcon class="h-4 w-4" />
</template>
@ -48,7 +45,7 @@
:key="n.comment"
:to="getRoute(n)"
class="flex cursor-pointer items-start gap-2.5 px-4 py-2.5 hover:bg-gray-100"
@click="mark_as_read(n.comment || n.notification_type_doc)"
@click="markAsRead(n.comment || n.notification_type_doc)"
>
<div class="mt-1 flex items-center gap-2.5">
<div
@ -98,6 +95,7 @@ import { notificationsStore } from '@/stores/notifications'
import { globalStore } from '@/stores/global'
import { timeAgo } from '@/utils'
import { onClickOutside } from '@vueuse/core'
import { capture } from '@/telemetry'
import { Tooltip } from 'frappe-ui'
import { ref, onMounted, onBeforeUnmount } from 'vue'
@ -113,17 +111,23 @@ onClickOutside(
},
{
ignore: ['#notifications-btn'],
}
},
)
function toggleNotificationPanel() {
notificationsStore().toggle()
}
function mark_as_read(doc) {
function markAsRead(doc) {
capture('notification_mark_as_read')
notificationsStore().mark_doc_as_read(doc)
}
function markAllAsRead() {
capture('notification_mark_all_as_read')
notificationsStore().mark_as_read.reload()
}
onBeforeUnmount(() => {
$socket.off('crm_notification')
})

View File

@ -5,7 +5,9 @@
:key="s.label"
class="flex items-center gap-2 text-base leading-5"
>
<div class="sm:w-[106px] w-36 text-sm text-gray-600">{{ __(s.label) }}</div>
<div class="sm:w-[106px] w-36 text-sm text-gray-600">
{{ __(s.label) }}
</div>
<div class="grid min-h-[28px] items-center">
<Tooltip v-if="s.tooltipText" :text="__(s.tooltipText)">
<div class="ml-2 cursor-pointer">
@ -43,6 +45,7 @@
import { Dropdown, Tooltip } from 'frappe-ui'
import { timeAgo, dateFormat, formatTime, dateTooltipFormat } from '@/utils'
import { statusesStore } from '@/stores/statuses'
import { capture } from '@/telemetry'
import { computed, defineModel } from 'vue'
const data = defineModel()
@ -58,8 +61,8 @@ let slaSection = computed(() => {
data.value.sla_status == 'Failed'
? 'red'
: data.value.sla_status == 'Fulfilled'
? 'green'
: 'orange'
? 'green'
: 'orange'
if (status == 'First Response Due') {
status = timeAgo(data.value.response_by)
@ -94,11 +97,13 @@ let slaSection = computed(() => {
options: communicationStatuses.data?.map((status) => ({
label: status.name,
value: status.name,
onClick: () =>
emit('updateField', 'communication_status', status.name),
onClick: () => {
capture('sla_status_change')
emit('updateField', 'communication_status', status.name)
},
})),
},
]
],
)
return sections
})

View File

@ -56,6 +56,7 @@
import Fields from '@/components/Fields.vue'
import QuickEntryLayoutBuilder from '@/components/Settings/QuickEntryLayoutBuilder.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'
@ -122,6 +123,7 @@ function saveChanges() {
).then(() => {
loading.value = false
show.value = false
capture('quick_entry_layout_builder', { doctype: _doctype.value })
})
}
</script>

View File

@ -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')
})
}

View File

@ -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()

View File

@ -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()

View File

@ -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)
},
})

97
frontend/src/telemetry.ts Normal file
View File

@ -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,
}