diff --git a/common/config/rush/pnpm-lock.yaml b/common/config/rush/pnpm-lock.yaml index 101b3a218c..1fc1bfe750 100644 --- a/common/config/rush/pnpm-lock.yaml +++ b/common/config/rush/pnpm-lock.yaml @@ -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: {} diff --git a/desktop/package.json b/desktop/package.json index 0c8571579a..18d4258ecf 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -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", diff --git a/desktop/src/ui/platform.ts b/desktop/src/ui/platform.ts index c103cb489a..85089edafc 100644 --- a/desktop/src/ui/platform.ts +++ b/desktop/src/ui/platform.ts @@ -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 { 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') diff --git a/desktop/src/ui/types.ts b/desktop/src/ui/types.ts index a0b415a383..72c7138c84 100644 --- a/desktop/src/ui/types.ts +++ b/desktop/src/ui/types.ts @@ -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 { diff --git a/dev/docker-compose.yaml b/dev/docker-compose.yaml index d0e5f480ca..a46842d40a 100644 --- a/dev/docker-compose.yaml +++ b/dev/docker-compose.yaml @@ -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: diff --git a/dev/prod/package.json b/dev/prod/package.json index 45316a9050..a848cac71b 100644 --- a/dev/prod/package.json +++ b/dev/prod/package.json @@ -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", diff --git a/dev/prod/src/analytics.ts b/dev/prod/src/analytics.ts index 3a040258aa..6d4b42a290 100644 --- a/dev/prod/src/analytics.ts +++ b/dev/prod/src/analytics.ts @@ -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) } \ No newline at end of file diff --git a/dev/prod/src/analytics/analyticsCollector.ts b/dev/prod/src/analytics/analyticsCollector.ts deleted file mode 100644 index 4081b7e6b3..0000000000 --- a/dev/prod/src/analytics/analyticsCollector.ts +++ /dev/null @@ -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 { - 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): 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 {} -} \ No newline at end of file diff --git a/dev/prod/src/analytics/posthog.ts b/dev/prod/src/analytics/posthog.ts deleted file mode 100644 index 10f3f66ac6..0000000000 --- a/dev/prod/src/analytics/posthog.ts +++ /dev/null @@ -1,44 +0,0 @@ -import { type AnalyticProvider } from "@hcengineering/analytics" -import posthog from 'posthog-js' - -export class PosthogAnalyticProvider implements AnalyticProvider { - init(config: Record): 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): void { - posthog.capture(event, params) - } - handleError(error: Error): void { - posthog.capture(error.message) - } - navigate(path: string): void { - posthog.capture('$pageview') - } -} \ No newline at end of file diff --git a/dev/prod/src/analytics/sentry.ts b/dev/prod/src/analytics/sentry.ts deleted file mode 100644 index d09edacb3d..0000000000 --- a/dev/prod/src/analytics/sentry.ts +++ /dev/null @@ -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): 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) - } -} \ No newline at end of file diff --git a/dev/prod/src/platform.ts b/dev/prod/src/platform.ts index b817be1961..e52031f8d6 100644 --- a/dev/prod/src/platform.ts +++ b/dev/prod/src/platform.ts @@ -229,7 +229,7 @@ const configs: Record = { 'dev-worker-local': '/config-worker-local.json' } -const PASSWORD_REQUIREMENTS: Record> = { +const PASSWORD_REQUIREMENTS: Record, Record> = { 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) diff --git a/packages/analytics-providers/.eslintrc.js b/packages/analytics-providers/.eslintrc.js new file mode 100644 index 0000000000..72235dc283 --- /dev/null +++ b/packages/analytics-providers/.eslintrc.js @@ -0,0 +1,7 @@ +module.exports = { + extends: ['./node_modules/@hcengineering/platform-rig/profiles/default/eslint.config.json'], + parserOptions: { + tsconfigRootDir: __dirname, + project: './tsconfig.json' + } +} diff --git a/packages/analytics-providers/package.json b/packages/analytics-providers/package.json new file mode 100644 index 0000000000..57411344a3 --- /dev/null +++ b/packages/analytics-providers/package.json @@ -0,0 +1,64 @@ +{ + "name": "@hcengineering/analytics-providers", + "version": "0.6.0", + "main": "lib/index.js", + "svelte": "src/index.ts", + "types": "types/index.d.ts", + "files": [ + "lib/**/*", + "types/**/*", + "tsconfig.json" + ], + "author": "Hardcore Engineering Inc.", + "license": "EPL-2.0", + "scripts": { + "build": "compile", + "build:watch": "compile", + "format": "format src", + "test": "jest --passWithNoTests --silent", + "_phase:build": "compile transpile src", + "_phase:test": "jest --passWithNoTests --silent", + "_phase:format": "format src", + "_phase:validate": "compile validate" + }, + "devDependencies": { + "cross-env": "~7.0.3", + "@hcengineering/platform-rig": "^0.6.0", + "@types/node": "^22.15.29", + "@typescript-eslint/eslint-plugin": "^6.11.0", + "eslint-plugin-import": "^2.26.0", + "eslint-plugin-promise": "^6.1.1", + "eslint-plugin-n": "^15.4.0", + "eslint": "^8.54.0", + "esbuild": "^0.24.2", + "@typescript-eslint/parser": "^6.11.0", + "eslint-config-standard-with-typescript": "^40.0.0", + "prettier": "^3.1.0", + "typescript": "^5.8.3", + "jest": "^29.7.0", + "ts-jest": "^29.1.1", + "@types/jest": "^29.5.5" + }, + "dependencies": { + "@hcengineering/platform": "^0.6.11", + "@hcengineering/analytics": "^0.6.0", + "@hcengineering/analytics-collector": "^0.6.0", + "@hcengineering/presentation": "^0.6.3", + "@hcengineering/core": "^0.6.32", + "posthog-js": "^1.246.0", + "@sentry/svelte": "^9.22.0", + "ua-parser-js": "^2.0.4", + "@hcengineering/ui": "^0.6.15" + }, + "repository": "https://github.com/hcengineering/platform", + "publishConfig": { + "registry": "https://npm.pkg.github.com" + }, + "exports": { + ".": { + "types": "./types/index.d.ts", + "require": "./lib/index.js", + "import": "./lib/index.js" + } + } +} diff --git a/packages/analytics-providers/src/analyticsCollector.ts b/packages/analytics-providers/src/analyticsCollector.ts new file mode 100644 index 0000000000..02103e54be --- /dev/null +++ b/packages/analytics-providers/src/analyticsCollector.ts @@ -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 | null = null + + init (config: Record): 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 { + 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 = {}, + eventName: string, + overrideDistinctId?: string + ): void { + const currentId = overrideDistinctId ?? (this.isAuthenticated && this.email != null ? this.email : this.anonymousId) + + const baseProperties: Record = { + ...properties, + analytics_collector: true, + $anonymous_id: this.anonymousId, + $is_identified: this.isAuthenticated + } + + const eventMetadata: Record = 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): Record { + const cleanedData: Record = {} + 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): 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 = { 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): 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 { + return this.sendEvents() + } +} diff --git a/packages/analytics-providers/src/configure.ts b/packages/analytics-providers/src/configure.ts new file mode 100644 index 0000000000..b248e8887c --- /dev/null +++ b/packages/analytics-providers/src/configure.ts @@ -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) + } +} diff --git a/packages/analytics-providers/src/index.ts b/packages/analytics-providers/src/index.ts new file mode 100644 index 0000000000..31bc7c2f0d --- /dev/null +++ b/packages/analytics-providers/src/index.ts @@ -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' diff --git a/packages/analytics-providers/src/posthog.ts b/packages/analytics-providers/src/posthog.ts new file mode 100644 index 0000000000..47a9cec847 --- /dev/null +++ b/packages/analytics-providers/src/posthog.ts @@ -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): 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): void { + posthog.capture(event, params) + } + + handleError (error: Error): void { + posthog.capture(error.message) + } + + navigate (path: string): void { + posthog.capture('$pageview') + } +} diff --git a/packages/analytics-providers/src/sentry.ts b/packages/analytics-providers/src/sentry.ts new file mode 100644 index 0000000000..c98c6d48a7 --- /dev/null +++ b/packages/analytics-providers/src/sentry.ts @@ -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): 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) + } +} diff --git a/services/analytics-collector/pod-analytics-collector/src/storage.ts b/packages/analytics-providers/src/types.ts similarity index 50% rename from services/analytics-collector/pod-analytics-collector/src/storage.ts rename to packages/analytics-providers/src/types.ts index d7f406ef60..e995b53068 100644 --- a/services/analytics-collector/pod-analytics-collector/src/storage.ts +++ b/packages/analytics-providers/src/types.ts @@ -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 = 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 } diff --git a/packages/analytics-providers/src/utils.ts b/packages/analytics-providers/src/utils.ts new file mode 100644 index 0000000000..0188d303ea --- /dev/null +++ b/packages/analytics-providers/src/utils.ts @@ -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 { + 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 = {}): Record { + 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 + } +} diff --git a/packages/analytics-providers/tsconfig.json b/packages/analytics-providers/tsconfig.json new file mode 100644 index 0000000000..b5ae22f6e4 --- /dev/null +++ b/packages/analytics-providers/tsconfig.json @@ -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"] +} \ No newline at end of file diff --git a/packages/analytics-service/src/sentry.ts b/packages/analytics-service/src/sentry.ts index 6b2e972bc9..ad3196fcf7 100644 --- a/packages/analytics-service/src/sentry.ts +++ b/packages/analytics-service/src/sentry.ts @@ -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 { diff --git a/packages/analytics/src/index.ts b/packages/analytics/src/index.ts index 99c26f450e..db90fdbec3 100644 --- a/packages/analytics/src/index.ts +++ b/packages/analytics/src/index.ts @@ -7,7 +7,8 @@ import { addEventListener, PlatformEvent, Severity, Status, translate } from '@h export const providers: AnalyticProvider[] = [] export interface AnalyticProvider { init: (config: Record) => 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) => void @@ -17,6 +18,8 @@ export interface AnalyticProvider { } export const Analytics = { + data: {}, + init (provider: AnalyticProvider, config: Record): 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 = {}): void { providers.forEach((provider) => { - provider.handleEvent(event, params) + provider.handleEvent(event, { ...this.data, ...params }) }) }, diff --git a/plugins/analytics-collector/src/types.ts b/plugins/analytics-collector/src/types.ts index 4cdb3c7f35..42a50265b3 100644 --- a/plugins/analytics-collector/src/types.ts +++ b/plugins/analytics-collector/src/types.ts @@ -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 + properties: Record timestamp: number + distinct_id: string } export interface OnboardingChannel extends Channel { diff --git a/plugins/guest-resources/src/connect.ts b/plugins/guest-resources/src/connect.ts index ced8e950d9..fc54ea3beb 100644 --- a/plugins/guest-resources/src/connect.ts +++ b/plugins/guest-resources/src/connect.ts @@ -48,10 +48,10 @@ export async function connect (title: string): Promise { 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 { }) 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 { } if (me !== undefined) { - Analytics.setUser(account) + Analytics.setUser(account, { account }) Analytics.setWorkspace(wsUrl) console.log('login: employee account', me) setCurrentAccount(me) diff --git a/plugins/login-resources/src/utils.ts b/plugins/login-resources/src/utils.ts index 4745c3eb97..eff26efa57 100644 --- a/plugins/login-resources/src/utils.ts +++ b/plugins/login-resources/src/utils.ts @@ -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) { diff --git a/plugins/setting-resources/src/components/Settings.svelte b/plugins/setting-resources/src/components/Settings.svelte index b86915a3eb..e5676421b2 100644 --- a/plugins/setting-resources/src/components/Settings.svelte +++ b/plugins/setting-resources/src/components/Settings.svelte @@ -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) diff --git a/plugins/workbench-resources/src/components/AccountPopup.svelte b/plugins/workbench-resources/src/components/AccountPopup.svelte index aa2ae08a91..dce5c58ccb 100644 --- a/plugins/workbench-resources/src/components/AccountPopup.svelte +++ b/plugins/workbench-resources/src/components/AccountPopup.svelte @@ -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' } diff --git a/plugins/workbench-resources/src/connect.ts b/plugins/workbench-resources/src/connect.ts index a4f41a6211..3c11c734f0 100644 --- a/plugins/workbench-resources/src/connect.ts +++ b/plugins/workbench-resources/src/connect.ts @@ -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(undefined) const versionStorageKey = 'last_server_version' @@ -387,8 +388,20 @@ export async function connect (title: string): Promise { 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 = { + 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) diff --git a/plugins/workbench/src/analytics.ts b/plugins/workbench/src/analytics.ts index 82cceda572..f7de24706e 100644 --- a/plugins/workbench/src/analytics.ts +++ b/plugins/workbench/src/analytics.ts @@ -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' } diff --git a/rush.json b/rush.json index bacb39adf9..0eaf80fc53 100644 --- a/rush.json +++ b/rush.json @@ -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", diff --git a/services/analytics-collector/pod-analytics-collector/package.json b/services/analytics-collector/pod-analytics-collector/package.json index 5c6d77efd9..54c88576b6 100644 --- a/services/analytics-collector/pod-analytics-collector/package.json +++ b/services/analytics-collector/pod-analytics-collector/package.json @@ -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" } } diff --git a/services/analytics-collector/pod-analytics-collector/src/collector.ts b/services/analytics-collector/pod-analytics-collector/src/collector.ts deleted file mode 100644 index 8f58f42da5..0000000000 --- a/services/analytics-collector/pod-analytics-collector/src/collector.ts +++ /dev/null @@ -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 = new Map() - private readonly closeWorkspaceTimeouts: Map = new Map() - private readonly createdWorkspaces: Set = new Set() - - private readonly onboardingMessagesCollection: Collection - - supportClient: SupportWsClient | undefined = undefined - - // - persons = new Map() - - constructor ( - private readonly ctx: MeasureContext, - private readonly db: Db - ) { - this.onboardingMessagesCollection = this.db.collection('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 { - 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 { - 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 { - 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 { - 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 { - 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 - } - } -} diff --git a/services/analytics-collector/pod-analytics-collector/src/config.ts b/services/analytics-collector/pod-analytics-collector/src/config.ts index 99d16feeb2..a603f3c5f7 100644 --- a/services/analytics-collector/pod-analytics-collector/src/config.ts +++ b/services/analytics-collector/pod-analytics-collector/src/config.ts @@ -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 = { 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 ?? '' } diff --git a/services/analytics-collector/pod-analytics-collector/src/format.ts b/services/analytics-collector/pod-analytics-collector/src/format.ts deleted file mode 100644 index ff43ce2374..0000000000 --- a/services/analytics-collector/pod-analytics-collector/src/format.ts +++ /dev/null @@ -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 { - 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 { - 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, - client: TxOperations -): Promise { - 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 { - 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 { - 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 { - 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 { - 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 { - const workbench = await translate(analyticsCollector.string.Workbench, {}) - - return [toText(workbench, 'bold'), toText(app)] -} - -async function navigateToSpecial (app: string, special: string, id?: string): Promise { - 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 { - 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>).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 { - 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>).label, {}) - - return await navigateToSpecial(app, label, _id) -} - -async function formatRecruitLoc (location: Location, hierarchy: Hierarchy): Promise { - 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 { - 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>) - 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 { - 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 { - 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 { - 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 { - 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[] diff --git a/services/analytics-collector/pod-analytics-collector/src/loaders.ts b/services/analytics-collector/pod-analytics-collector/src/loaders.ts deleted file mode 100644 index 1c028922ac..0000000000 --- a/services/analytics-collector/pod-analytics-collector/src/loaders.ts +++ /dev/null @@ -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) -} diff --git a/services/analytics-collector/pod-analytics-collector/src/main.ts b/services/analytics-collector/pod-analytics-collector/src/main.ts index f2a2acca00..b50f7db3f6 100644 --- a/services/analytics-collector/pod-analytics-collector/src/main.ts +++ b/services/analytics-collector/pod-analytics-collector/src/main.ts @@ -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 => { - 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) + }) } diff --git a/services/analytics-collector/pod-analytics-collector/src/server.ts b/services/analytics-collector/pod-analytics-collector/src/server.ts index 0c87f6a6cf..c3450508c2 100644 --- a/services/analytics-collector/pod-analytics-collector/src/server.ts +++ b/services/analytics-collector/pod-analytics-collector/src/server.ts @@ -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 @@ -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 { + 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 { + 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 = 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 = { + alias: event.properties.alias, + $anon_distinct_id: event.distinct_id, + ...baseEvent + } + const aliasEvent: Record = { + 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 = { + 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 = { + 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() + }) }) ) diff --git a/services/analytics-collector/pod-analytics-collector/src/supportWsClient.ts b/services/analytics-collector/pod-analytics-collector/src/supportWsClient.ts deleted file mode 100644 index cd202d34ab..0000000000 --- a/services/analytics-collector/pod-analytics-collector/src/supportWsClient.ts +++ /dev/null @@ -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>() - personIdByPerson = new Map, PersonId>() - - rate = new RateLimiter(1) - - generalChannel: Channel | undefined = undefined - - async initClient (): Promise { - 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) - break - } - } - } - } - - private txUpdateDoc (client: TxOperations, tx: TxUpdateDoc): 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) - } - } - - private async getOrCreateOnboardingChannel ( - client: TxOperations, - workspace: WorkspaceUuid, - account: AccountUuid, - person: Person - ): Promise<{ - channelId: Ref | 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): Promise { - 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 - ): Promise { - 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() - 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 - ): Promise { - 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) - }) - } - } -} diff --git a/services/analytics-collector/pod-analytics-collector/src/types.ts b/services/analytics-collector/pod-analytics-collector/src/types.ts deleted file mode 100644 index 59241fff3b..0000000000 --- a/services/analytics-collector/pod-analytics-collector/src/types.ts +++ /dev/null @@ -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 - channelId: Ref -} diff --git a/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts b/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts deleted file mode 100644 index 19e07f4d0a..0000000000 --- a/services/analytics-collector/pod-analytics-collector/src/workspaceClient.ts +++ /dev/null @@ -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 - - constructor ( - readonly ctx: MeasureContext, - readonly workspace: WorkspaceUuid - ) { - this.opClient = this.initClient() - void this.opClient.then((opClient) => { - this.opClient = opClient - }) - } - - protected async initClient (): Promise { - 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 { - const opClient = await this.opClient - return await opClient.findOne(contact.class.Person, { - personUuid - }) - } - - async close (): Promise { - 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() - } - } -}