Returned analytics collector with PostHog integration (#9431)

Signed-off-by: Alexander Platov <alexander.platov@hardcoreeng.com>
This commit is contained in:
Alexander Platov
2025-07-10 21:28:07 +07:00
committed by GitHub
parent a273030c68
commit 881a12cc6e
41 changed files with 1079 additions and 1493 deletions
+79 -18
View File
@@ -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: {}
+1
View File
@@ -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",
+3
View File
@@ -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')
+7 -3
View File
@@ -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 {
+15
View File
@@ -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:
+1
View File
@@ -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 -10
View File
@@ -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 {}
}
-44
View File
@@ -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')
}
}
-49
View File
@@ -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)
}
}
+3 -3
View File
@@ -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'
}
}
+64
View File
@@ -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)
}
}
+21
View File
@@ -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)
}
}
@@ -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
}
+104
View File
@@ -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"]
}
+7 -2
View File
@@ -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 {
+13 -4
View File
@@ -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 })
})
},
+4 -1
View File
@@ -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 {
+5 -5
View File
@@ -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)
+12 -13
View File
@@ -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'
}
+15 -2
View File
@@ -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 -1
View File
@@ -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'
}
+5
View File
@@ -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()
}
}
}