mirror of
https://github.com/hcengineering/platform.git
synced 2026-08-17 18:05:42 +02:00
Returned analytics collector with PostHog integration (#9431)
Signed-off-by: Alexander Platov <alexander.platov@hardcoreeng.com>
This commit is contained in:
Generated
+79
-18
@@ -109,6 +109,9 @@ importers:
|
||||
'@rush-temp/analytics-collector-resources':
|
||||
specifier: file:./projects/analytics-collector-resources.tgz
|
||||
version: file:projects/analytics-collector-resources.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(postcss-load-config@4.0.2(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(postcss@8.5.3)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
'@rush-temp/analytics-providers':
|
||||
specifier: file:./projects/analytics-providers.tgz
|
||||
version: file:projects/analytics-providers.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(svelte@4.2.19)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
'@rush-temp/analytics-service':
|
||||
specifier: file:./projects/analytics-service.tgz
|
||||
version: file:projects/analytics-service.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
@@ -807,7 +810,7 @@ importers:
|
||||
version: file:projects/pod-ai-bot.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(encoding@0.1.13)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)(zod@3.24.2)
|
||||
'@rush-temp/pod-analytics-collector':
|
||||
specifier: file:./projects/pod-analytics-collector.tgz
|
||||
version: file:projects/pod-analytics-collector.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)
|
||||
version: file:projects/pod-analytics-collector.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))
|
||||
'@rush-temp/pod-backup':
|
||||
specifier: file:./projects/pod-backup.tgz
|
||||
version: file:projects/pod-backup.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))
|
||||
@@ -2233,6 +2236,9 @@ importers:
|
||||
typescript:
|
||||
specifier: ^5.8.3
|
||||
version: 5.8.3
|
||||
ua-parser-js:
|
||||
specifier: ^2.0.4
|
||||
version: 2.0.4(encoding@0.1.13)
|
||||
update-browserslist-db:
|
||||
specifier: ^1.1.3
|
||||
version: 1.1.3(browserslist@4.25.0)
|
||||
@@ -3949,6 +3955,10 @@ packages:
|
||||
resolution: {integrity: sha512-PymckPRCfAQsFCV/Wl2gI5DXgQUhBhfJxxX1eC03dRkXOORf1+FXXOE/YnHZFSr6tsA8x1EKaoD6OxfGLxjIwg==, tarball: file:projects/analytics-collector.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/analytics-providers@file:projects/analytics-providers.tgz':
|
||||
resolution: {integrity: sha512-lxtnwgE6v5e/x0DTmAyMeOapNOwL4m91+KOgTIe5DWDUJigxi8c6jUFkvTVvgcWP7Jd+Dfmb2t6GhsBMlMBJPg==, tarball: file:projects/analytics-providers.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/analytics-service@file:projects/analytics-service.tgz':
|
||||
resolution: {integrity: sha512-tyWWPUNVizFTlaQZ7Qnx8IMZqQV4aaK+hV4mscCSz5HJleb95mfWEVkeDdeseahfUhO6I5yBwUfbyAsFIKmWhw==, tarball: file:projects/analytics-service.tgz}
|
||||
version: 0.0.0
|
||||
@@ -4206,7 +4216,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/desktop@file:projects/desktop.tgz':
|
||||
resolution: {integrity: sha512-IvqscPK0lZ/tLl+8d36IQMcsbPTUxuPvXOePpDpIc66Meli/X8Dmg/IVCvMRtYls64bDL389/+aaW5FcluDi6A==, tarball: file:projects/desktop.tgz}
|
||||
resolution: {integrity: sha512-VqYfpEML7x0UunCGG5DzQaRWyrTPlfazb33/1O3HtQJWutFSqZOwTrqZlOjaK9JASToMcjZWn3QVvS0r4NzJkQ==, tarball: file:projects/desktop.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/devmodel-resources@file:projects/devmodel-resources.tgz':
|
||||
@@ -4530,7 +4540,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-desktop-downloads@file:projects/model-desktop-downloads.tgz':
|
||||
resolution: {integrity: sha512-3LbhVzZdxmA3pCoSO8fhFT8zJIKSA+v6DUD1j5eAy75cF+/tO55X6V3+LaE2J310G6bHnmfaFxtMpkw/8Q7sxQ==, tarball: file:projects/model-desktop-downloads.tgz}
|
||||
resolution: {integrity: sha512-6qbZTuD0EjH5vsI8AKDRQtzTco/1es7tldQrdJGsicRLbRoOzhxSBSJJQWhxVbT2GJCKebqpWzOz9DfWvUczug==, tarball: file:projects/model-desktop-downloads.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/model-desktop-preferences@file:projects/model-desktop-preferences.tgz':
|
||||
@@ -4882,7 +4892,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-analytics-collector@file:projects/pod-analytics-collector.tgz':
|
||||
resolution: {integrity: sha512-7luEBtyB/4w0arhvNzyhRrYAbJ+XSLNagit2nrKKgQumKLdf7uw7o3n6i0QcMYnTI8CPjcq2V/w8ScuvVJswZA==, tarball: file:projects/pod-analytics-collector.tgz}
|
||||
resolution: {integrity: sha512-YsXK0lM7Spe4gaV61mzcLnesPCnB3kxryetFQJEFTNEh+azKvSQTXGvyozSPJL8OriZDNMC/NqsqXqO2/fFLPg==, tarball: file:projects/pod-analytics-collector.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/pod-backup@file:projects/pod-backup.tgz':
|
||||
@@ -5038,7 +5048,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/prod@file:projects/prod.tgz':
|
||||
resolution: {integrity: sha512-T4g4IfjD/0Op9iqtGgWixhNMmdGJc+pKl/errCAuujrH+uoyToKqDpjOFwNQMBVKjuz04IRs9tBhCSm1tJsx+g==, tarball: file:projects/prod.tgz}
|
||||
resolution: {integrity: sha512-1T4lh2EXGJzKr9TVDZjM43NGxyRvl3urz5oLbnGVCCHranalzlpfl4eN56DMxKfBvd0wG0KPi7lPWRdJlFL4WA==, tarball: file:projects/prod.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/products-assets@file:projects/products-assets.tgz':
|
||||
@@ -5558,7 +5568,7 @@ packages:
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/text-editor-resources@file:projects/text-editor-resources.tgz':
|
||||
resolution: {integrity: sha512-0FuTY6/95g1xMR1QGgnj3l+pDR0F1GAwZxosX6WwUunaGTVqIpZHz+F4OgKLsejWFI2HvvUpd0a9nwvSR7AE1A==, tarball: file:projects/text-editor-resources.tgz}
|
||||
resolution: {integrity: sha512-l8BWqWjeXzK677ub/0+a+EP+o0GKpibkWOFiXj36FQRqZ7T+pkmgdWvs3ujkIjyTdmTrCHxDbk+hrd51S4NwfA==, tarball: file:projects/text-editor-resources.tgz}
|
||||
version: 0.0.0
|
||||
|
||||
'@rush-temp/text-editor@file:projects/text-editor.tgz':
|
||||
@@ -8302,6 +8312,9 @@ packages:
|
||||
resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==}
|
||||
engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16}
|
||||
|
||||
detect-europe-js@0.1.2:
|
||||
resolution: {integrity: sha512-lgdERlL3u0aUdHocoouzT10d9I89VVhk0qNRmll7mXdGfJT1/wqZ2ZLA4oJAjeACPY5fT1wsbq2AT+GkuInsow==}
|
||||
|
||||
detect-indent@6.1.0:
|
||||
resolution: {integrity: sha512-reYkTUJAZb9gUuZ2RvVCNhVHdg62RHnJ7WJl8ftMi4diZ6NWlciOzQN88pUhSELEwflJht4oQDv0F0BMlwaYtA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -9973,6 +9986,9 @@ packages:
|
||||
resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
is-standalone-pwa@0.1.1:
|
||||
resolution: {integrity: sha512-9Cbovsa52vNQCjdXOzeQq5CnCbAcRk05aU62K20WO372NrTv0NxibLFCK6lQ4/iZEFdEA3p3t2VNOn8AJ53F5g==}
|
||||
|
||||
is-stream@1.1.0:
|
||||
resolution: {integrity: sha512-uQPm8kcs47jx38atAcWTVxyltQYoPT68y9aWYdV6yWXSyW8mzSat0TL6CiWdZeCdF3KrAvpVtnHbTv4RN+rqdQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
@@ -13289,6 +13305,13 @@ packages:
|
||||
engines: {node: '>=14.17'}
|
||||
hasBin: true
|
||||
|
||||
ua-is-frozen@0.1.2:
|
||||
resolution: {integrity: sha512-RwKDW2p3iyWn4UbaxpP2+VxwqXh0jpvdxsYpZ5j/MLLiQOfbsV5shpgQiw93+KMYQPcteeMQ289MaAFzs3G9pw==}
|
||||
|
||||
ua-parser-js@2.0.4:
|
||||
resolution: {integrity: sha512-XiBOnM/UpUq21ZZ91q2AVDOnGROE6UQd37WrO9WBgw4u2eGvUCNOheMmZ3EfEUj7DLHr8tre+Um/436Of/Vwzg==}
|
||||
hasBin: true
|
||||
|
||||
uc.micro@2.0.0:
|
||||
resolution: {integrity: sha512-DffL94LsNOccVn4hyfRe5rdKa273swqeA5DJpMOeFmEn1wCDc7nAbbB0gXlgBCL7TNzeTv6G7XVWzan7iJtfig==}
|
||||
|
||||
@@ -16504,6 +16527,39 @@ snapshots:
|
||||
- svelte
|
||||
- ts-node
|
||||
|
||||
'@rush-temp/analytics-providers@file:projects/analytics-providers.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(encoding@0.1.13)(svelte@4.2.19)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@sentry/svelte': 9.22.0(svelte@4.2.19)
|
||||
'@types/jest': 29.5.12
|
||||
'@types/node': 22.15.29
|
||||
'@typescript-eslint/eslint-plugin': 6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3)
|
||||
'@typescript-eslint/parser': 6.21.0(eslint@8.56.0)(typescript@5.8.3)
|
||||
cross-env: 7.0.3
|
||||
esbuild: 0.24.2
|
||||
eslint: 8.56.0
|
||||
eslint-config-standard-with-typescript: 40.0.0(@typescript-eslint/eslint-plugin@6.21.0(@typescript-eslint/parser@6.21.0(eslint@8.56.0)(typescript@5.8.3))(eslint@8.56.0)(typescript@5.8.3))(eslint-plugin-import@2.29.1(eslint@8.56.0))(eslint-plugin-n@15.7.0(eslint@8.56.0))(eslint-plugin-promise@6.1.1(eslint@8.56.0))(eslint@8.56.0)(typescript@5.8.3)
|
||||
eslint-plugin-import: 2.29.1(eslint@8.56.0)
|
||||
eslint-plugin-n: 15.7.0(eslint@8.56.0)
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
|
||||
jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
posthog-js: 1.246.0
|
||||
prettier: 3.2.5
|
||||
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
ua-parser-js: 2.0.4(encoding@0.1.13)
|
||||
transitivePeerDependencies:
|
||||
- '@babel/core'
|
||||
- '@jest/types'
|
||||
- '@rrweb/types'
|
||||
- babel-jest
|
||||
- babel-plugin-macros
|
||||
- encoding
|
||||
- node-notifier
|
||||
- rrweb-snapshot
|
||||
- supports-color
|
||||
- svelte
|
||||
- ts-node
|
||||
|
||||
'@rush-temp/analytics-service@file:projects/analytics-service.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(@types/node@22.15.29)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))':
|
||||
dependencies:
|
||||
'@sentry/node': 9.22.0
|
||||
@@ -22322,7 +22378,7 @@ snapshots:
|
||||
- utf-8-validate
|
||||
- zod
|
||||
|
||||
'@rush-temp/pod-analytics-collector@file:projects/pod-analytics-collector.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(bufferutil@4.0.8)(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)(utf-8-validate@6.0.4)':
|
||||
'@rush-temp/pod-analytics-collector@file:projects/pod-analytics-collector.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))':
|
||||
dependencies:
|
||||
'@tsconfig/node16': 1.0.4
|
||||
'@types/cors': 2.8.17
|
||||
@@ -22342,30 +22398,19 @@ snapshots:
|
||||
eslint-plugin-promise: 6.1.1(eslint@8.56.0)
|
||||
express: 4.21.2
|
||||
jest: 29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3))
|
||||
mongodb: 6.16.0(gcp-metadata@5.3.0(encoding@0.1.13))(snappy@7.2.2)(socks@2.8.3)
|
||||
prettier: 3.2.5
|
||||
puppeteer: 22.14.0(bufferutil@4.0.8)(typescript@5.8.3)(utf-8-validate@6.0.4)
|
||||
ts-jest: 29.1.2(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))(esbuild@0.24.2)(jest@29.7.0(@types/node@22.15.29)(ts-node@10.9.2(@types/node@22.15.29)(typescript@5.8.3)))(typescript@5.8.3)
|
||||
ts-node: 10.9.2(@types/node@22.15.29)(typescript@5.8.3)
|
||||
typescript: 5.8.3
|
||||
transitivePeerDependencies:
|
||||
- '@aws-sdk/credential-providers'
|
||||
- '@babel/core'
|
||||
- '@jest/types'
|
||||
- '@mongodb-js/zstd'
|
||||
- '@swc/core'
|
||||
- '@swc/wasm'
|
||||
- babel-jest
|
||||
- babel-plugin-macros
|
||||
- bufferutil
|
||||
- gcp-metadata
|
||||
- kerberos
|
||||
- mongodb-client-encryption
|
||||
- node-notifier
|
||||
- snappy
|
||||
- socks
|
||||
- supports-color
|
||||
- utf-8-validate
|
||||
|
||||
'@rush-temp/pod-backup@file:projects/pod-backup.tgz(@babel/core@7.23.9)(@jest/types@29.6.3)(babel-jest@29.7.0(@babel/core@7.23.9))':
|
||||
dependencies:
|
||||
@@ -31516,6 +31561,8 @@ snapshots:
|
||||
|
||||
destroy@1.2.0: {}
|
||||
|
||||
detect-europe-js@0.1.2: {}
|
||||
|
||||
detect-indent@6.1.0: {}
|
||||
|
||||
detect-libc@2.0.2: {}
|
||||
@@ -33573,6 +33620,8 @@ snapshots:
|
||||
dependencies:
|
||||
call-bind: 1.0.7
|
||||
|
||||
is-standalone-pwa@0.1.1: {}
|
||||
|
||||
is-stream@1.1.0: {}
|
||||
|
||||
is-stream@2.0.1: {}
|
||||
@@ -37461,6 +37510,18 @@ snapshots:
|
||||
|
||||
typescript@5.8.3: {}
|
||||
|
||||
ua-is-frozen@0.1.2: {}
|
||||
|
||||
ua-parser-js@2.0.4(encoding@0.1.13):
|
||||
dependencies:
|
||||
'@types/node-fetch': 2.6.12
|
||||
detect-europe-js: 0.1.2
|
||||
is-standalone-pwa: 0.1.1
|
||||
node-fetch: 2.7.0(encoding@0.1.13)
|
||||
ua-is-frozen: 0.1.2
|
||||
transitivePeerDependencies:
|
||||
- encoding
|
||||
|
||||
uc.micro@2.0.0: {}
|
||||
|
||||
ufo@1.5.4: {}
|
||||
|
||||
@@ -213,6 +213,7 @@
|
||||
"@hcengineering/process": "^0.6.0",
|
||||
"@hcengineering/process-assets": "^0.6.0",
|
||||
"@hcengineering/process-resources": "^0.6.0",
|
||||
"@hcengineering/analytics-providers": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"@hcengineering/analytics-collector-assets": "^0.6.0",
|
||||
"@hcengineering/analytics-collector-resources": "^0.6.0",
|
||||
|
||||
@@ -136,6 +136,7 @@ import textEditor, { textEditorId } from '@hcengineering/text-editor'
|
||||
|
||||
import { initThemeStore, setDefaultLanguage } from '@hcengineering/theme'
|
||||
import { configureNotifications } from './notifications'
|
||||
import { configureAnalyticsProviders } from '@hcengineering/analytics-providers'
|
||||
import { Branding, Config, IPCMainExposed } from './types'
|
||||
|
||||
import github, { githubId } from '@hcengineering/github'
|
||||
@@ -259,6 +260,8 @@ export async function configurePlatform (): Promise<void> {
|
||||
const title = myBranding.title ?? 'Huly Desktop'
|
||||
ipcMain.setTitle(title)
|
||||
|
||||
configureAnalyticsProviders(config)
|
||||
|
||||
setMetadata(login.metadata.AccountsUrl, config.ACCOUNTS_URL)
|
||||
setMetadata(login.metadata.DisableSignUp, config.DISABLE_SIGNUP === 'true')
|
||||
setMetadata(login.metadata.HideLocalLogin, config.HIDE_LOCAL_LOGIN === 'true')
|
||||
|
||||
@@ -9,6 +9,9 @@ export interface Config {
|
||||
ACCOUNTS_URL: string
|
||||
AI_URL?: string
|
||||
ANALYTICS_COLLECTOR_URL?: string
|
||||
POSTHOG_API_KEY?: string
|
||||
POSTHOG_HOST?: string
|
||||
SENTRY_DSN?: string
|
||||
BRANDING_URL?: string
|
||||
CALENDAR_URL: string
|
||||
COLLABORATOR?: string
|
||||
@@ -28,9 +31,9 @@ export interface Config {
|
||||
LINK_PREVIEW_URL?: string
|
||||
LIVEKIT_WS?: string
|
||||
LOVE_ENDPOINT?: string
|
||||
MODEL_VERSION?: string
|
||||
MODEL_VERSION: string
|
||||
PRESENCE_URL?: string
|
||||
PREVIEW_CONFIG: string
|
||||
PREVIEW_CONFIG?: string
|
||||
PRINT_URL?: string
|
||||
PUSH_PUBLIC_KEY: string
|
||||
REKONI_URL: string
|
||||
@@ -40,7 +43,7 @@ export interface Config {
|
||||
TELEGRAM_URL: string
|
||||
UPLOAD_CONFIG: string
|
||||
UPLOAD_URL: string
|
||||
VERSION?: string
|
||||
VERSION: string
|
||||
STREAM_URL?: string
|
||||
BACKUP_URL?: string
|
||||
PUBLIC_SCHEDULE_URL?: string
|
||||
@@ -49,6 +52,7 @@ export interface Config {
|
||||
MAIL_URL?: string
|
||||
COMMUNICATION_API_ENABLED?: string
|
||||
BILLING_URL?: string
|
||||
PASSWORD_STRICTNESS?: 'very_strict' | 'strict' | 'normal' | 'none'
|
||||
}
|
||||
|
||||
export interface Branding {
|
||||
|
||||
@@ -385,6 +385,21 @@ services:
|
||||
# - STATS_URL=http://huly.local:4900
|
||||
# # - LOVE_ENDPOINT=http://huly.local:8096
|
||||
# # - OPENAI_API_KEY=token
|
||||
# analytics:
|
||||
# image: hardcoreeng/analytics-collector
|
||||
# extra_hosts:
|
||||
# - 'huly.local:host-gateway'
|
||||
# restart: unless-stopped
|
||||
# ports:
|
||||
# - 4017:4017
|
||||
# environment:
|
||||
# - SECRET=secret
|
||||
# - PORT=4017
|
||||
# - SERVICE_ID=analytics-collector-service
|
||||
# - ACCOUNTS_URL=http://huly.local:3000
|
||||
# - STATS_URL=http://huly.local:4900
|
||||
# - POSTHOG_HOST=${POSTHOG_HOST}
|
||||
# - POSTHOG_API_KEY=${POSTHOG_API_KEY}
|
||||
msg2file:
|
||||
image: hardcoreeng/msg2file
|
||||
ports:
|
||||
|
||||
@@ -63,6 +63,7 @@
|
||||
"@hcengineering/ai-bot": "^0.6.0",
|
||||
"@hcengineering/ai-bot-resources": "^0.6.0",
|
||||
"@hcengineering/analytics": "^0.6.0",
|
||||
"@hcengineering/analytics-providers": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"@hcengineering/analytics-collector-assets": "^0.6.0",
|
||||
"@hcengineering/analytics-collector-resources": "^0.6.0",
|
||||
|
||||
@@ -2,17 +2,9 @@
|
||||
// Copyright © 2024 Hardcore Engineering Inc
|
||||
//
|
||||
|
||||
import { type AnalyticProvider, Analytics } from "@hcengineering/analytics"
|
||||
import { PosthogAnalyticProvider } from "./analytics/posthog"
|
||||
import { SentryAnalyticProvider } from "./analytics/sentry"
|
||||
import { configureAnalyticsProviders } from "@hcengineering/analytics-providers"
|
||||
import { type Config } from "./platform"
|
||||
|
||||
export function configureAnalytics (config: Config) {
|
||||
const providers: AnalyticProvider[] = [
|
||||
new SentryAnalyticProvider,
|
||||
new PosthogAnalyticProvider
|
||||
]
|
||||
for (const provider of providers) {
|
||||
Analytics.init(provider, config)
|
||||
}
|
||||
configureAnalyticsProviders(config)
|
||||
}
|
||||
@@ -1,113 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type AnalyticProvider } from '@hcengineering/analytics'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { type AnalyticEvent, AnalyticEventType } from '@hcengineering/analytics-collector'
|
||||
|
||||
import { type Config } from '../platform'
|
||||
|
||||
export class AnalyticsCollectorProvider implements AnalyticProvider {
|
||||
private readonly collectIntervalMs = 5000
|
||||
|
||||
private readonly events: AnalyticEvent[] = []
|
||||
|
||||
private url: string = ''
|
||||
|
||||
init(config: Config): boolean {
|
||||
this.url = config.ANALYTICS_COLLECTOR_URL
|
||||
if (this.url !== undefined && this.url !== '' && this.url !== null) {
|
||||
setInterval(() => {
|
||||
void this.sendEvents()
|
||||
}, this.collectIntervalMs)
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
async sendEvents(): Promise<void> {
|
||||
const data = this.events.splice(0, this.events.length)
|
||||
|
||||
if(data.length === 0) {
|
||||
return
|
||||
}
|
||||
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
|
||||
if (token === '') {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await fetch(`${this.url}/collect`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(data)
|
||||
})
|
||||
}catch(err) {
|
||||
console.error('Failed to send events', err)
|
||||
}
|
||||
}
|
||||
|
||||
setUser(email: string): void {
|
||||
this.events.push({
|
||||
event: AnalyticEventType.SetUser,
|
||||
params: { email },
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
setTag(key: string, value: string): void {
|
||||
this.events.push({
|
||||
event: AnalyticEventType.SetTag,
|
||||
params: { key, value },
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
setWorkspace(ws: string): void {
|
||||
this.setTag('workspace', ws)
|
||||
}
|
||||
|
||||
handleEvent(event: string, params: Record<string, string>): void {
|
||||
this.events.push({
|
||||
event: AnalyticEventType.CustomEvent,
|
||||
params: { ...params, event },
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
handleError(error: Error): void {
|
||||
this.events.push({
|
||||
event: AnalyticEventType.Error,
|
||||
params: { error },
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
navigate(path: string): void {
|
||||
this.events.push({
|
||||
event: AnalyticEventType.Navigation,
|
||||
params: { path },
|
||||
timestamp: Date.now()
|
||||
})
|
||||
}
|
||||
|
||||
logout(): void {}
|
||||
}
|
||||
@@ -1,44 +0,0 @@
|
||||
import { type AnalyticProvider } from "@hcengineering/analytics"
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
export class PosthogAnalyticProvider implements AnalyticProvider {
|
||||
init(config: Record<string, any>): boolean {
|
||||
if (config.POSTHOG_API_KEY !== undefined && config.POSTHOG_API_KEY !== '' && config.POSTHOG_HOST !== null) {
|
||||
posthog.init(config.POSTHOG_API_KEY, {
|
||||
api_host: config.POSTHOG_HOST,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setUser(email: string): void {
|
||||
if (!posthog._isIdentified()) {
|
||||
posthog.identify(email, { email: email })
|
||||
}
|
||||
}
|
||||
setTag(key: string, value: string): void {
|
||||
posthog.setPersonProperties({ [key]: value })
|
||||
}
|
||||
setWorkspace(ws: string): void {
|
||||
this.setTag('workspace', ws)
|
||||
posthog.group('workspace', ws, {
|
||||
name: `${ws}`
|
||||
})
|
||||
}
|
||||
logout(): void {
|
||||
posthog.reset()
|
||||
}
|
||||
handleEvent(event: string, params: Record<string, any>): void {
|
||||
posthog.capture(event, params)
|
||||
}
|
||||
handleError(error: Error): void {
|
||||
posthog.capture(error.message)
|
||||
}
|
||||
navigate(path: string): void {
|
||||
posthog.capture('$pageview')
|
||||
}
|
||||
}
|
||||
@@ -1,49 +0,0 @@
|
||||
import { type AnalyticProvider } from "@hcengineering/analytics"
|
||||
import * as Sentry from "@sentry/svelte"
|
||||
|
||||
export class SentryAnalyticProvider implements AnalyticProvider {
|
||||
navigate (path: string): void {}
|
||||
init (config: Record<string, any>): boolean {
|
||||
if (config.SENTRY_DSN !== undefined && config.SENTRY_DSN !== '') {
|
||||
Sentry.init({
|
||||
dsn: config.SENTRY_DSN,
|
||||
integrations: [Sentry.browserTracingIntegration(), Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false
|
||||
})],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
tracePropagationTargets: [/^https:\/\/huly\.app/, /^https:\/\/app\.huly\.io/, /^https:\/\/account\.huly\.io/],
|
||||
|
||||
replaysSessionSampleRate: 0.0,
|
||||
replaysOnErrorSampleRate: 1.0,
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setUser(email: string): void {
|
||||
Sentry.setUser({ email })
|
||||
}
|
||||
logout(): void {
|
||||
Sentry.setUser(null)
|
||||
}
|
||||
setTag(key: string, value: string): void {
|
||||
Sentry.setTag(key, value)
|
||||
}
|
||||
setWorkspace(ws: string): void {
|
||||
this.setTag('workspace', ws)
|
||||
}
|
||||
handleEvent(event: string): void {
|
||||
// currently we don't need it, but maybe in future
|
||||
// Sentry.captureMessage(event, 'log')
|
||||
}
|
||||
handleError(error: Error): void {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
@@ -229,7 +229,7 @@ const configs: Record<string, string> = {
|
||||
'dev-worker-local': '/config-worker-local.json'
|
||||
}
|
||||
|
||||
const PASSWORD_REQUIREMENTS: Record<Config['PASSWORD_STRICTNESS'], Record<string, number>> = {
|
||||
const PASSWORD_REQUIREMENTS: Record<NonNullable<Config['PASSWORD_STRICTNESS']>, Record<string, number>> = {
|
||||
very_strict: {
|
||||
MinDigits: 4,
|
||||
MinLength: 32,
|
||||
@@ -389,7 +389,7 @@ export async function configurePlatform() {
|
||||
})
|
||||
configureI18n()
|
||||
|
||||
const config: Config = await loadServerConfig(configs[clientType] ?? '/config.json')
|
||||
const config: Config = await loadServerConfig(configs[clientType ?? ''] ?? '/config.json')
|
||||
const branding: BrandingMap =
|
||||
config.BRANDING_URL !== undefined ? await (await fetch(config.BRANDING_URL, { keepalive: true })).json() : {}
|
||||
const myBranding = branding[window.location.host] ?? {}
|
||||
@@ -440,7 +440,7 @@ export async function configurePlatform() {
|
||||
|
||||
setMetadata(presentation.metadata.FrontUrl, config.FRONT_URL)
|
||||
setMetadata(presentation.metadata.PreviewConfig, parsePreviewConfig(config.PREVIEW_CONFIG))
|
||||
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG, config.UPLOAD_URL))
|
||||
setMetadata(presentation.metadata.UploadConfig, parseUploadConfig(config.UPLOAD_CONFIG ?? '', config.UPLOAD_URL))
|
||||
setMetadata(presentation.metadata.StatsUrl, config.STATS_URL)
|
||||
setMetadata(presentation.metadata.LinkPreviewUrl, config.LINK_PREVIEW_URL)
|
||||
setMetadata(presentation.metadata.MailUrl, config.MAIL_URL)
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
module.exports = {
|
||||
extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'],
|
||||
parserOptions: {
|
||||
tsconfigRootDir: __dirname,
|
||||
project: './tsconfig.json'
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
{
|
||||
"name": "@hcengineering/analytics-providers",
|
||||
"version": "0.6.0",
|
||||
"main": "lib/index.js",
|
||||
"svelte": "src/index.ts",
|
||||
"types": "types/index.d.ts",
|
||||
"files": [
|
||||
"lib/**/*",
|
||||
"types/**/*",
|
||||
"tsconfig.json"
|
||||
],
|
||||
"author": "Hardcore Engineering Inc.",
|
||||
"license": "EPL-2.0",
|
||||
"scripts": {
|
||||
"build": "compile",
|
||||
"build:watch": "compile",
|
||||
"format": "format src",
|
||||
"test": "jest --passWithNoTests --silent",
|
||||
"_phase:build": "compile transpile src",
|
||||
"_phase:test": "jest --passWithNoTests --silent",
|
||||
"_phase:format": "format src",
|
||||
"_phase:validate": "compile validate"
|
||||
},
|
||||
"devDependencies": {
|
||||
"cross-env": "~7.0.3",
|
||||
"@hcengineering/platform-rig": "^0.6.0",
|
||||
"@types/node": "^22.15.29",
|
||||
"@typescript-eslint/eslint-plugin": "^6.11.0",
|
||||
"eslint-plugin-import": "^2.26.0",
|
||||
"eslint-plugin-promise": "^6.1.1",
|
||||
"eslint-plugin-n": "^15.4.0",
|
||||
"eslint": "^8.54.0",
|
||||
"esbuild": "^0.24.2",
|
||||
"@typescript-eslint/parser": "^6.11.0",
|
||||
"eslint-config-standard-with-typescript": "^40.0.0",
|
||||
"prettier": "^3.1.0",
|
||||
"typescript": "^5.8.3",
|
||||
"jest": "^29.7.0",
|
||||
"ts-jest": "^29.1.1",
|
||||
"@types/jest": "^29.5.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/analytics": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"@hcengineering/presentation": "^0.6.3",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"posthog-js": "^1.246.0",
|
||||
"@sentry/svelte": "^9.22.0",
|
||||
"ua-parser-js": "^2.0.4",
|
||||
"@hcengineering/ui": "^0.6.15"
|
||||
},
|
||||
"repository": "https://github.com/hcengineering/platform",
|
||||
"publishConfig": {
|
||||
"registry": "https://npm.pkg.github.com"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
"types": "./types/index.d.ts",
|
||||
"require": "./lib/index.js",
|
||||
"import": "./lib/index.js"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type AnalyticProvider } from '@hcengineering/analytics'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import { AnalyticEventType } from '@hcengineering/analytics-collector'
|
||||
import { collectEventMetadata } from './utils'
|
||||
import { type QueuedEvent } from './types'
|
||||
|
||||
export class AnalyticsCollectorProvider implements AnalyticProvider {
|
||||
private readonly collectIntervalMs = 5000
|
||||
private readonly maxRetries = 3
|
||||
private readonly events: QueuedEvent[] = []
|
||||
private collectTimer: any = null
|
||||
private url: string = ''
|
||||
private email: string | undefined = undefined
|
||||
private anonymousId: string = ''
|
||||
private isAuthenticated: boolean = false
|
||||
private data: Record<string, any> | null = null
|
||||
|
||||
init (config: Record<string, any>): boolean {
|
||||
if (config.ANALYTICS_COLLECTOR_URL == null) return false
|
||||
this.url = config.ANALYTICS_COLLECTOR_URL
|
||||
if (this.url !== undefined && this.url !== '' && this.url !== null) {
|
||||
this.initializeAnonymousId()
|
||||
this.startCollectionTimer()
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
private initializeAnonymousId (): void {
|
||||
this.anonymousId = this.generateAnonymousId()
|
||||
}
|
||||
|
||||
private generateAnonymousId (): string {
|
||||
return 'anon_' + Date.now() + '_' + Math.random().toString(36).substring(2, 15)
|
||||
}
|
||||
|
||||
private startCollectionTimer (): void {
|
||||
if (this.collectTimer != null) clearInterval(this.collectTimer)
|
||||
this.collectTimer = setInterval(() => {
|
||||
void this.sendEvents()
|
||||
}, this.collectIntervalMs)
|
||||
}
|
||||
|
||||
private stopCollectionTimer (): void {
|
||||
if (this.collectTimer != null) {
|
||||
clearInterval(this.collectTimer)
|
||||
this.collectTimer = null
|
||||
}
|
||||
}
|
||||
|
||||
async sendEvents (): Promise<void> {
|
||||
if (this.events.length === 0) return
|
||||
|
||||
const token = getMetadata(presentation.metadata.Token) ?? ''
|
||||
if (token === '') return
|
||||
|
||||
const eventsToSend = this.events.splice(0, this.events.length)
|
||||
|
||||
try {
|
||||
const response = await fetch(`${this.url}/collect`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: 'Bearer ' + token,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(eventsToSend)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
this.handleFailedEvents(eventsToSend)
|
||||
}
|
||||
} catch (err) {
|
||||
this.handleFailedEvents(eventsToSend)
|
||||
}
|
||||
}
|
||||
|
||||
private handleFailedEvents (failedEvents: QueuedEvent[]): void {
|
||||
const eventsToRetry: QueuedEvent[] = []
|
||||
|
||||
failedEvents.forEach((event) => {
|
||||
event.retryCount = (event.retryCount ?? 0) + 1
|
||||
if (event.retryCount <= this.maxRetries) {
|
||||
eventsToRetry.push(event)
|
||||
}
|
||||
})
|
||||
|
||||
this.events.unshift(...eventsToRetry)
|
||||
}
|
||||
|
||||
addEvent (
|
||||
eventType: AnalyticEventType,
|
||||
properties: Record<string, any> = {},
|
||||
eventName: string,
|
||||
overrideDistinctId?: string
|
||||
): void {
|
||||
const currentId = overrideDistinctId ?? (this.isAuthenticated && this.email != null ? this.email : this.anonymousId)
|
||||
|
||||
const baseProperties: Record<string, any> = {
|
||||
...properties,
|
||||
analytics_collector: true,
|
||||
$anonymous_id: this.anonymousId,
|
||||
$is_identified: this.isAuthenticated
|
||||
}
|
||||
|
||||
const eventMetadata: Record<string, any> = collectEventMetadata(baseProperties)
|
||||
if (eventType === AnalyticEventType.CustomEvent && (eventName !== '' || eventName != null)) {
|
||||
eventMetadata.event = eventName
|
||||
}
|
||||
if (this.data != null) {
|
||||
for (const key in this.data) {
|
||||
if (Object.prototype.hasOwnProperty.call(this.data, key)) {
|
||||
const value = this.data[key]
|
||||
eventMetadata[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const event: QueuedEvent = {
|
||||
event: eventType,
|
||||
properties: eventMetadata,
|
||||
timestamp: Date.now(),
|
||||
distinct_id: currentId
|
||||
}
|
||||
this.events.push(event)
|
||||
}
|
||||
|
||||
private normalizeUserData (data: Record<string, any>): Record<string, any> {
|
||||
const cleanedData: Record<string, any> = {}
|
||||
for (const key in data) {
|
||||
if (Object.prototype.hasOwnProperty.call(data, key)) {
|
||||
const value = data[key]
|
||||
if (Array.isArray(value)) {
|
||||
cleanedData[key] = value.join(', ')
|
||||
} else {
|
||||
cleanedData[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
return cleanedData
|
||||
}
|
||||
|
||||
setUser (email: string, data: Record<string, any>): void {
|
||||
const wasAuthenticated = this.isAuthenticated
|
||||
const previousId = this.anonymousId
|
||||
const normalizedData = this.normalizeUserData(data)
|
||||
|
||||
this.email = email
|
||||
this.data = normalizedData
|
||||
this.isAuthenticated = true
|
||||
|
||||
if (!wasAuthenticated && (previousId != null || previousId !== '')) {
|
||||
this.addEvent(
|
||||
AnalyticEventType.SetAlias,
|
||||
{
|
||||
alias: previousId,
|
||||
distinct_id: email,
|
||||
previous_id: previousId,
|
||||
$anonymous_id: previousId
|
||||
},
|
||||
'$create_alias',
|
||||
previousId
|
||||
)
|
||||
}
|
||||
|
||||
const setData: Record<string, any> = { email, ...normalizedData }
|
||||
this.addEvent(
|
||||
AnalyticEventType.SetUser,
|
||||
{
|
||||
$set: setData,
|
||||
$set_once: {
|
||||
initial_url: window.location.pathname,
|
||||
first_seen: new Date().toISOString()
|
||||
}
|
||||
},
|
||||
'$identify'
|
||||
)
|
||||
}
|
||||
|
||||
setAlias (distinctId: string, alias: string): void {
|
||||
this.addEvent(
|
||||
AnalyticEventType.SetAlias,
|
||||
{
|
||||
alias,
|
||||
previous_id: distinctId
|
||||
},
|
||||
'$create_alias'
|
||||
)
|
||||
}
|
||||
|
||||
setTag (key: string, value: string | number): void {
|
||||
this.addEvent(
|
||||
AnalyticEventType.SetTag,
|
||||
{
|
||||
$set: { [key]: value },
|
||||
$set_once: { initial_url: window.location.pathname }
|
||||
},
|
||||
'$set'
|
||||
)
|
||||
}
|
||||
|
||||
setWorkspace (ws: string): void {
|
||||
this.addEvent(
|
||||
AnalyticEventType.SetGroup,
|
||||
{
|
||||
$group_0: ws,
|
||||
$group_key: ws,
|
||||
$group_set: {
|
||||
name: ws,
|
||||
joined_at: new Date().toISOString()
|
||||
},
|
||||
$group_type: 'workspace',
|
||||
$groups: { workspace: ws }
|
||||
},
|
||||
'$groupidentify'
|
||||
)
|
||||
}
|
||||
|
||||
handleEvent (event: string, params: Record<string, string>): void {
|
||||
this.addEvent(AnalyticEventType.CustomEvent, { event, ...params }, event)
|
||||
}
|
||||
|
||||
handleError (error: Error): void {
|
||||
const currentId = this.isAuthenticated && (this.email != null || this.email !== '') ? this.email : this.anonymousId
|
||||
this.addEvent(
|
||||
AnalyticEventType.Error,
|
||||
{
|
||||
error_message: error.message ?? 'Unknown error',
|
||||
error_type: error.name ?? 'Error',
|
||||
error_stack: error.stack ?? ''
|
||||
},
|
||||
'$exception',
|
||||
currentId
|
||||
)
|
||||
}
|
||||
|
||||
navigate (path: string): void {
|
||||
this.addEvent(AnalyticEventType.Navigation, { path }, '$pageview')
|
||||
}
|
||||
|
||||
logout (): void {
|
||||
void this.sendEvents()
|
||||
|
||||
this.email = undefined
|
||||
this.isAuthenticated = false
|
||||
this.data = null
|
||||
|
||||
this.anonymousId = this.generateAnonymousId()
|
||||
}
|
||||
|
||||
destroy (): void {
|
||||
this.stopCollectionTimer()
|
||||
void this.sendEvents()
|
||||
}
|
||||
|
||||
flush (): Promise<void> {
|
||||
return this.sendEvents()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type AnalyticProvider, Analytics } from '@hcengineering/analytics'
|
||||
import { AnalyticsCollectorProvider } from './analyticsCollector'
|
||||
import { PosthogAnalyticProvider } from './posthog'
|
||||
import { SentryAnalyticProvider } from './sentry'
|
||||
import { type AnalyticsConfig } from './types'
|
||||
|
||||
export * from './analyticsCollector'
|
||||
export * from './posthog'
|
||||
export * from './sentry'
|
||||
export * from './utils'
|
||||
export * from './types'
|
||||
|
||||
export function configureAnalyticsProviders (config: AnalyticsConfig): void {
|
||||
const providers: AnalyticProvider[] = [
|
||||
new AnalyticsCollectorProvider(),
|
||||
new SentryAnalyticProvider(),
|
||||
new PosthogAnalyticProvider()
|
||||
]
|
||||
|
||||
for (const provider of providers) {
|
||||
Analytics.init(provider, config)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
export * from './analyticsCollector'
|
||||
export * from './posthog'
|
||||
export * from './sentry'
|
||||
export * from './utils'
|
||||
export * from './types'
|
||||
export * from './configure'
|
||||
@@ -0,0 +1,69 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type AnalyticProvider } from '@hcengineering/analytics'
|
||||
import posthog from 'posthog-js'
|
||||
|
||||
export class PosthogAnalyticProvider implements AnalyticProvider {
|
||||
init (config: Record<string, any>): boolean {
|
||||
if (config.POSTHOG_API_KEY !== undefined && config.POSTHOG_API_KEY !== '' && config.POSTHOG_HOST !== null) {
|
||||
posthog.init(config.POSTHOG_API_KEY, {
|
||||
api_host: config.POSTHOG_HOST,
|
||||
autocapture: false,
|
||||
capture_pageview: false,
|
||||
capture_pageleave: false
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setUser (email: string): void {
|
||||
if (!posthog._isIdentified()) {
|
||||
posthog.identify(email, { email })
|
||||
}
|
||||
}
|
||||
|
||||
setAlias (distinctId: string, alias: string): void {
|
||||
posthog.alias(alias, distinctId)
|
||||
}
|
||||
|
||||
setTag (key: string, value: string | number): void {
|
||||
posthog.setPersonProperties({ [key]: value })
|
||||
}
|
||||
|
||||
setWorkspace (ws: string): void {
|
||||
this.setTag('workspace', ws)
|
||||
posthog.group('workspace', ws, {
|
||||
name: `${ws}`
|
||||
})
|
||||
}
|
||||
|
||||
logout (): void {
|
||||
posthog.reset(true)
|
||||
}
|
||||
|
||||
handleEvent (event: string, params: Record<string, any>): void {
|
||||
posthog.capture(event, params)
|
||||
}
|
||||
|
||||
handleError (error: Error): void {
|
||||
posthog.capture(error.message)
|
||||
}
|
||||
|
||||
navigate (path: string): void {
|
||||
posthog.capture('$pageview')
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { type AnalyticProvider } from '@hcengineering/analytics'
|
||||
import * as Sentry from '@sentry/svelte'
|
||||
|
||||
export class SentryAnalyticProvider implements AnalyticProvider {
|
||||
navigate (path: string): void {}
|
||||
init (config: Record<string, any>): boolean {
|
||||
if (config.SENTRY_DSN !== undefined && config.SENTRY_DSN !== '') {
|
||||
Sentry.init({
|
||||
dsn: config.SENTRY_DSN,
|
||||
integrations: [
|
||||
Sentry.browserTracingIntegration(),
|
||||
Sentry.replayIntegration({
|
||||
maskAllText: false,
|
||||
blockAllMedia: false
|
||||
})
|
||||
],
|
||||
|
||||
// Set tracesSampleRate to 1.0 to capture 100%
|
||||
// of transactions for performance monitoring.
|
||||
// We recommend adjusting this value in production
|
||||
tracesSampleRate: 1.0,
|
||||
|
||||
tracePropagationTargets: [/^https:\/\/huly\.app/, /^https:\/\/app\.huly\.io/, /^https:\/\/account\.huly\.io/],
|
||||
|
||||
replaysSessionSampleRate: 0.0,
|
||||
replaysOnErrorSampleRate: 1.0
|
||||
})
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
setUser (email: string): void {
|
||||
Sentry.setUser({ email })
|
||||
}
|
||||
|
||||
setAlias (distinctId: string, alias: string): void {}
|
||||
|
||||
logout (): void {
|
||||
Sentry.setUser(null)
|
||||
}
|
||||
|
||||
setTag (key: string, value: string | number): void {
|
||||
Sentry.setTag(key, value)
|
||||
}
|
||||
|
||||
setWorkspace (ws: string): void {
|
||||
this.setTag('workspace', ws)
|
||||
}
|
||||
|
||||
handleEvent (event: string): void {
|
||||
// currently we don't need it, but maybe in future
|
||||
// Sentry.captureMessage(event, 'log')
|
||||
}
|
||||
|
||||
handleError (error: Error): void {
|
||||
Sentry.captureException(error)
|
||||
}
|
||||
}
|
||||
+12
-20
@@ -1,5 +1,5 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
@@ -13,24 +13,16 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { MongoClientReference, getMongoClient } from '@hcengineering/mongo'
|
||||
import { MongoClient } from 'mongodb'
|
||||
import { type AnalyticEvent } from '@hcengineering/analytics-collector'
|
||||
|
||||
import config from './config'
|
||||
|
||||
const clientRef: MongoClientReference = getMongoClient(config.MongoUrl)
|
||||
let client: MongoClient | undefined
|
||||
|
||||
export const getDB = (() => {
|
||||
return async () => {
|
||||
if (client === undefined) {
|
||||
client = await clientRef.getClient()
|
||||
}
|
||||
|
||||
return client.db(config.MongoDb)
|
||||
}
|
||||
})()
|
||||
|
||||
export const closeDB: () => Promise<void> = async () => {
|
||||
clientRef.close()
|
||||
export interface AnalyticsConfig {
|
||||
ANALYTICS_COLLECTOR_URL?: string
|
||||
SENTRY_DSN?: string
|
||||
POSTHOG_API?: string
|
||||
POSTHOG_HOST?: string
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
export interface QueuedEvent extends AnalyticEvent {
|
||||
retryCount?: number
|
||||
}
|
||||
@@ -0,0 +1,104 @@
|
||||
//
|
||||
// Copyright © 2025 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { UAParser } from 'ua-parser-js'
|
||||
import { getMetadata } from '@hcengineering/platform'
|
||||
import presentation from '@hcengineering/presentation'
|
||||
import { desktopPlatform } from '@hcengineering/ui'
|
||||
|
||||
const parser = UAParser()
|
||||
|
||||
function getUrlTrackingParams (): Record<string, string | null> {
|
||||
const params = new URLSearchParams(window.location.search)
|
||||
return {
|
||||
utm_source: params.get('utm_source'),
|
||||
utm_medium: params.get('utm_medium'),
|
||||
utm_campaign: params.get('utm_campaign'),
|
||||
utm_term: params.get('utm_term'),
|
||||
utm_content: params.get('utm_content'),
|
||||
gclid: params.get('gclid'),
|
||||
gad_source: params.get('gad_source'),
|
||||
gclsrc: params.get('gclsrc'),
|
||||
dclid: params.get('dclid'),
|
||||
wbraid: params.get('wbraid'),
|
||||
gbraid: params.get('gbraid'),
|
||||
fbclid: params.get('fbclid'),
|
||||
msclkid: params.get('msclkid'),
|
||||
twclid: params.get('twclid'),
|
||||
li_fat_id: params.get('li_fat_id'),
|
||||
mc_cid: params.get('mc_cid'),
|
||||
igshid: params.get('igshid'),
|
||||
ttclid: params.get('ttclid')
|
||||
}
|
||||
}
|
||||
|
||||
function getSearchEngine (referrer: string): string | null {
|
||||
if (referrer == null || referrer === '') return null
|
||||
const domains = {
|
||||
'google.': 'google',
|
||||
'bing.': 'bing',
|
||||
'yahoo.': 'yahoo',
|
||||
'duckduckgo.': 'duckduckgo',
|
||||
'yandex.': 'yandex'
|
||||
}
|
||||
for (const [domain, engine] of Object.entries(domains)) {
|
||||
if (referrer.includes(domain)) return engine
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
function getDeviceType (type: string | undefined | null): string {
|
||||
if (type == null || type === '') return 'Desktop'
|
||||
if (type === 'mobile') return 'Mobile'
|
||||
if (type === 'tablet') return 'Tablet'
|
||||
return 'Desktop'
|
||||
}
|
||||
|
||||
export function collectEventMetadata (properties: Record<string, any> = {}): Record<string, any> {
|
||||
const trackingParams = getUrlTrackingParams()
|
||||
const referrer = document.referrer === '' ? '$direct' : document.referrer
|
||||
const referringDomain = referrer !== '$direct' ? new URL(referrer).hostname : '$direct'
|
||||
|
||||
const now = new Date()
|
||||
const timezone = Intl.DateTimeFormat().resolvedOptions().timeZone
|
||||
const timezoneOffset = -now.getTimezoneOffset()
|
||||
|
||||
return {
|
||||
...properties,
|
||||
$timestamp: new Date().toISOString(),
|
||||
$os: parser.os.name ?? 'Unknown OS',
|
||||
$os_version: parser.os.version ?? '',
|
||||
$browser: parser.browser.name ?? 'Unknown Browser',
|
||||
$browser_version: parseInt(parser.browser.major ?? '0', 10),
|
||||
$device_type: getDeviceType(parser.device.type),
|
||||
$current_url: window.location.href,
|
||||
$host: window.location.hostname,
|
||||
$pathname: window.location.pathname,
|
||||
$screen_height: window.screen.height,
|
||||
$screen_width: window.screen.width,
|
||||
$viewport_height: window.innerHeight,
|
||||
$viewport_width: window.innerWidth,
|
||||
$lib: desktopPlatform ? 'app' : 'web',
|
||||
$lib_version: getMetadata(presentation.metadata.FrontVersion) ?? '0.0.0',
|
||||
$search_engine: getSearchEngine(referrer),
|
||||
$referrer: referrer,
|
||||
$referring_domain: referringDomain,
|
||||
$raw_user_agent: navigator.userAgent,
|
||||
title: document.title,
|
||||
$timezone: timezone,
|
||||
$timezone_offset: timezoneOffset,
|
||||
...trackingParams
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"extends": "./node_modules/@hcengineering/platform-rig/profiles/default/tsconfig.json",
|
||||
|
||||
"compilerOptions": {
|
||||
"rootDir": "./src",
|
||||
"outDir": "./lib",
|
||||
"declarationDir": "./types",
|
||||
"tsBuildInfoFile": ".build/build.tsbuildinfo"
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "lib", "dist", "types", "bundle"]
|
||||
}
|
||||
@@ -24,8 +24,13 @@ export class SentryAnalyticProvider implements AnalyticProvider {
|
||||
return true
|
||||
}
|
||||
|
||||
setUser (email: string): void {
|
||||
Sentry.setUser({ email })
|
||||
setUser (socialId: string): void {
|
||||
Sentry.setUser({ email: socialId })
|
||||
}
|
||||
|
||||
setAlias (distinctId: string, alias: string): void {
|
||||
// Sentry does not support aliasing like PostHog or Mixpanel
|
||||
// This method is left empty intentionally
|
||||
}
|
||||
|
||||
setTag (key: string, value: string): void {
|
||||
|
||||
@@ -7,7 +7,8 @@ import { addEventListener, PlatformEvent, Severity, Status, translate } from '@h
|
||||
export const providers: AnalyticProvider[] = []
|
||||
export interface AnalyticProvider {
|
||||
init: (config: Record<string, any>) => boolean
|
||||
setUser: (email: string) => void
|
||||
setUser: (email: string, data: any) => void
|
||||
setAlias: (distinctId: string, alias: string) => void
|
||||
setTag: (key: string, value: string) => void
|
||||
setWorkspace: (ws: string) => void
|
||||
handleEvent: (event: string, params: Record<string, string>) => void
|
||||
@@ -17,6 +18,8 @@ export interface AnalyticProvider {
|
||||
}
|
||||
|
||||
export const Analytics = {
|
||||
data: {},
|
||||
|
||||
init (provider: AnalyticProvider, config: Record<string, any>): void {
|
||||
const res = provider.init(config)
|
||||
if (res) {
|
||||
@@ -24,9 +27,15 @@ export const Analytics = {
|
||||
}
|
||||
},
|
||||
|
||||
setUser (email: string): void {
|
||||
setUser (email: string, data: any): void {
|
||||
providers.forEach((provider) => {
|
||||
provider.setUser(email)
|
||||
provider.setUser(email, data)
|
||||
})
|
||||
},
|
||||
|
||||
setAlias (distinctId: string, alias: string): void {
|
||||
providers.forEach((provider) => {
|
||||
provider.setAlias(distinctId, alias)
|
||||
})
|
||||
},
|
||||
|
||||
@@ -44,7 +53,7 @@ export const Analytics = {
|
||||
|
||||
handleEvent (event: string, params: Record<string, any> = {}): void {
|
||||
providers.forEach((provider) => {
|
||||
provider.handleEvent(event, params)
|
||||
provider.handleEvent(event, { ...this.data, ...params })
|
||||
})
|
||||
},
|
||||
|
||||
|
||||
@@ -18,7 +18,9 @@ import type { AccountUuid, WorkspaceUuid } from '@hcengineering/core'
|
||||
|
||||
export enum AnalyticEventType {
|
||||
SetUser = 'setUser',
|
||||
SetAlias = 'setAlias',
|
||||
SetTag = 'setTag',
|
||||
SetGroup = 'setGroup',
|
||||
Navigation = 'navigation',
|
||||
Error = 'error',
|
||||
CustomEvent = 'customEvent'
|
||||
@@ -26,8 +28,9 @@ export enum AnalyticEventType {
|
||||
|
||||
export interface AnalyticEvent {
|
||||
event: AnalyticEventType
|
||||
params: Record<string, any>
|
||||
properties: Record<string, any>
|
||||
timestamp: number
|
||||
distinct_id: string
|
||||
}
|
||||
|
||||
export interface OnboardingChannel extends Channel {
|
||||
|
||||
@@ -48,10 +48,10 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
const selectWorkspace = await getResource(login.function.SelectWorkspace)
|
||||
const workspaceLoginInfo = (await selectWorkspace(wsUrl, exchangedToken))[1]
|
||||
if (workspaceLoginInfo == null) {
|
||||
console.error(
|
||||
`Error selecting workspace ${wsUrl}. There might be something wrong with the token. Please try to log in again.`
|
||||
)
|
||||
const err = `Error selecting workspace ${wsUrl}. There might be something wrong with the token. Please try to log in again.`
|
||||
console.error(err)
|
||||
// something went wrong with selecting workspace with the selected token
|
||||
Analytics.handleError(new Error(err))
|
||||
await logOut()
|
||||
invalidError.set(true)
|
||||
return
|
||||
@@ -179,7 +179,7 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
})
|
||||
console.log('logging in as guest')
|
||||
Analytics.handleEvent('GUEST LOGIN')
|
||||
Analytics.setWorkspace(wsUrl)
|
||||
// Analytics.setWorkspace(wsUrl)
|
||||
|
||||
const account = workspaceLoginInfo.account
|
||||
|
||||
@@ -192,7 +192,7 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
}
|
||||
|
||||
if (me !== undefined) {
|
||||
Analytics.setUser(account)
|
||||
Analytics.setUser(account, { account })
|
||||
Analytics.setWorkspace(wsUrl)
|
||||
console.log('login: employee account', me)
|
||||
setCurrentAccount(me)
|
||||
|
||||
@@ -83,7 +83,6 @@ export async function doLogin (email: string, password: string): Promise<[Status
|
||||
const loginInfo = await accountClient.login(email, password)
|
||||
|
||||
Analytics.handleEvent(LoginEvents.LoginPassword, { email, ok: true })
|
||||
Analytics.setUser(email)
|
||||
|
||||
return [OK, loginInfo]
|
||||
} catch (err: any) {
|
||||
@@ -108,7 +107,7 @@ export async function doLoginAsGuest (): Promise<[Status, LoginInfo | null]> {
|
||||
const loginInfo = await accountClient.loginAsGuest()
|
||||
|
||||
/* Analytics.handleEvent(LoginEvents.LoginPassword, { email, ok: true })
|
||||
Analytics.setUser(email) */
|
||||
Analytics.setUser(email, loginInfo.account) */
|
||||
|
||||
return [OK, loginInfo]
|
||||
} catch (err: any) {
|
||||
@@ -136,7 +135,6 @@ export async function signUp (
|
||||
const otpInfo = await getAccountClient(null).signUp(email, password, first, last)
|
||||
|
||||
Analytics.handleEvent(LoginEvents.SignUpEmail, { email, ok: true })
|
||||
Analytics.setUser(email)
|
||||
|
||||
return [OK, otpInfo]
|
||||
} catch (err: any) {
|
||||
@@ -158,8 +156,7 @@ export async function signUpOtp (email: string, first: string, last: string): Pr
|
||||
try {
|
||||
const otpInfo = await getAccountClient(null).signUpOtp(email, first, last)
|
||||
|
||||
Analytics.handleEvent('signUpOtp')
|
||||
Analytics.setUser(email)
|
||||
Analytics.handleEvent('signUpOtp', { email, ok: true })
|
||||
|
||||
return [OK, otpInfo]
|
||||
} catch (err: any) {
|
||||
@@ -431,7 +428,7 @@ export async function fetchWorkspace (): Promise<[Status, WorkspaceInfoWithStatu
|
||||
const workspaceWithStatus = await getAccountClient(token).getWorkspaceInfo(true)
|
||||
|
||||
Analytics.handleEvent('Fetch workspace')
|
||||
Analytics.setWorkspace(workspaceWithStatus.url)
|
||||
// Analytics.setWorkspace(workspaceWithStatus.url)
|
||||
|
||||
return [OK, workspaceWithStatus]
|
||||
} catch (err: any) {
|
||||
@@ -623,16 +620,17 @@ export async function join (
|
||||
try {
|
||||
const workspaceLoginInfo = await getAccountClient().join(email, password, inviteId)
|
||||
|
||||
Analytics.handleEvent('Join')
|
||||
Analytics.setUser(email)
|
||||
Analytics.handleEvent('Join', { email, ok: true })
|
||||
|
||||
return [OK, workspaceLoginInfo]
|
||||
} catch (err: any) {
|
||||
if (err instanceof PlatformError) {
|
||||
Analytics.handleEvent('Join', { email, ok: false })
|
||||
await handleStatusError('Join error', err.status)
|
||||
|
||||
return [err.status, null]
|
||||
} else {
|
||||
Analytics.handleEvent('Join', { email, ok: false })
|
||||
Analytics.handleError(err)
|
||||
|
||||
return [unknownError(err), null]
|
||||
@@ -650,16 +648,17 @@ export async function signUpJoin (
|
||||
try {
|
||||
const workspaceLoginInfo = await getAccountClient().signUpJoin(email, password, first, last, inviteId)
|
||||
|
||||
Analytics.handleEvent('Signup Join')
|
||||
Analytics.setUser(email)
|
||||
Analytics.handleEvent('Signup Join', { email, ok: true })
|
||||
|
||||
return [OK, workspaceLoginInfo]
|
||||
} catch (err: any) {
|
||||
if (err instanceof PlatformError) {
|
||||
Analytics.handleEvent('Signup Join', { email, ok: false })
|
||||
await handleStatusError('Sign up join error', err.status)
|
||||
|
||||
return [err.status, null]
|
||||
} else {
|
||||
Analytics.handleEvent('Signup Join', { email, ok: false })
|
||||
Analytics.handleError(err)
|
||||
|
||||
return [unknownError(err), null]
|
||||
@@ -894,17 +893,18 @@ export async function loginOtp (email: string): Promise<[Status, OtpInfo | null]
|
||||
try {
|
||||
const otpInfo = await getAccountClient(null).loginOtp(email)
|
||||
|
||||
Analytics.handleEvent('sendOtp')
|
||||
Analytics.setUser(email)
|
||||
Analytics.handleEvent('sendOtp', { email, ok: true })
|
||||
|
||||
return [OK, otpInfo]
|
||||
} catch (err: any) {
|
||||
if (err instanceof PlatformError) {
|
||||
Analytics.handleEvent('sendOtp', { email, ok: false })
|
||||
await handleStatusError('Send otp error', err.status)
|
||||
|
||||
return [err.status, null]
|
||||
} else {
|
||||
console.error('Send otp error', err)
|
||||
Analytics.handleEvent('sendOtp', { email, ok: false })
|
||||
Analytics.handleError(err)
|
||||
|
||||
return [unknownError(err), null]
|
||||
@@ -923,7 +923,6 @@ export async function doValidateOtp (
|
||||
const loginInfo = await getAccountClient(null).validateOtp(email, code, password)
|
||||
|
||||
Analytics.handleEvent(telemetryEvent, { email, ok: true })
|
||||
Analytics.setUser(email)
|
||||
|
||||
return [OK, loginInfo]
|
||||
} catch (err: any) {
|
||||
|
||||
@@ -103,9 +103,10 @@
|
||||
navigate(loc)
|
||||
}
|
||||
function signOut (): void {
|
||||
Analytics.handleEvent(SettingsEvents.SignOut)
|
||||
void logOut()
|
||||
navigate({ path: [loginId] })
|
||||
Analytics.handleEvent(SettingsEvents.SignOut)
|
||||
Analytics.logout()
|
||||
}
|
||||
function selectWorkspace (): void {
|
||||
Analytics.handleEvent(SettingsEvents.SelectWorkspace)
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
import workbench from '../plugin'
|
||||
import { logOut } from '../utils'
|
||||
import HelpAndSupport from './HelpAndSupport.svelte'
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
|
||||
let items: SettingsCategory[] = []
|
||||
|
||||
@@ -166,6 +167,8 @@
|
||||
action: async () => {
|
||||
await logOut()
|
||||
navigate({ path: [loginId] })
|
||||
Analytics.handleEvent('workbench.SignOut')
|
||||
Analytics.logout()
|
||||
},
|
||||
group: 'end'
|
||||
}
|
||||
|
||||
@@ -54,6 +54,7 @@ import { get, writable } from 'svelte/store'
|
||||
|
||||
import plugin from './plugin'
|
||||
import { logOut, workspaceCreating } from './utils'
|
||||
import { WorkbenchEvents } from '@hcengineering/workbench'
|
||||
|
||||
export const versionError = writable<string | undefined>(undefined)
|
||||
const versionStorageKey = 'last_server_version'
|
||||
@@ -387,8 +388,20 @@ export async function connect (title: string): Promise<Client | undefined> {
|
||||
await setPlatformStatus(new Status(Severity.INFO, platform.status.SystemAccount, {}))
|
||||
}
|
||||
|
||||
Analytics.setUser(account)
|
||||
Analytics.setTag('workspace', wsUrl)
|
||||
const socialId = me.fullSocialIds.find((si) => si._id === me.primarySocialId)?.value
|
||||
const email = me.fullSocialIds.find((si) => si.type === 'email')?.value ?? socialId
|
||||
|
||||
const data: Record<string, any> = {
|
||||
social_id: socialId ?? account,
|
||||
account_uuid: account,
|
||||
workspace: workspace.name,
|
||||
workspace_uuid: workspace.uuid,
|
||||
branding: workspace.branding ?? 'unknown'
|
||||
}
|
||||
|
||||
Analytics.setUser(email ?? account, data)
|
||||
Analytics.setWorkspace(workspace.name)
|
||||
Analytics.handleEvent(WorkbenchEvents.Connect)
|
||||
console.log('Logged in with account: ', me)
|
||||
setCurrentAccount(me)
|
||||
|
||||
|
||||
@@ -2,5 +2,6 @@ export enum WorkbenchEvents {
|
||||
DocumentationOpened = 'workbench.help.DocumentationOpened',
|
||||
KeyboardShortcutsOpened = 'workbench.help.KeyboardShortcutsOpened',
|
||||
SidebarCloseWidget = 'workbench.sidebar.CloseWidget',
|
||||
SidebarOpenWidget = 'workbench.sidebar.OpenWidget'
|
||||
SidebarOpenWidget = 'workbench.sidebar.OpenWidget',
|
||||
Connect = 'workbench.Connect'
|
||||
}
|
||||
|
||||
@@ -432,6 +432,11 @@
|
||||
"projectFolder": "packages/analytics",
|
||||
"shouldPublish": false
|
||||
},
|
||||
{
|
||||
"packageName": "@hcengineering/analytics-providers",
|
||||
"projectFolder": "packages/analytics-providers",
|
||||
"shouldPublish": false
|
||||
},
|
||||
{
|
||||
"packageName": "@hcengineering/analytics-service",
|
||||
"projectFolder": "packages/analytics-service",
|
||||
|
||||
@@ -52,58 +52,17 @@
|
||||
"typescript": "^5.8.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@hcengineering/account": "^0.6.0",
|
||||
"@hcengineering/account-client": "^0.6.0",
|
||||
"@hcengineering/analytics": "^0.6.0",
|
||||
"@hcengineering/analytics-collector": "^0.6.0",
|
||||
"@hcengineering/analytics-collector-assets": "^0.6.0",
|
||||
"@hcengineering/analytics-service": "^0.6.0",
|
||||
"@hcengineering/calendar": "^0.6.24",
|
||||
"@hcengineering/calendar-assets": "^0.6.22",
|
||||
"@hcengineering/chunter": "^0.6.20",
|
||||
"@hcengineering/chunter-assets": "^0.6.18",
|
||||
"@hcengineering/client": "^0.6.18",
|
||||
"@hcengineering/client-resources": "^0.6.27",
|
||||
"@hcengineering/contact": "^0.6.24",
|
||||
"@hcengineering/contact-assets": "^0.6.13",
|
||||
"@hcengineering/core": "^0.6.32",
|
||||
"@hcengineering/document": "^0.6.0",
|
||||
"@hcengineering/document-assets": "^0.6.0",
|
||||
"@hcengineering/drive": "^0.6.0",
|
||||
"@hcengineering/drive-assets": "^0.6.0",
|
||||
"@hcengineering/hr": "^0.6.19",
|
||||
"@hcengineering/hr-assets": "^0.6.19",
|
||||
"@hcengineering/lead": "^0.6.0",
|
||||
"@hcengineering/lead-assets": "^0.6.0",
|
||||
"@hcengineering/love": "^0.6.0",
|
||||
"@hcengineering/love-assets": "^0.6.0",
|
||||
"@hcengineering/mongo": "^0.6.1",
|
||||
"@hcengineering/notification": "^0.6.23",
|
||||
"@hcengineering/notification-assets": "^0.6.17",
|
||||
"@hcengineering/platform": "^0.6.11",
|
||||
"@hcengineering/preference": "^0.6.13",
|
||||
"@hcengineering/preference-assets": "^0.6.0",
|
||||
"@hcengineering/recruit": "^0.6.29",
|
||||
"@hcengineering/recruit-assets": "^0.6.23",
|
||||
"@hcengineering/server-analytics-collector-resources": "^0.6.0",
|
||||
"@hcengineering/server-client": "^0.6.0",
|
||||
"@hcengineering/server-core": "^0.6.1",
|
||||
"@hcengineering/server-token": "^0.6.11",
|
||||
"@hcengineering/setting": "^0.6.17",
|
||||
"@hcengineering/setting-assets": "^0.6.15",
|
||||
"@hcengineering/text": "^0.6.5",
|
||||
"@hcengineering/time": "^0.6.0",
|
||||
"@hcengineering/time-assets": "^0.6.0",
|
||||
"@hcengineering/tracker": "^0.6.24",
|
||||
"@hcengineering/tracker-assets": "^0.6.0",
|
||||
"@hcengineering/view": "^0.6.13",
|
||||
"@hcengineering/view-assets": "^0.6.11",
|
||||
"@hcengineering/workbench": "^0.6.16",
|
||||
"@hcengineering/workbench-assets": "^0.6.14",
|
||||
"cors": "^2.8.5",
|
||||
"dotenv": "~16.0.0",
|
||||
"express": "^4.21.2",
|
||||
"mongodb": "^6.16.0",
|
||||
"puppeteer": "^22.6.1"
|
||||
"express": "^4.21.2"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,215 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
import { generateToken, Token } from '@hcengineering/server-token'
|
||||
import { AnalyticEvent } from '@hcengineering/analytics-collector'
|
||||
import { AccountRole, MeasureContext, isWorkspaceCreating, WorkspaceUuid, PersonUuid } from '@hcengineering/core'
|
||||
import { Person } from '@hcengineering/contact'
|
||||
import { getClient as getAccountClient, isWorkspaceLoginInfo } from '@hcengineering/account-client'
|
||||
import { Db, Collection } from 'mongodb'
|
||||
|
||||
import { WorkspaceClient } from './workspaceClient'
|
||||
import config from './config'
|
||||
import { SupportWsClient } from './supportWsClient'
|
||||
import { OnboardingMessage } from './types'
|
||||
|
||||
const closeWorkspaceTimeout = 10 * 60 * 1000 // 10 minutes
|
||||
|
||||
export class Collector {
|
||||
private readonly workspaces: Map<WorkspaceUuid, WorkspaceClient> = new Map<WorkspaceUuid, WorkspaceClient>()
|
||||
private readonly closeWorkspaceTimeouts: Map<WorkspaceUuid, NodeJS.Timeout> = new Map<WorkspaceUuid, NodeJS.Timeout>()
|
||||
private readonly createdWorkspaces: Set<WorkspaceUuid> = new Set<WorkspaceUuid>()
|
||||
|
||||
private readonly onboardingMessagesCollection: Collection<OnboardingMessage>
|
||||
|
||||
supportClient: SupportWsClient | undefined = undefined
|
||||
|
||||
// <account-workspace key, Person>
|
||||
persons = new Map<string, Person>()
|
||||
|
||||
constructor (
|
||||
private readonly ctx: MeasureContext,
|
||||
private readonly db: Db
|
||||
) {
|
||||
this.onboardingMessagesCollection = this.db.collection<OnboardingMessage>('messages')
|
||||
// this.supportClient = this.getSupportWorkspaceClient() // TODO: FIXME
|
||||
}
|
||||
|
||||
getWorkspaceClient (workspaceId: WorkspaceUuid): WorkspaceClient {
|
||||
const workspace = workspaceId
|
||||
|
||||
let wsClient: WorkspaceClient
|
||||
|
||||
if (!this.workspaces.has(workspace)) {
|
||||
this.ctx.info('Creating workspace client', { workspace, allClients: Array.from(this.workspaces.keys()) })
|
||||
const client = new WorkspaceClient(this.ctx, workspaceId)
|
||||
this.workspaces.set(workspace, client)
|
||||
wsClient = client
|
||||
} else {
|
||||
wsClient = this.workspaces.get(workspace) as WorkspaceClient
|
||||
}
|
||||
|
||||
this.setWorkspaceCloseTimeout(workspace)
|
||||
|
||||
return wsClient
|
||||
}
|
||||
|
||||
setWorkspaceCloseTimeout (workspace: WorkspaceUuid): void {
|
||||
const timeoutId = this.closeWorkspaceTimeouts.get(workspace)
|
||||
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
}
|
||||
|
||||
const newTimeoutId = setTimeout(() => {
|
||||
void this.closeWorkspaceClient(workspace)
|
||||
}, closeWorkspaceTimeout)
|
||||
|
||||
this.closeWorkspaceTimeouts.set(workspace, newTimeoutId)
|
||||
}
|
||||
|
||||
getSupportWorkspaceClient (): SupportWsClient {
|
||||
// TODO: FIXME
|
||||
throw new Error('Not implemented')
|
||||
// let client: SupportWsClient
|
||||
|
||||
// if (this.supportClient !== undefined) {
|
||||
// client = this.supportClient
|
||||
// } else {
|
||||
// client = new SupportWsClient(this.ctx, config.SupportWorkspace)
|
||||
// this.supportClient = client
|
||||
// }
|
||||
|
||||
// this.setWorkspaceCloseTimeout(config.SupportWorkspace)
|
||||
|
||||
// return client
|
||||
}
|
||||
|
||||
async closeWorkspaceClient (workspace: WorkspaceUuid): Promise<void> {
|
||||
this.ctx.info('Closing workspace client', { workspace })
|
||||
const timeoutId = this.closeWorkspaceTimeouts.get(workspace)
|
||||
|
||||
if (timeoutId !== undefined) {
|
||||
clearTimeout(timeoutId)
|
||||
this.closeWorkspaceTimeouts.delete(workspace)
|
||||
}
|
||||
|
||||
if (workspace === config.SupportWorkspace) {
|
||||
await this.supportClient?.close()
|
||||
this.supportClient = undefined
|
||||
} else {
|
||||
const client = this.workspaces.get(workspace)
|
||||
|
||||
if (client !== undefined) {
|
||||
await client.close()
|
||||
this.workspaces.delete(workspace)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
collect (events: AnalyticEvent[], token: Token): void {
|
||||
if (token.workspace === config.SupportWorkspace) {
|
||||
return
|
||||
}
|
||||
|
||||
void this.pushEventsToSupport(events, token)
|
||||
}
|
||||
|
||||
async isWorkspaceCreated (token: Token): Promise<boolean> {
|
||||
const ws = token.workspace
|
||||
|
||||
if (this.createdWorkspaces.has(ws)) {
|
||||
return true
|
||||
}
|
||||
|
||||
const rawToken = generateToken(token.account, token.workspace, { ...token.extra, service: 'analytics-collector' })
|
||||
const wsInfo = await getAccountClient(config.AccountsUrl, rawToken).getWorkspaceInfo()
|
||||
this.ctx.info('workspace info', wsInfo)
|
||||
|
||||
if (wsInfo === undefined) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (isWorkspaceCreating(wsInfo?.mode)) {
|
||||
return false
|
||||
}
|
||||
|
||||
this.createdWorkspaces.add(ws)
|
||||
return true
|
||||
}
|
||||
|
||||
async getPerson (account: PersonUuid, workspace: WorkspaceUuid): Promise<Person | undefined> {
|
||||
const key = `${account}-${workspace}`
|
||||
|
||||
if (this.persons.has(key)) {
|
||||
return this.persons.get(key)
|
||||
}
|
||||
|
||||
const token = generateToken(account, workspace, { service: 'analytics-collector' })
|
||||
const wsLoginInfo = await getAccountClient(config.AccountsUrl, token).getLoginInfoByToken()
|
||||
|
||||
if (!isWorkspaceLoginInfo(wsLoginInfo)) {
|
||||
this.ctx.error('Cannot find workspace login info by token', { wsLoginInfo })
|
||||
return
|
||||
}
|
||||
|
||||
if (wsLoginInfo.role !== AccountRole.Owner) {
|
||||
return
|
||||
}
|
||||
|
||||
const fromWsClient = this.getWorkspaceClient(workspace)
|
||||
const person = await fromWsClient.getPerson(account)
|
||||
|
||||
if (person === undefined) {
|
||||
this.ctx.error('Cannnot find person', { account, workspace })
|
||||
return
|
||||
}
|
||||
|
||||
this.persons.set(key, person)
|
||||
|
||||
return person
|
||||
}
|
||||
|
||||
async pushEventsToSupport (events: AnalyticEvent[], token: Token): Promise<void> {
|
||||
const isCreated = await this.isWorkspaceCreated(token)
|
||||
|
||||
if (!isCreated) {
|
||||
return
|
||||
}
|
||||
|
||||
const person = await this.getPerson(token.account, token.workspace)
|
||||
|
||||
if (person === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const client = this.getSupportWorkspaceClient()
|
||||
|
||||
await client.pushEvents(events, token.workspace, person, this.onboardingMessagesCollection)
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
for (const [, client] of this.workspaces) {
|
||||
await client.close()
|
||||
}
|
||||
|
||||
this.workspaces.clear()
|
||||
this.closeWorkspaceTimeouts.clear()
|
||||
|
||||
if (this.supportClient !== undefined) {
|
||||
await this.supportClient.close()
|
||||
this.supportClient = undefined
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,12 +15,11 @@
|
||||
|
||||
export interface Config {
|
||||
Port: number
|
||||
MongoUrl: string
|
||||
MongoDb: string
|
||||
Secret: string
|
||||
ServiceID: string
|
||||
SupportWorkspace: string
|
||||
AccountsUrl: string
|
||||
PostHogHost: string
|
||||
PostHogAPI: string
|
||||
SentryDSN?: string
|
||||
}
|
||||
|
||||
@@ -29,11 +28,11 @@ const parseNumber = (str: string | undefined): number | undefined => (str !== un
|
||||
const config: Config = (() => {
|
||||
const params: Partial<Config> = {
|
||||
Port: parseNumber(process.env.PORT) ?? 4007,
|
||||
MongoUrl: process.env.MONGO_URL,
|
||||
MongoDb: process.env.MONGO_DB ?? '%analytics-collector',
|
||||
Secret: process.env.SECRET,
|
||||
ServiceID: process.env.SERVICE_ID ?? 'analytics-collector-service',
|
||||
AccountsUrl: process.env.ACCOUNTS_URL,
|
||||
PostHogHost: process.env.POSTHOG_HOST,
|
||||
PostHogAPI: process.env.POSTHOG_API_KEY,
|
||||
SentryDSN: process.env.SENTRY_DSN ?? ''
|
||||
}
|
||||
|
||||
|
||||
@@ -1,488 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import analyticsCollector, { AnalyticEvent, AnalyticEventType } from '@hcengineering/analytics-collector'
|
||||
import chunter, { chunterId } from '@hcengineering/chunter'
|
||||
import contact, { contactId } from '@hcengineering/contact'
|
||||
import document, { documentId } from '@hcengineering/document'
|
||||
import drive, { driveId } from '@hcengineering/drive'
|
||||
import love, { loveId } from '@hcengineering/love'
|
||||
import notification, { notificationId } from '@hcengineering/notification'
|
||||
import recruit, { recruitId } from '@hcengineering/recruit'
|
||||
import time, { timeId } from '@hcengineering/time'
|
||||
import tracker, { trackerId } from '@hcengineering/tracker'
|
||||
import workbench, { WorkbenchEvents } from '@hcengineering/workbench'
|
||||
import { AccountUuid, Class, Doc, Hierarchy, Markup, Ref, TxOperations } from '@hcengineering/core'
|
||||
import { MarkupNode, MarkupNodeType, MarkupMark, MarkupMarkType } from '@hcengineering/text'
|
||||
import { translate } from '@hcengineering/platform'
|
||||
|
||||
export async function eventToMarkup (
|
||||
event: AnalyticEvent,
|
||||
hierarchy: Hierarchy,
|
||||
client: TxOperations
|
||||
): Promise<Markup | undefined> {
|
||||
switch (event.event) {
|
||||
case AnalyticEventType.CustomEvent:
|
||||
return await formatCustomEvent(event, client)
|
||||
case AnalyticEventType.Error:
|
||||
return await formatErrorEvent(event)
|
||||
case AnalyticEventType.Navigation:
|
||||
return await formatNavigationEvent(event, hierarchy)
|
||||
case AnalyticEventType.SetTag:
|
||||
return await formatSetTagEvent(event)
|
||||
case AnalyticEventType.SetUser:
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
function toMarkup (items: MarkupNode[]): Markup {
|
||||
return JSON.stringify({
|
||||
type: 'doc',
|
||||
content: [
|
||||
{
|
||||
type: 'paragraph',
|
||||
content: items
|
||||
}
|
||||
]
|
||||
})
|
||||
}
|
||||
|
||||
function toText (text: string, display: 'normal' | 'bold' | 'code' = 'normal'): MarkupNode {
|
||||
const marks: MarkupMark[] = []
|
||||
if (display === 'bold') {
|
||||
marks.push({ type: MarkupMarkType.bold, attrs: {} })
|
||||
}
|
||||
|
||||
if (display === 'code') {
|
||||
marks.push({ type: MarkupMarkType.code, attrs: {} })
|
||||
}
|
||||
|
||||
return { type: MarkupNodeType.text, text, marks }
|
||||
}
|
||||
|
||||
async function formatCustomEvent (event: AnalyticEvent, client: TxOperations): Promise<string | undefined> {
|
||||
const text = event.params.event as string | undefined
|
||||
|
||||
if (text === undefined || text === '') return
|
||||
if (eventsToSkip.includes(event.params.event)) return
|
||||
if (sidebarEvents.includes(text)) {
|
||||
return await formatSidebarEvent(text, event.params, client)
|
||||
}
|
||||
|
||||
const paramsTexts = []
|
||||
|
||||
for (const key in event.params) {
|
||||
if (key !== 'event') {
|
||||
paramsTexts.push(`${key}: ${event.params[key]}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (paramsTexts.length === 0) {
|
||||
return toMarkup([toText(text)])
|
||||
}
|
||||
return toMarkup([toText(text + ' '), toText(paramsTexts.join(', '), 'code')])
|
||||
}
|
||||
|
||||
async function formatSidebarEvent (
|
||||
event: string,
|
||||
params: Record<string, any>,
|
||||
client: TxOperations
|
||||
): Promise<string | undefined> {
|
||||
let text = event
|
||||
switch (event) {
|
||||
case WorkbenchEvents.SidebarOpenWidget:
|
||||
text = 'open widget'
|
||||
break
|
||||
case WorkbenchEvents.SidebarCloseWidget:
|
||||
text = 'close widget'
|
||||
break
|
||||
default:
|
||||
break
|
||||
}
|
||||
const paramsTexts = []
|
||||
|
||||
if (params.widget !== undefined) {
|
||||
const widget = client.getModel().findAllSync(workbench.class.Widget, { _id: params.widget })[0]
|
||||
if (widget !== undefined) {
|
||||
const widgetName = await translate(widget.label, {})
|
||||
paramsTexts.push(`widget: ${widgetName}`)
|
||||
} else {
|
||||
paramsTexts.push(`widget: ${params.widget}`)
|
||||
}
|
||||
}
|
||||
|
||||
if (params.tab !== undefined) {
|
||||
paramsTexts.push(`tab: ${params.tab}`)
|
||||
}
|
||||
|
||||
return toMarkup([toText('Sidebar: ', 'bold'), toText(text + ' '), toText(paramsTexts.join(', '), 'code')])
|
||||
}
|
||||
|
||||
async function formatErrorEvent (event: AnalyticEvent): Promise<string | undefined> {
|
||||
const error = event.params.error
|
||||
|
||||
if (error === undefined || (typeof error === 'object' && Object.keys(error).length === 0)) {
|
||||
return
|
||||
}
|
||||
|
||||
const text = await translate(analyticsCollector.string.Error, {})
|
||||
|
||||
return toMarkup([toText(text), toText(error.message ?? JSON.stringify(error), 'code')])
|
||||
}
|
||||
|
||||
async function formatSetTagEvent (event: AnalyticEvent): Promise<string | undefined> {
|
||||
const key = event.params.key
|
||||
|
||||
if (key === undefined || key === '' || key === 'workspace') return
|
||||
|
||||
const value = event.params.value
|
||||
const setLabel = await translate(analyticsCollector.string.Set, {})
|
||||
const toLabel = await translate(analyticsCollector.string.To, {})
|
||||
|
||||
return toMarkup([toText(setLabel), toText(key, 'bold'), toText(toLabel), toText(value, 'bold')])
|
||||
}
|
||||
|
||||
async function formatNavigationEvent (event: AnalyticEvent, hierarchy: Hierarchy): Promise<string | undefined> {
|
||||
const path = event.params.path as string | undefined
|
||||
|
||||
if (path === undefined || path === '') return
|
||||
const location = parseLocation(new URL('http://localhost:8080' + path))
|
||||
|
||||
if (location.path.length < 3) {
|
||||
return undefined
|
||||
}
|
||||
|
||||
return toMarkup(await formatPath(location, hierarchy))
|
||||
}
|
||||
|
||||
async function formatPath (location: Location, hierarchy: Hierarchy): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
|
||||
const app = path[2]
|
||||
|
||||
if (app === chunterId) {
|
||||
const app = await translate(chunter.string.Chat, {})
|
||||
return await formatLocWithThreads(location, hierarchy, app)
|
||||
}
|
||||
|
||||
if (app === notificationId) {
|
||||
const app = await translate(notification.string.Inbox, {})
|
||||
return await formatLocWithThreads(location, hierarchy, app)
|
||||
}
|
||||
|
||||
if (app === timeId) {
|
||||
return await formatTimeLoc(location, hierarchy)
|
||||
}
|
||||
|
||||
if (app === loveId) {
|
||||
const app = await translate(love.string.Office, {})
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
if (app === contactId) {
|
||||
return await formatContactsLoc(location)
|
||||
}
|
||||
|
||||
if (app === recruitId) {
|
||||
return await formatRecruitLoc(location, hierarchy)
|
||||
}
|
||||
|
||||
if (app === trackerId) {
|
||||
return await formatTrackerLoc(location, hierarchy)
|
||||
}
|
||||
|
||||
if (app === documentId) {
|
||||
return await formatDocumentLoc(location, hierarchy)
|
||||
}
|
||||
|
||||
if (app === 'team') {
|
||||
const app = await translate(time.string.Team, {})
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
if (app === driveId) {
|
||||
return await formatDriveLoc(location)
|
||||
}
|
||||
|
||||
return await formatDefaultLoc(location)
|
||||
}
|
||||
|
||||
async function navigateToApp (app: string): Promise<MarkupNode[]> {
|
||||
const workbench = await translate(analyticsCollector.string.Workbench, {})
|
||||
|
||||
return [toText(workbench, 'bold'), toText(app)]
|
||||
}
|
||||
|
||||
async function navigateToSpecial (app: string, special: string, id?: string): Promise<MarkupNode[]> {
|
||||
const openSpecial = await translate(analyticsCollector.string.OpenSpecial, { special })
|
||||
|
||||
return [
|
||||
toText(app, 'bold'),
|
||||
toText(': ', 'bold'),
|
||||
toText(openSpecial),
|
||||
...(id !== undefined ? [toText(' '), toText(id, 'code')] : [])
|
||||
]
|
||||
}
|
||||
|
||||
async function formatLocWithThreads (location: Location, hierarchy: Hierarchy, app: string): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
const [_id, _class] = decodeURIComponent(path[3]).split('|')
|
||||
|
||||
if (_id === undefined || _class === undefined) {
|
||||
return await navigateToSpecial(app, path[3])
|
||||
}
|
||||
|
||||
const label = await translate(hierarchy.getClass(_class as Ref<Class<Doc>>).label, {})
|
||||
const thread = await translate(chunter.string.Thread, {})
|
||||
|
||||
const openSpecial = await translate(analyticsCollector.string.OpenSpecial, { special: thread })
|
||||
|
||||
return [
|
||||
...(await navigateToSpecial(app, label, _id)),
|
||||
...(path[4] !== undefined ? [toText(', '), toText(openSpecial), toText(path[4], 'code')] : [])
|
||||
]
|
||||
}
|
||||
|
||||
async function formatTimeLoc (location: Location, hierarchy: Hierarchy): Promise<MarkupNode[]> {
|
||||
const { fragment } = location
|
||||
const app = await translate(time.string.Planner, {})
|
||||
|
||||
if (fragment === undefined || fragment === '') {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
const [, _id, _class] = decodeURIComponent(fragment).split('|')
|
||||
|
||||
if (_id === undefined || _class === undefined) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
const label = await translate(hierarchy.getClass(_class as Ref<Class<Doc>>).label, {})
|
||||
|
||||
return await navigateToSpecial(app, label, _id)
|
||||
}
|
||||
|
||||
async function formatRecruitLoc (location: Location, hierarchy: Hierarchy): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
const app = await translate(recruit.string.RecruitApplication, {})
|
||||
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
const applicationClass = hierarchy.getClass(recruit.class.Applicant)
|
||||
const vacancyClass = hierarchy.getClass(recruit.class.Vacancy)
|
||||
const reviewClass = hierarchy.getClass(recruit.class.Review)
|
||||
const talentClass = hierarchy.getClass(recruit.mixin.Candidate)
|
||||
const vacancyListClass = hierarchy.getClass(recruit.mixin.VacancyList)
|
||||
|
||||
const [prefix] = path[3].split('-')
|
||||
let label: string | undefined
|
||||
|
||||
if (prefix === applicationClass.shortLabel) {
|
||||
label = await translate(applicationClass.label, {})
|
||||
}
|
||||
|
||||
if (prefix === vacancyClass.shortLabel) {
|
||||
label = await translate(vacancyClass.label, {})
|
||||
}
|
||||
|
||||
if (prefix === reviewClass.shortLabel) {
|
||||
label = await translate(reviewClass.label, {})
|
||||
}
|
||||
|
||||
if (prefix === talentClass.shortLabel) {
|
||||
label = await translate(talentClass.label, {})
|
||||
}
|
||||
|
||||
if (prefix === vacancyListClass.shortLabel) {
|
||||
label = await translate(vacancyListClass.label, {})
|
||||
}
|
||||
|
||||
if (label !== undefined) {
|
||||
return await navigateToSpecial(app, label, path[3])
|
||||
}
|
||||
|
||||
return await navigateToSpecial(app, path[3])
|
||||
}
|
||||
|
||||
async function formatTrackerLoc (location: Location, hierarchy: Hierarchy): Promise<MarkupNode[]> {
|
||||
const { path, fragment } = location
|
||||
const app = await translate(tracker.string.TrackerApplication, {})
|
||||
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
if (path.length === 4) {
|
||||
const issue = await translate(tracker.string.Issue, {})
|
||||
return await navigateToSpecial(app, issue, path[3])
|
||||
}
|
||||
|
||||
const [, _id, _class] = fragment !== undefined ? decodeURIComponent(fragment).split('|') : []
|
||||
|
||||
if (_id === undefined || _class === undefined) {
|
||||
const toSpecialLabel = await navigateToSpecial(app, path[4])
|
||||
const inProject = await translate(analyticsCollector.string.InProject, {})
|
||||
return [...toSpecialLabel, toText(inProject), toText(path[3], 'code')]
|
||||
}
|
||||
|
||||
const clazz = hierarchy.getClass(_class as Ref<Class<Doc>>)
|
||||
const label = await translate(clazz.label, {})
|
||||
const openSpecial = await translate(analyticsCollector.string.OpenSpecial, { special: label })
|
||||
const inProject = await translate(analyticsCollector.string.InProject, {})
|
||||
|
||||
return [
|
||||
toText(app, 'bold'),
|
||||
toText(': ', 'bold'),
|
||||
toText(openSpecial),
|
||||
toText(_id, 'code'),
|
||||
toText(inProject),
|
||||
toText(path[3], 'code')
|
||||
]
|
||||
}
|
||||
|
||||
async function formatDriveLoc (location: Location): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
const app = await translate(drive.string.Drive, {})
|
||||
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
if (path.length === 4) {
|
||||
return await navigateToSpecial(app, path[3])
|
||||
}
|
||||
|
||||
const label = await translate(drive.string.Folder, {})
|
||||
|
||||
return await navigateToSpecial(app, label, path[4])
|
||||
}
|
||||
|
||||
async function formatContactsLoc (location: Location): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
const app = await translate(contact.string.Contacts, {})
|
||||
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
const spaces = ['employees', 'contacts', 'persons', 'companies']
|
||||
|
||||
if (spaces.includes(path[3])) {
|
||||
return await navigateToSpecial(app, path[3])
|
||||
}
|
||||
|
||||
return [
|
||||
toText(app, 'bold'),
|
||||
toText(': ', 'bold'),
|
||||
toText(await translate(analyticsCollector.string.Open, {})),
|
||||
toText(path[3], 'code')
|
||||
]
|
||||
}
|
||||
|
||||
async function formatDocumentLoc (location: Location, hierarchy: Hierarchy): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
const app = await translate(document.string.Documents, {})
|
||||
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app)
|
||||
}
|
||||
|
||||
const clazz = hierarchy.getClass(document.class.Document)
|
||||
const label = await translate(clazz.label, {})
|
||||
return await navigateToSpecial(app, label, path[3])
|
||||
}
|
||||
|
||||
async function formatDefaultLoc (location: Location, app?: string): Promise<MarkupNode[]> {
|
||||
const { path } = location
|
||||
if (path.length < 4) {
|
||||
return await navigateToApp(app ?? path[2])
|
||||
}
|
||||
|
||||
return await navigateToSpecial(app ?? path[2], path[3])
|
||||
}
|
||||
|
||||
export interface Location {
|
||||
path: string[]
|
||||
fragment?: string
|
||||
}
|
||||
|
||||
export function parseLocation (location: URL): Location {
|
||||
return {
|
||||
path: parsePath(location.pathname),
|
||||
fragment: parseHash(location.hash)
|
||||
}
|
||||
}
|
||||
|
||||
function parsePath (path: string): string[] {
|
||||
const split = path.split('/').map((ps) => decodeURIComponent(ps))
|
||||
if (split.length >= 1) {
|
||||
if (split[0] === '') {
|
||||
split.splice(0, 1)
|
||||
}
|
||||
}
|
||||
if (split.length >= 1) {
|
||||
if (split[split.length - 1] === '') {
|
||||
split.splice(split.length - 1, 1)
|
||||
}
|
||||
}
|
||||
return split
|
||||
}
|
||||
|
||||
function parseHash (hash: string): string {
|
||||
if (hash.startsWith('#')) {
|
||||
return decodeURIComponent(hash.substring(1))
|
||||
}
|
||||
return decodeURIComponent(hash)
|
||||
}
|
||||
|
||||
export function getOnboardingMessage (account: AccountUuid, workspace: string, name: string): Markup {
|
||||
const nodes: MarkupNode[] = [
|
||||
toText('New user for onboarding: '),
|
||||
toText('name', 'bold'),
|
||||
toText(' - '),
|
||||
toText(name),
|
||||
toText(', '),
|
||||
toText('account', 'bold'),
|
||||
toText(' - '),
|
||||
toText(account),
|
||||
toText(', '),
|
||||
toText('workspace', 'bold'),
|
||||
toText(' - '),
|
||||
toText(workspace)
|
||||
]
|
||||
|
||||
return toMarkup(nodes)
|
||||
}
|
||||
|
||||
const eventsToSkip = [
|
||||
'Fetch workspace',
|
||||
'Create Tab',
|
||||
'Update Tab',
|
||||
'document.Opened',
|
||||
'Create Message',
|
||||
'chunter.MessageCreated',
|
||||
'Create Time',
|
||||
'Create Tag',
|
||||
'SetCollectionItems'
|
||||
]
|
||||
|
||||
const sidebarEvents = [WorkbenchEvents.SidebarOpenWidget, WorkbenchEvents.SidebarCloseWidget] as string[]
|
||||
@@ -1,78 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { analyticsCollectorId } from '@hcengineering/analytics-collector'
|
||||
import { calendarId } from '@hcengineering/calendar'
|
||||
import { chunterId } from '@hcengineering/chunter'
|
||||
import { contactId } from '@hcengineering/contact'
|
||||
import { coreId } from '@hcengineering/core'
|
||||
import { documentId } from '@hcengineering/document'
|
||||
import { driveId } from '@hcengineering/drive'
|
||||
import { hrId } from '@hcengineering/hr'
|
||||
import { leadId } from '@hcengineering/lead'
|
||||
import { loveId } from '@hcengineering/love'
|
||||
import { notificationId } from '@hcengineering/notification'
|
||||
import { preferenceId } from '@hcengineering/preference'
|
||||
import { recruitId } from '@hcengineering/recruit'
|
||||
import { settingId } from '@hcengineering/setting'
|
||||
import { timeId } from '@hcengineering/time'
|
||||
import { trackerId } from '@hcengineering/tracker'
|
||||
import { viewId } from '@hcengineering/view'
|
||||
import { workbenchId } from '@hcengineering/workbench'
|
||||
|
||||
import analyticsCollectorEn from '@hcengineering/analytics-collector-assets/lang/en.json'
|
||||
import calendarEn from '@hcengineering/calendar-assets/lang/en.json'
|
||||
import chunterEn from '@hcengineering/chunter-assets/lang/en.json'
|
||||
import contactEn from '@hcengineering/contact-assets/lang/en.json'
|
||||
import coreEng from '@hcengineering/core/lang/en.json'
|
||||
import documentEn from '@hcengineering/document-assets/lang/en.json'
|
||||
import driveEn from '@hcengineering/drive-assets/lang/en.json'
|
||||
import hrEn from '@hcengineering/hr-assets/lang/en.json'
|
||||
import leadEn from '@hcengineering/lead-assets/lang/en.json'
|
||||
import loveEn from '@hcengineering/love-assets/lang/en.json'
|
||||
import notificationEn from '@hcengineering/notification-assets/lang/en.json'
|
||||
import platformEng from '@hcengineering/platform/lang/en.json'
|
||||
import preferenceEn from '@hcengineering/preference-assets/lang/en.json'
|
||||
import recruitEn from '@hcengineering/recruit-assets/lang/en.json'
|
||||
import settingEn from '@hcengineering/setting-assets/lang/en.json'
|
||||
import timeEn from '@hcengineering/time-assets/lang/en.json'
|
||||
import trackerEn from '@hcengineering/tracker-assets/lang/en.json'
|
||||
import viewEn from '@hcengineering/view-assets/lang/en.json'
|
||||
import workbenchEn from '@hcengineering/workbench-assets/lang/en.json'
|
||||
|
||||
import { addStringsLoader, platformId } from '@hcengineering/platform'
|
||||
|
||||
export function registerLoaders (): void {
|
||||
addStringsLoader(coreId, async (lang: string) => coreEng)
|
||||
addStringsLoader(platformId, async (lang: string) => platformEng)
|
||||
|
||||
addStringsLoader(analyticsCollectorId, async (lang: string) => analyticsCollectorEn)
|
||||
addStringsLoader(calendarId, async (lang: string) => calendarEn)
|
||||
addStringsLoader(chunterId, async (lang: string) => chunterEn)
|
||||
addStringsLoader(contactId, async (lang: string) => contactEn)
|
||||
addStringsLoader(documentId, async (lang: string) => documentEn)
|
||||
addStringsLoader(driveId, async (lang: string) => driveEn)
|
||||
addStringsLoader(hrId, async (lang: string) => hrEn)
|
||||
addStringsLoader(leadId, async (lang: string) => leadEn)
|
||||
addStringsLoader(loveId, async (lang: string) => loveEn)
|
||||
addStringsLoader(notificationId, async (lang: string) => notificationEn)
|
||||
addStringsLoader(preferenceId, async (lang: string) => preferenceEn)
|
||||
addStringsLoader(recruitId, async (lang: string) => recruitEn)
|
||||
addStringsLoader(settingId, async (lang: string) => settingEn)
|
||||
addStringsLoader(timeId, async (lang: string) => timeEn)
|
||||
addStringsLoader(trackerId, async (lang: string) => trackerEn)
|
||||
addStringsLoader(viewId, async (lang: string) => viewEn)
|
||||
addStringsLoader(workbenchId, async (lang: string) => workbenchEn)
|
||||
}
|
||||
@@ -13,6 +13,10 @@
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import { setMetadata } from '@hcengineering/platform'
|
||||
import serverToken from '@hcengineering/server-token'
|
||||
import serverClient from '@hcengineering/server-client'
|
||||
|
||||
import { Analytics } from '@hcengineering/analytics'
|
||||
import { SplitLogger, configureAnalytics } from '@hcengineering/analytics-service'
|
||||
import { MeasureMetricsContext, newMetrics } from '@hcengineering/core'
|
||||
@@ -20,6 +24,7 @@ import { join } from 'path'
|
||||
|
||||
import config from './config'
|
||||
import { initStatisticsContext } from '@hcengineering/server-core'
|
||||
import { createServer, listen } from './server'
|
||||
|
||||
const ctx = initStatisticsContext('analytics-collector', {
|
||||
factory: () =>
|
||||
@@ -39,36 +44,27 @@ configureAnalytics(config.SentryDSN, config)
|
||||
Analytics.setTag('application', 'analytics-collector-service')
|
||||
|
||||
export const main = async (): Promise<void> => {
|
||||
ctx.info('Analytics collector service is not implemented yet')
|
||||
process.exit()
|
||||
// setMetadata(serverToken.metadata.Secret, config.Secret)
|
||||
// setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl)
|
||||
// setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
|
||||
//
|
||||
// ctx.info('Analytics service started', {
|
||||
// accountsUrl: config.AccountsUrl
|
||||
// })
|
||||
//
|
||||
// registerLoaders()
|
||||
//
|
||||
// const db = await getDB()
|
||||
// const collector = new Collector(ctx, db)
|
||||
//
|
||||
// const app = createServer(collector)
|
||||
// const server = listen(app, config.Port)
|
||||
//
|
||||
// const shutdown = (): void => {
|
||||
// void collector.close()
|
||||
// void closeDB()
|
||||
// server.close(() => process.exit())
|
||||
// }
|
||||
//
|
||||
// process.on('SIGINT', shutdown)
|
||||
// process.on('SIGTERM', shutdown)
|
||||
// process.on('uncaughtException', (e) => {
|
||||
// console.error(e)
|
||||
// })
|
||||
// process.on('unhandledRejection', (e) => {
|
||||
// console.error(e)
|
||||
// })
|
||||
setMetadata(serverToken.metadata.Secret, config.Secret)
|
||||
setMetadata(serverClient.metadata.Endpoint, config.AccountsUrl)
|
||||
setMetadata(serverClient.metadata.UserAgent, config.ServiceID)
|
||||
|
||||
ctx.info('Analytics service started', {
|
||||
accountsUrl: config.AccountsUrl
|
||||
})
|
||||
|
||||
const app = createServer()
|
||||
const server = listen(app, config.Port)
|
||||
|
||||
const shutdown = (): void => {
|
||||
server.close(() => process.exit())
|
||||
}
|
||||
|
||||
process.on('SIGINT', shutdown)
|
||||
process.on('SIGTERM', shutdown)
|
||||
process.on('uncaughtException', (e) => {
|
||||
console.error(e)
|
||||
})
|
||||
process.on('unhandledRejection', (e) => {
|
||||
console.error(e)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,11 +17,10 @@ import { Token } from '@hcengineering/server-token'
|
||||
import cors from 'cors'
|
||||
import express, { type Express, type NextFunction, type Request, type Response } from 'express'
|
||||
import { type Server } from 'http'
|
||||
import { AnalyticEvent } from '@hcengineering/analytics-collector'
|
||||
import { AnalyticEvent, AnalyticEventType } from '@hcengineering/analytics-collector'
|
||||
import { extractToken } from '@hcengineering/server-client'
|
||||
|
||||
import config from './config'
|
||||
import { ApiError } from './error'
|
||||
import { Collector } from './collector'
|
||||
|
||||
type AsyncRequestHandler = (req: Request, res: Response, token: Token, next: NextFunction) => Promise<void>
|
||||
|
||||
@@ -48,23 +47,169 @@ const wrapRequest = (fn: AsyncRequestHandler) => (req: Request, res: Response, n
|
||||
|
||||
function isContentValid (body: any[]): boolean {
|
||||
return !body.some((it) => {
|
||||
if (it == null) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!('event' in it)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (!('params' in it)) {
|
||||
return true
|
||||
}
|
||||
|
||||
if (it == null) return true
|
||||
if (!('event' in it)) return true
|
||||
if (!('properties' in it)) return true
|
||||
return !('timestamp' in it)
|
||||
})
|
||||
}
|
||||
|
||||
export function createServer (collector: Collector): Express {
|
||||
function getRecordsByType (event: AnalyticEvent): Record<string, any> {
|
||||
switch (event.event as AnalyticEventType) {
|
||||
case AnalyticEventType.SetTag:
|
||||
return {
|
||||
event: '$set',
|
||||
event_type: 'set_property'
|
||||
}
|
||||
case AnalyticEventType.SetUser:
|
||||
return {
|
||||
event: '$identify',
|
||||
event_type: 'identify'
|
||||
}
|
||||
case AnalyticEventType.SetGroup:
|
||||
return {
|
||||
event: '$groupidentify',
|
||||
event_type: 'groupidentify'
|
||||
}
|
||||
case AnalyticEventType.SetAlias:
|
||||
return {
|
||||
event: '$create_alias',
|
||||
$alias: event.properties.alias,
|
||||
event_type: 'create_alias'
|
||||
}
|
||||
case AnalyticEventType.Navigation:
|
||||
return {
|
||||
event: '$pageview',
|
||||
event_type: 'pageview'
|
||||
}
|
||||
case AnalyticEventType.Error:
|
||||
return {
|
||||
event: '$exception',
|
||||
event_type: 'exception'
|
||||
}
|
||||
case AnalyticEventType.CustomEvent:
|
||||
return {
|
||||
event:
|
||||
typeof event.properties.event === 'string' && event.properties.event.length > 0
|
||||
? event.properties.event
|
||||
: 'custom_event'
|
||||
}
|
||||
default:
|
||||
return {
|
||||
event: typeof event.event === 'string' && event.event.length > 0 ? event.event : 'unknown_event'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function preparePostHogEvent (event: AnalyticEvent, req: Request): Record<string, any> {
|
||||
let errorMessage = 'Unknown error'
|
||||
let errorType = 'Error'
|
||||
|
||||
if (event.event === AnalyticEventType.Error) {
|
||||
if (event.properties.error_message != null && String(event.properties.error_message).trim() !== '') {
|
||||
errorMessage = String(event.properties.error_message).trim()
|
||||
} else if (event.properties.$error != null && String(event.properties.$error).trim() !== '') {
|
||||
errorMessage = String(event.properties.$error).trim()
|
||||
} else if (event.properties.message != null && String(event.properties.message).trim() !== '') {
|
||||
errorMessage = String(event.properties.message).trim()
|
||||
} else if (event.properties.error != null && String(event.properties.error).trim() !== '') {
|
||||
errorMessage = String(event.properties.error).trim()
|
||||
}
|
||||
|
||||
if (event.properties.error_type != null && String(event.properties.error_type).trim() !== '') {
|
||||
errorType = String(event.properties.error_type).trim()
|
||||
} else if (event.properties.$error_type != null && String(event.properties.$error_type).trim() !== '') {
|
||||
errorType = String(event.properties.$error_type).trim()
|
||||
}
|
||||
}
|
||||
|
||||
const baseEvent: Record<string, any> = event.properties
|
||||
const recordsByType = getRecordsByType(event)
|
||||
for (const [key, value] of Object.entries(recordsByType)) {
|
||||
baseEvent[key] = value
|
||||
}
|
||||
baseEvent.$ip = req.ip
|
||||
baseEvent.timestamp = event.properties.$timestamp ?? new Date(event.timestamp).toISOString()
|
||||
|
||||
if (event.event === AnalyticEventType.SetAlias && typeof event.properties.alias === 'string') {
|
||||
const aliasProperties: Record<string, any> = {
|
||||
alias: event.properties.alias,
|
||||
$anon_distinct_id: event.distinct_id,
|
||||
...baseEvent
|
||||
}
|
||||
const aliasEvent: Record<string, any> = {
|
||||
event: '$create_alias',
|
||||
distinct_id: event.distinct_id,
|
||||
properties: aliasProperties,
|
||||
timestamp: new Date(event.timestamp).toISOString()
|
||||
}
|
||||
aliasEvent.properties.$original_timestamp = event.timestamp
|
||||
|
||||
if ('event' in aliasEvent.properties) {
|
||||
aliasEvent.properties.$original_event_type = aliasEvent.properties.event
|
||||
delete aliasEvent.properties.event
|
||||
}
|
||||
|
||||
return aliasEvent
|
||||
}
|
||||
|
||||
if (event.event === AnalyticEventType.Error) {
|
||||
if (baseEvent.$exception_list == null && baseEvent.$exception == null) {
|
||||
const exceptionList = []
|
||||
|
||||
const exceptionData: Record<string, any> = {
|
||||
type: errorType !== '' ? errorType : 'Error',
|
||||
value: errorMessage,
|
||||
message: errorMessage
|
||||
}
|
||||
|
||||
baseEvent.$exception_message = errorMessage
|
||||
baseEvent.$exception_type = errorType
|
||||
|
||||
exceptionList.push(exceptionData)
|
||||
|
||||
baseEvent.$exception_list = exceptionList
|
||||
|
||||
delete baseEvent.$error
|
||||
delete baseEvent.$error_type
|
||||
delete baseEvent.$error_stack
|
||||
delete baseEvent.error
|
||||
delete baseEvent.error_message
|
||||
delete baseEvent.error_type
|
||||
delete baseEvent.error_stack
|
||||
delete baseEvent.message
|
||||
delete baseEvent.stack
|
||||
delete baseEvent.stackTrace
|
||||
}
|
||||
}
|
||||
|
||||
const eventName = recordsByType.event ?? event.event ?? 'unknown_event'
|
||||
|
||||
const regularEventForPostHog: Record<string, any> = {
|
||||
event: eventName,
|
||||
distinct_id: event.distinct_id,
|
||||
properties: { ...baseEvent },
|
||||
timestamp: new Date(event.timestamp).toISOString()
|
||||
}
|
||||
regularEventForPostHog.properties.$original_timestamp = event.timestamp
|
||||
|
||||
if ('event' in regularEventForPostHog.properties) {
|
||||
regularEventForPostHog.properties.$original_event_type = regularEventForPostHog.properties.event
|
||||
delete regularEventForPostHog.properties.event
|
||||
}
|
||||
|
||||
if (
|
||||
typeof event.properties.$anonymous_id === 'string' &&
|
||||
event.properties.$anonymous_id !== event.distinct_id &&
|
||||
event.distinct_id !== event.properties.$anonymous_id
|
||||
) {
|
||||
regularEventForPostHog.properties.$anon_distinct_id = event.properties.$anonymous_id
|
||||
}
|
||||
|
||||
return regularEventForPostHog
|
||||
}
|
||||
|
||||
export function createServer (): Express {
|
||||
const app = express()
|
||||
app.use(cors())
|
||||
app.use(express.json())
|
||||
@@ -82,10 +227,39 @@ export function createServer (collector: Collector): Express {
|
||||
|
||||
const events: AnalyticEvent[] = req.body
|
||||
|
||||
collector.collect(events, token)
|
||||
const posthogEvents = events.map((event) => {
|
||||
return preparePostHogEvent(event, req)
|
||||
})
|
||||
|
||||
const payload = {
|
||||
api_key: config.PostHogAPI,
|
||||
batch: posthogEvents.reverse()
|
||||
}
|
||||
|
||||
try {
|
||||
const response = await fetch(`${config.PostHogHost}/batch/`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'User-Agent': 'Analytics-Collector/1.0'
|
||||
},
|
||||
body: JSON.stringify(payload)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text()
|
||||
console.error(`PostHog API error: ${response.status} ${response.statusText}`, errorText)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to send events to PostHog:', error)
|
||||
}
|
||||
|
||||
res.status(200)
|
||||
res.json({})
|
||||
res.json({
|
||||
received: events.length,
|
||||
processed: posthogEvents.length,
|
||||
timestamp: new Date().toISOString()
|
||||
})
|
||||
})
|
||||
)
|
||||
|
||||
|
||||
@@ -1,243 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import analyticsCollector, { AnalyticEvent, OnboardingChannel } from '@hcengineering/analytics-collector'
|
||||
import chunter, { Channel, ChatMessage } from '@hcengineering/chunter'
|
||||
import { getPrimarySocialId, type Person } from '@hcengineering/contact'
|
||||
import core, {
|
||||
AccountUuid,
|
||||
Doc,
|
||||
generateId,
|
||||
PersonId,
|
||||
RateLimiter,
|
||||
Ref,
|
||||
systemAccountUuid,
|
||||
Tx,
|
||||
TxOperations,
|
||||
TxProcessor,
|
||||
TxUpdateDoc,
|
||||
WorkspaceInfoWithStatus,
|
||||
WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
import {
|
||||
createGeneralOnboardingChannel,
|
||||
getOrCreateOnboardingChannel
|
||||
} from '@hcengineering/server-analytics-collector-resources'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
|
||||
import { getClient as getAccountClient } from '@hcengineering/account-client'
|
||||
import { Collection } from 'mongodb'
|
||||
import { eventToMarkup, getOnboardingMessage } from './format'
|
||||
import { OnboardingMessage } from './types'
|
||||
import config from './config'
|
||||
import { WorkspaceClient } from './workspaceClient'
|
||||
|
||||
export class SupportWsClient extends WorkspaceClient {
|
||||
channelIdByKey = new Map<string, Ref<OnboardingChannel>>()
|
||||
personIdByPerson = new Map<Ref<Person>, PersonId>()
|
||||
|
||||
rate = new RateLimiter(1)
|
||||
|
||||
generalChannel: Channel | undefined = undefined
|
||||
|
||||
async initClient (): Promise<TxOperations> {
|
||||
const client = await super.initClient()
|
||||
|
||||
this.generalChannel = await createGeneralOnboardingChannel(this.ctx, client)
|
||||
if (this.client != null) {
|
||||
this.client.notify = (...txes) => {
|
||||
this.handleTx(client, ...txes)
|
||||
}
|
||||
}
|
||||
|
||||
return client
|
||||
}
|
||||
|
||||
private handleTx (client: TxOperations, ...txes: Tx[]): void {
|
||||
for (const tx of txes) {
|
||||
switch (tx._class) {
|
||||
case core.class.TxUpdateDoc: {
|
||||
this.txUpdateDoc(client, tx as TxUpdateDoc<Doc>)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private txUpdateDoc (client: TxOperations, tx: TxUpdateDoc<Doc>): void {
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
if (
|
||||
hierarchy.isDerived(tx.objectClass, chunter.class.Channel) &&
|
||||
tx.objectId === analyticsCollector.space.GeneralOnboardingChannel
|
||||
) {
|
||||
if (this.generalChannel == null) {
|
||||
return
|
||||
}
|
||||
this.generalChannel = TxProcessor.updateDoc2Doc(this.generalChannel, tx as TxUpdateDoc<Channel>)
|
||||
}
|
||||
}
|
||||
|
||||
private async getOrCreateOnboardingChannel (
|
||||
client: TxOperations,
|
||||
workspace: WorkspaceUuid,
|
||||
account: AccountUuid,
|
||||
person: Person
|
||||
): Promise<{
|
||||
channelId: Ref<OnboardingChannel> | undefined
|
||||
isCreated: boolean
|
||||
workspace?: WorkspaceInfoWithStatus
|
||||
}> {
|
||||
const key = `${account}-${workspace}`
|
||||
|
||||
if (this.channelIdByKey.has(key)) {
|
||||
return {
|
||||
channelId: this.channelIdByKey.get(key),
|
||||
isCreated: false
|
||||
}
|
||||
}
|
||||
|
||||
const token = generateToken(systemAccountUuid, workspace, { service: 'analytics-collector' })
|
||||
const wsInfo = await getAccountClient(config.AccountsUrl, token).getWorkspaceInfo()
|
||||
|
||||
if (wsInfo === undefined) {
|
||||
this.ctx.error('Failed to get workspace info', { workspace })
|
||||
return {
|
||||
channelId: undefined,
|
||||
isCreated: false
|
||||
}
|
||||
}
|
||||
|
||||
const [channel, isCreated] = await getOrCreateOnboardingChannel(
|
||||
this.ctx,
|
||||
client,
|
||||
account,
|
||||
{
|
||||
workspaceId: workspace,
|
||||
workspaceName: wsInfo?.name ?? '',
|
||||
workspaceUrl: wsInfo?.url ?? ''
|
||||
},
|
||||
person
|
||||
)
|
||||
|
||||
if (channel !== undefined) {
|
||||
this.channelIdByKey.set(key, channel)
|
||||
}
|
||||
|
||||
return {
|
||||
channelId: channel,
|
||||
isCreated,
|
||||
workspace: wsInfo
|
||||
}
|
||||
}
|
||||
|
||||
async getPersonId (person: Ref<Person>): Promise<PersonId | undefined> {
|
||||
const cachedPersonId = this.personIdByPerson.get(person)
|
||||
const personId = cachedPersonId ?? (await getPrimarySocialId(await this.opClient, person))
|
||||
|
||||
if (personId === undefined) {
|
||||
console.error('Person id not found for person', person)
|
||||
return
|
||||
}
|
||||
|
||||
if (cachedPersonId === undefined) {
|
||||
this.personIdByPerson.set(person, personId)
|
||||
}
|
||||
|
||||
return personId
|
||||
}
|
||||
|
||||
async processEvents (
|
||||
events: AnalyticEvent[],
|
||||
workspace: WorkspaceUuid,
|
||||
person: Person,
|
||||
onboardingMessages: Collection<OnboardingMessage>
|
||||
): Promise<void> {
|
||||
const client = await this.opClient
|
||||
const op = client.apply(undefined, 'processEvents')
|
||||
const wsString = workspace
|
||||
const account = person.personUuid as AccountUuid
|
||||
|
||||
if (account === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
const {
|
||||
channelId,
|
||||
isCreated,
|
||||
workspace: workspaceInfo
|
||||
} = await this.getOrCreateOnboardingChannel(op, wsString, account, person)
|
||||
|
||||
if (channelId === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
if (isCreated) {
|
||||
const messageId = generateId<ChatMessage>()
|
||||
await op.addCollection(
|
||||
chunter.class.ChatMessage,
|
||||
analyticsCollector.space.GeneralOnboardingChannel,
|
||||
analyticsCollector.space.GeneralOnboardingChannel,
|
||||
chunter.class.Channel,
|
||||
'messages',
|
||||
{ message: getOnboardingMessage(account, workspaceInfo?.url ?? wsString, person.name) },
|
||||
messageId
|
||||
)
|
||||
|
||||
await onboardingMessages.insertOne({ messageId, channelId })
|
||||
}
|
||||
|
||||
const hierarchy = client.getHierarchy()
|
||||
|
||||
for (const event of events) {
|
||||
const markup = await eventToMarkup(event, hierarchy, client)
|
||||
|
||||
if (markup === undefined) {
|
||||
continue
|
||||
}
|
||||
|
||||
await op.addCollection(
|
||||
chunter.class.ChatMessage,
|
||||
channelId,
|
||||
channelId,
|
||||
chunter.class.Channel,
|
||||
'messages',
|
||||
{ message: markup },
|
||||
undefined,
|
||||
event.timestamp
|
||||
)
|
||||
}
|
||||
|
||||
await op.commit()
|
||||
}
|
||||
|
||||
async pushEvents (
|
||||
events: AnalyticEvent[],
|
||||
workspace: WorkspaceUuid,
|
||||
person: Person,
|
||||
onboardingMessages: Collection<OnboardingMessage>
|
||||
): Promise<void> {
|
||||
const channelKey = `${person._id}-${workspace}`
|
||||
|
||||
if (this.channelIdByKey.has(channelKey)) {
|
||||
await this.processEvents(events, workspace, person, onboardingMessages)
|
||||
} else {
|
||||
// If we dont have OnboardingChannel we should call it sync to prevent multiple channels for the same user and workspace
|
||||
await this.rate.add(async () => {
|
||||
await this.processEvents(events, workspace, person, onboardingMessages)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,8 +0,0 @@
|
||||
import { ChatMessage } from '@hcengineering/chunter'
|
||||
import { Ref } from '@hcengineering/core'
|
||||
import { OnboardingChannel } from '@hcengineering/analytics-collector'
|
||||
|
||||
export interface OnboardingMessage {
|
||||
messageId: Ref<ChatMessage>
|
||||
channelId: Ref<OnboardingChannel>
|
||||
}
|
||||
@@ -1,75 +0,0 @@
|
||||
//
|
||||
// Copyright © 2024 Hardcore Engineering Inc.
|
||||
//
|
||||
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
|
||||
// you may not use this file except in compliance with the License. You may
|
||||
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
|
||||
//
|
||||
// Unless required by applicable law or agreed to in writing, software
|
||||
// distributed under the License is distributed on an "AS IS" BASIS,
|
||||
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
//
|
||||
// See the License for the specific language governing permissions and
|
||||
// limitations under the License.
|
||||
//
|
||||
|
||||
import core, {
|
||||
Client,
|
||||
MeasureContext,
|
||||
PersonUuid,
|
||||
systemAccountUuid,
|
||||
TxOperations,
|
||||
WorkspaceUuid
|
||||
} from '@hcengineering/core'
|
||||
import { generateToken } from '@hcengineering/server-token'
|
||||
import contact, { Person } from '@hcengineering/contact'
|
||||
|
||||
import { connectPlatform } from './platform'
|
||||
|
||||
export class WorkspaceClient {
|
||||
client: Client | undefined
|
||||
opClient: Promise<TxOperations> | TxOperations
|
||||
|
||||
constructor (
|
||||
readonly ctx: MeasureContext,
|
||||
readonly workspace: WorkspaceUuid
|
||||
) {
|
||||
this.opClient = this.initClient()
|
||||
void this.opClient.then((opClient) => {
|
||||
this.opClient = opClient
|
||||
})
|
||||
}
|
||||
|
||||
protected async initClient (): Promise<TxOperations> {
|
||||
const token = generateToken(systemAccountUuid, this.workspace, {
|
||||
client: 'analytics',
|
||||
service: 'analytics-collector'
|
||||
})
|
||||
this.client = await connectPlatform(token)
|
||||
|
||||
return new TxOperations(this.client, core.account.System)
|
||||
}
|
||||
|
||||
async getPerson (personUuid: PersonUuid): Promise<Person | undefined> {
|
||||
const opClient = await this.opClient
|
||||
return await opClient.findOne(contact.class.Person, {
|
||||
personUuid
|
||||
})
|
||||
}
|
||||
|
||||
async close (): Promise<void> {
|
||||
if (this.client === undefined) {
|
||||
return
|
||||
}
|
||||
|
||||
await this.client?.close()
|
||||
|
||||
if (this.opClient instanceof Promise) {
|
||||
void this.opClient.then((opClient) => {
|
||||
void opClient.close()
|
||||
})
|
||||
} else {
|
||||
await this.opClient.close()
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user