├── .github ├── dependabot.yml └── workflows │ ├── ci.yaml │ └── release.yaml ├── .gitignore ├── CONTRIBUTING.md ├── DCO ├── README.md ├── USAGE_DATA.md ├── images └── optin-popup.png ├── package-lock.json ├── package.json ├── src ├── common │ ├── api │ │ ├── analyticsEvent.ts │ │ ├── cacheService.ts │ │ ├── environment.ts │ │ ├── idProvider.ts │ │ ├── index.ts │ │ ├── redhatService.ts │ │ ├── reporter.ts │ │ ├── settings.ts │ │ └── telemetry.ts │ ├── envVar.ts │ ├── impl │ │ ├── configuration.ts │ │ ├── configurationManager.ts │ │ ├── constants.ts │ │ ├── eventCacheService.ts │ │ ├── eventTracker.ts │ │ ├── reporter.ts │ │ ├── telemetryEventQueue.ts │ │ └── telemetryServiceImpl.ts │ ├── telemetryServiceBuilder.ts │ ├── utils │ │ ├── debug.ts │ │ ├── errorMessages.ts │ │ ├── events.ts │ │ ├── extensions.ts │ │ ├── geolocation.ts │ │ ├── hashcode.ts │ │ ├── keyLocator.ts │ │ ├── logger.ts │ │ └── uuid.ts │ └── vscode │ │ ├── fileSystemStorageService.ts │ │ ├── fsUtils.ts │ │ ├── redhatServiceInitializer.ts │ │ └── settings.ts ├── config │ └── telemetry-config.json ├── index.ts ├── node │ ├── cloud │ │ ├── cheIdProvider.ts │ │ └── gitpodIdProvider.ts │ ├── fileSystemIdManager.ts │ ├── idManagerFactory.ts │ ├── index.ts │ ├── platform.ts │ └── redHatServiceNodeProvider.ts ├── tests │ ├── config │ │ └── telemetry-config.json │ ├── gitpod │ │ └── gitpodIdManager.test.ts │ ├── services │ │ ├── configuration.test.ts │ │ └── configurationManager.disabled.ts │ ├── telemetryEventQueue.test.ts │ └── utils │ │ ├── events.test.ts │ │ └── geolocation.test.ts └── webworker │ ├── index.ts │ ├── platform.ts │ ├── redHatServiceWebWorkerProvider.ts │ └── vfsIdManager.ts ├── test-webpack ├── node │ └── index.ts └── webworker │ └── index.ts ├── tsconfig.base.json ├── tsconfig.browser.json ├── tsconfig.json ├── tsconfig.node.json └── webpack.config.ts /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | version: 2 2 | updates: 3 | - package-ecosystem: "npm" 4 | directory: "/" 5 | schedule: 6 | interval: "weekly" 7 | -------------------------------------------------------------------------------- /.github/workflows/ci.yaml: -------------------------------------------------------------------------------- 1 | name: CI Build 2 | 3 | # Controls when the action will run. Triggers the workflow on pushed tags 4 | on: 5 | pull_request: 6 | branches: 7 | - main 8 | push: 9 | branches: 10 | - "*" 11 | 12 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 13 | jobs: 14 | # This workflow contains a single job called "build" 15 | build: 16 | # The type of runner that the job will run on 17 | runs-on: ${{ matrix.os }} 18 | strategy: 19 | matrix: 20 | os: [ubuntu-latest] 21 | # Steps represent a sequence of tasks that will be executed as part of the job 22 | steps: 23 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 24 | - uses: actions/checkout@v4 25 | 26 | # Set up Node 27 | - name: Use Node 20 28 | uses: actions/setup-node@v4 29 | with: 30 | node-version: 20 31 | registry-url: 'https://registry.npmjs.org' 32 | 33 | # Run install dependencies 34 | - name: Install dependencies 35 | run: npm i 36 | 37 | # Build 38 | - name: Build 39 | run: npm run prepublish 40 | 41 | - name: Test 42 | run: npm test 43 | 44 | - name: Test packaging 45 | run: npm run package 46 | -------------------------------------------------------------------------------- /.github/workflows/release.yaml: -------------------------------------------------------------------------------- 1 | name: Release to NPM 2 | 3 | # Controls when the action will run. Triggers the workflow on pushed tags 4 | on: 5 | push: 6 | tags: 7 | - '*' 8 | 9 | # A workflow run is made up of one or more jobs that can run sequentially or in parallel 10 | jobs: 11 | # This workflow contains a single job called "build" 12 | build: 13 | # The type of runner that the job will run on 14 | runs-on: ${{ matrix.os }} 15 | strategy: 16 | matrix: 17 | os: [ubuntu-latest] 18 | # Steps represent a sequence of tasks that will be executed as part of the job 19 | steps: 20 | # Checks-out your repository under $GITHUB_WORKSPACE, so your job can access it 21 | - uses: actions/checkout@v4 22 | 23 | # Set up Node 24 | - name: Use Node 20 25 | uses: actions/setup-node@v4 26 | with: 27 | node-version: 20 28 | registry-url: 'https://registry.npmjs.org' 29 | 30 | # Run install dependencies 31 | - name: Install dependencies 32 | run: npm ci 33 | 34 | - name: Build 35 | run: npm run prepublish 36 | 37 | - name: Test 38 | run: npm test 39 | 40 | # Publish to npm 41 | - name: Publish to npm 42 | run: npm publish --access public 43 | env: 44 | NODE_AUTH_TOKEN: ${{secrets.NPM_TOKEN}} 45 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | .vscode 3 | lib 4 | .DS_Store 5 | .nyc_output 6 | coverage 7 | dist/ 8 | -------------------------------------------------------------------------------- /CONTRIBUTING.md: -------------------------------------------------------------------------------- 1 | ## Contributing 2 | 3 | ### Certificate of Origin 4 | 5 | By contributing to this project you agree to the Developer Certificate of 6 | Origin (DCO). This document was created by the Linux Kernel community and is a 7 | simple statement that you, as a contributor, have the legal right to make the 8 | contribution. See the [DCO](DCO) file for details. 9 | -------------------------------------------------------------------------------- /DCO: -------------------------------------------------------------------------------- 1 | Developer Certificate of Origin 2 | Version 1.1 3 | 4 | Copyright (C) 2004, 2006 The Linux Foundation and its contributors. 5 | 1 Letterman Drive 6 | Suite D4700 7 | San Francisco, CA, 94129 8 | 9 | Everyone is permitted to copy and distribute verbatim copies of this 10 | license document, but changing it is not allowed. 11 | 12 | 13 | Developer's Certificate of Origin 1.1 14 | 15 | By making a contribution to this project, I certify that: 16 | 17 | (a) The contribution was created in whole or in part by me and I 18 | have the right to submit it under the open source license 19 | indicated in the file; or 20 | 21 | (b) The contribution is based upon previous work that, to the best 22 | of my knowledge, is covered under an appropriate open source 23 | license and I have the right under that license to submit that 24 | work with modifications, whether created in whole or in part 25 | by me, under the same open source license (unless I am 26 | permitted to submit under a different license), as indicated 27 | in the file; or 28 | 29 | (c) The contribution was provided directly to me by some other 30 | person who certified (a), (b) or (c) and I have not modified 31 | it. 32 | 33 | (d) I understand and agree that this project and the contribution 34 | are public and that a record of the contribution (including all 35 | personal information I submit with it, including my sign-off) is 36 | maintained indefinitely and may be redistributed consistent with 37 | this project or the open source license(s) involved. 38 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![npm](https://img.shields.io/npm/v/@redhat-developer/vscode-redhat-telemetry?color=brightgreen)](https://www.npmjs.com/package/@redhat-developer/vscode-redhat-telemetry) 2 | 3 | # Red Hat Telemetry Collection API 4 | 5 | This library provides a telemetry collection API for extensions published by Red Hat. **After getting the user's approval, anonymous** [usage data](https://github.com/redhat-developer/vscode-redhat-telemetry/blob/HEAD/USAGE_DATA.md) is collected and sent to Red Hat servers, to help improve our products and services. Read our [privacy statement](https://developers.redhat.com/article/tool-data-collection) to learn more about it. 6 | 7 | The first time one of Red Hat's extensions engaging in telemetry collection runs, the user will be asked to opt-in Red Hat's telemetry collection program: 8 | 9 | ![Opt-in request](images/optin-popup.png) 10 | 11 | Whether the request is accepted or denied, this pop up will not show again. 12 | 13 | It's also possible to opt-in later, by setting the `redhat.telemetry.enabled` user setting to `true`. 14 | 15 | From File > Preferences > Settings (On macOS: Code > Preferences > Settings), search for telemetry, and check the `Redhat > Telemetry : Enabled` setting. This will enable sending all telemetry events from Red Hat extensions going forward. 16 | 17 | 18 | ## How to disable telemetry reporting? 19 | If you want to stop sending usage data to Red Hat, you can set the `redhat.telemetry.enabled` user setting to `false`. 20 | 21 | From File > Preferences > Settings (On macOS: Code > Preferences > Settings), search for telemetry, and uncheck the `Redhat > Telemetry : Enabled` setting. This will silence all telemetry events from Red Hat extensions going forward. 22 | 23 | Additionally, and starting from version 0.5.0, this module abides by Visual Studio Code's telemetry level: if `telemetry.telemetryLevel` is set to `off`, then no telemetry events will be sent to Red Hat, even if `redhat.telemetry.enabled` is set to `true`. If `telemetry.telemetryLevel` is set to `error` or `crash`, only events containing an `error` or `errors` property will be sent to Red Hat. 24 | 25 | # Remote configuration 26 | Starting from version 0.5.0, Red Hat Telemetry can be remotely configured. Once every 12h (or whatever is remotely configured), [telemetry-config.json](src/config/telemetry-config.json) will be downloaded to, depending on your platform: 27 | 28 | - **Windows** `%APPDATA%\Code\User\globalStorage\vscode-redhat-telemetry\cache\telemetry-config.json` 29 | - **macOS** `$HOME/Library/Application\ Support/Code/User/globalStorage/vscode-redhat-telemetry/cache/telemetry-config.json` 30 | - **Linux** `$HOME/.config/Code/User/globalStorage/vscode-redhat-telemetry/cache/telemetry-config.json` 31 | 32 | This allows Red Hat extensions to limit the events to be sent, by including or excluding certain events, by name or containing properties, or by limiting the ratio of users sending data. 33 | eg.: 34 | - 50% of `redhat.vscode-hypothetical` users only, to report error events, excluding stackoverflows: 35 | 36 | Starting with 0.6.1, you can configure ratios on included events, meaning X% of the users will send a particular event (does not mean X% of the events will be sent!). 37 | 38 | ```json 39 | { 40 | "*": { 41 | "enabled":"all", // supports "all", "error", "crash", "off" 42 | "refresh": "12h", 43 | "includes": [ 44 | { 45 | "name" : "startup", 46 | "dailyLimit": "1" // Limit to 1 event per day per extension 47 | }, 48 | { 49 | "name" : "*" // Always put wildcard patterns last in the array, to ensure other events are included 50 | } 51 | ] 52 | }, 53 | "redhat.vscode-hypothetical": { 54 | "enabled": "error", 55 | "ratio": "0.5", // 50% of the users will send data 56 | "excludes": [ 57 | { 58 | "property": "error", 59 | "value": "*stackoverflow*" 60 | } 61 | ] 62 | }, 63 | "redhat.vscode-mythological": { 64 | "enabled": "all", 65 | "includes": [ 66 | { 67 | "name": "something-too-frequent", 68 | "ratio":"0.1" // 10% of the users will send that event 69 | }, 70 | { 71 | "name": "something-less-frequent",// all users could send that event but ... 72 | } 73 | ], 74 | "excludes": [ 75 | { 76 | "name": "something-less-frequent", 77 | "ratio":"0.997" //... actually 99.7% of the users won't send that event 78 | } 79 | ] 80 | } 81 | } 82 | ``` 83 | 84 | Extension configuration inherits and overrides the `*` configuration. 85 | 86 | 87 | # How to use this library 88 | 89 | ## Add the `@redhat-developer/vscode-redhat-telemetry` dependency 90 | 91 | In order to install [`@redhat-developer/vscode-redhat-telemetry`](https://github.com/redhat-developer/vscode-redhat-telemetry/) in your VS Code extension, open a terminal and execute: 92 | 93 | ``` 94 | npm i @redhat-developer/vscode-redhat-telemetry 95 | ``` 96 | ## Contribute the `redhat.telemetry.enabled` preference 97 | Unless your extension already depends on a telemetry-enabled Red Hat extension, it needs to declare the `redhat.telemetry.enabled` preference in its package.json, like: 98 | 99 | ``` 100 | "contributes": { 101 | "configuration": { 102 | ... 103 | "properties": { 104 | ... 105 | "redhat.telemetry.enabled": { 106 | "type": "boolean", 107 | "default": null, 108 | "markdownDescription": "Enable usage data and errors to be sent to Red Hat servers. Read our [privacy statement](https://developers.redhat.com/article/tool-data-collection).", 109 | "tags":[ "telemetry", "usesOnlineServices" ], 110 | "scope": "window" 111 | }, 112 | } 113 | } 114 | } 115 | ``` 116 | ## [Optional] Add a custom segment key in package.json file 117 | By default, extensions will send their data to https://app.segment.com/redhat-devtools/sources/vscode/. In development mode, the data is sent to https://app.segment.com/redhat-devtools/sources/vs_code_tests/. 118 | 119 | - You can specify custom segment keys in your package.json, to connect and push usage data to https://segment.com/ 120 | 121 | ```json 122 | "segmentWriteKey": "your-segment-key-goes-here", 123 | "segmentWriteKeyDebug": "your-segment-key-goes-here-for-dev-mode", 124 | ``` 125 | 126 | ## Add the below code to your `extension.ts` 127 | 128 | Get a reference to the RedHatService instance from your VS Code extension's `activate` method in `extension.ts`: 129 | ```typescript 130 | import { getRedHatService, TelemetryService } from "@redhat-developer/vscode-redhat-telemetry"; 131 | 132 | let telemetryService: TelemetryService = null; 133 | 134 | export async function activate(context: ExtensionContext) { 135 | const redhatService = await getRedHatService(context); 136 | telemetryService = await redhatService.getTelemetryService(); 137 | telemetryService.sendStartupEvent(); 138 | ... 139 | } 140 | ``` 141 | 142 | Send events from the telemetryService reference: 143 | 144 | ```typescript 145 | ... 146 | if (telemetryService) { 147 | telemetryService.send({name: "Simplest event"}); 148 | ... 149 | let event = { 150 | name: "Test Event", 151 | type: "track", // optional type (track is the default) 152 | properties: { // optional custom properties 153 | foo: "bar", 154 | } 155 | }; 156 | telemetryService.send(event); 157 | } 158 | ``` 159 | 160 | To access the anonymous Red Hat UUID for the current user: 161 | ```typescript 162 | const redhatUuid = await (await redhatService.getIdProvider()).getRedHatUUID(); 163 | ``` 164 | 165 | Once your extension is deactivated, a shutdown event, including the session duration, will automatically be sent on its behalf. However, shutdown event delivery is not guaranteed, in case VS Code is faster to exit than to send those last events. 166 | 167 | All event properties are automatically sanitized to anonymize all paths (best effort) and references to the username. 168 | 169 | 170 | ## Publicly document your data collection 171 | 172 | Once telemetry is in place, you need to document the extent of the telemetry collection performed by your extension. 173 | * add a USAGE_DATA.md page to your extension's repository, listing the type of data being collected by your extension. 174 | * add a `Data and Telemetry` paragraph at the end of your extension's README file: 175 | > `The ***** extension collects anonymous [usage data](USAGE_DATA.md) and sends it to Red Hat servers to help improve our products and services. Read our [privacy statement](https://developers.redhat.com/article/tool-data-collection) to learn more. This extension respects the `redhat.telemetry.enabled` setting which you can learn more about at https://github.com/redhat-developer/vscode-redhat-telemetry#how-to-disable-telemetry-reporting` 176 | 177 | * add a reference to your telemetry documentation page to this repository's own [USAGE_DATA.md](https://github.com/redhat-developer/vscode-redhat-telemetry/blob/HEAD/USAGE_DATA.md#other-extensions). 178 | 179 | ### Checking telemetry during development 180 | In your `.vscode/launch.json`: 181 | - set the `VSCODE_REDHAT_TELEMETRY_DEBUG` environment variable to `true`, to log telemetry events in the console 182 | - set the `REDHAT_TELEMETRY_REMOTE_CONFIG_URL` environment variable to the URL of a remote configuration file, if you need to test remote configuration 183 | 184 | ```json 185 | { 186 | "name": "Run Extension", 187 | "type": "extensionHost", 188 | "request": "launch", 189 | "args": [ 190 | "--extensionDevelopmentPath=${workspaceFolder}" 191 | ], 192 | "outFiles": [ 193 | "${workspaceFolder}/dist/**/*.js" 194 | ], 195 | "preLaunchTask": "${defaultBuildTask}", 196 | "env": { 197 | "VSCODE_REDHAT_TELEMETRY_DEBUG":"true", 198 | "REDHAT_TELEMETRY_REMOTE_CONFIG_URL":"https://gist.githubusercontent.com/fbricon/cff82f0bd7ff69bf2b9f5f04b1accc50/raw/65b61b7d8845c842a90a8e6a90d852af34934160/telemetry-config.json" 199 | } 200 | }, 201 | ``` 202 | 203 | 204 | # How to use from a VS Code web extension 205 | When the VS Code extension runs as a web extension, telemetry should use a webworker specific API. So just change your code so it imports from the dedicated webworker namespace. 206 | 207 | ```typescript 208 | import { getRedHatService, TelemetryService} from "@redhat-developer/vscode-redhat-telemetry/lib/webworker"; 209 | ``` 210 | 211 | The API is identical to the regular node one. 212 | 213 | However, in order for webpack to compile your web extension, some adjustments are required to the alias and fallback properties: 214 | 215 | ```js 216 | /**@type {import('webpack').Configuration}*/ 217 | const webConfig = { 218 | target: 'webworker', // extensions run in a webworker context 219 | ... 220 | resolve: { 221 | ... 222 | alias: { 223 | 'node-fetch': 'whatwg-fetch', 224 | 'object-hash': 'object-hash/dist/object_hash.js', 225 | }, 226 | fallback: { 227 | path: require.resolve('path-browserify'), 228 | 'node-fetch': require.resolve('whatwg-fetch'), 229 | util: require.resolve('util'), 230 | }, 231 | }, 232 | ... 233 | }; 234 | ``` 235 | 236 | # Build 237 | In a terminal, execute: 238 | ``` 239 | npm i 240 | ``` 241 | to install the dependencies, then: 242 | ``` 243 | npm run prepublish 244 | ``` 245 | to build the library 246 | 247 | # Information on data transmission during development 248 | 249 | When the extension sending telemetry is running in `development mode`, the data are sent to the `test.vscode` project on https://segment.com/, or whatever project bound to the optional [segmentWriteDebugKey](#optional-add-a-custom-segment-key-in-packagejson-file). 250 | As the transmission is opt-in, unless specifiying it explicitely, no data are transmitted during CI builds. 251 | -------------------------------------------------------------------------------- /USAGE_DATA.md: -------------------------------------------------------------------------------- 1 | ## Usage data being collected by Red Hat Extensions 2 | Only anonymous data is being collected by Red Hat extensions using `vscode-redhat-telemetry` facilities. The IP address of telemetry requests is not even stored on Red Hat servers. 3 | All telemetry events are automatically sanitized to anonymize all paths (best effort) and references to the username. 4 | 5 | ### Common data 6 | Telemetry requests may contain: 7 | 8 | * a random anonymous user id (UUID v4), that is stored locally on `~/.redhat/anonymousId` 9 | * the client name (VS Code, VSCodium, Eclipse Che...) and its version 10 | * the type of client (Desktop vs Web) 11 | * the name and version of the extension sending the event (eg. `fabric8-analytics.fabric8-analytics-vscode-extension`) 12 | * whether the extension runs remotely or not (eg. in WSL) 13 | * the OS name and version (and distribution name, in case of Linux) 14 | * the user locale (eg. en_US) 15 | * the user timezone 16 | * the country id (as determined by the current timezone) 17 | 18 | Also, when running in a web extension: 19 | * the browser name and version 20 | * the host service, eg. `vscode.dev` 21 | 22 | Common events are reported: 23 | 24 | * when extension is started 25 | * when extension is shutdown 26 | - duration of the session 27 | 28 | ### Other extensions 29 | Red Hat extensions' specific telemetry collection details can be found there: 30 | 31 | * [Dependency Analytics](https://github.com/fabric8-analytics/fabric8-analytics-vscode-extension/blob/master/Telemetry.md) 32 | * [OpenShift Connector](https://github.com/redhat-developer/vscode-openshift-tools/blob/master/USAGE_DATA.md) 33 | * [Project Initializer](https://github.com/redhat-developer/vscode-project-initializer/blob/master/USAGE_DATA.md) 34 | * [Quarkus](https://github.com/redhat-developer/vscode-quarkus/blob/master/USAGE_DATA.md) 35 | * [Red Hat Authentication](https://github.com/redhat-developer/vscode-redhat-account/blob/main/USAGE_DATA.md) 36 | * [Red Hat OpenShift Application Services](https://github.com/redhat-developer/vscode-rhoas/blob/main/USAGE_DATA.md) 37 | * [Remote Server Protocol](https://github.com/redhat-developer/vscode-rsp-ui/blob/master/USAGE_DATA.md) 38 | * [Tekton Pipelines](https://github.com/redhat-developer/vscode-tekton/blob/master/USAGE_DATA.md) 39 | * [Tooling for Apache Camel K](https://github.com/camel-tooling/vscode-camelk/blob/main/USAGE_DATA.md) 40 | * [Language Support for Apache Camel](https://github.com/camel-tooling/camel-lsp-client-vscode/blob/main/USAGE_DATA.md) 41 | * [Debug Adapter for Apache Camel](https://github.com/camel-tooling/camel-dap-client-vscode/blob/main/USAGE_DATA.md) 42 | * [Tools for MicroProfile](https://github.com/redhat-developer/vscode-microprofile/blob/master/USAGE_DATA.md) 43 | * [XML](https://github.com/redhat-developer/vscode-xml/blob/master/USAGE_DATA.md) 44 | * [YAML](https://github.com/redhat-developer/vscode-yaml/blob/main/USAGE_DATA.md) 45 | * [Ansible](https://github.com/ansible/vscode-ansible/blob/main/USAGE_DATA.md) 46 | -------------------------------------------------------------------------------- /images/optin-popup.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/redhat-developer/vscode-redhat-telemetry/47bcb8ca31f36c0bb22145904dae12b100500360/images/optin-popup.png -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "@redhat-developer/vscode-redhat-telemetry", 3 | "version": "0.9.1", 4 | "description": "Provides Telemetry APIs for Red Hat applications", 5 | "main": "lib/index.js", 6 | "types": "lib", 7 | "scripts": { 8 | "clean": "rimraf lib/", 9 | "copy-files": "copyfiles -u 1 src/config/* src/tests/config/* lib/", 10 | "compile": "tsc -b ./tsconfig.json", 11 | "build": "npm run clean && npm run copy-files && npm run compile", 12 | "prepublish": "npm run build", 13 | "coverage": "nyc -r lcov -e .ts -x \"*.ts\" npm run test", 14 | "test": "mocha -r ts-node/register --ui tdd \"src/tests/**/*.test.ts\"", 15 | "package": "npm run build && webpack" 16 | }, 17 | "files": [ 18 | "/lib" 19 | ], 20 | "repository": { 21 | "type": "git", 22 | "url": "git+https://github.com/redhat-developer/vscode-redhat-telemetry.git" 23 | }, 24 | "bugs": { 25 | "url": "https://github.com/redhat-developer/vscode-redhat-telemetry/issues" 26 | }, 27 | "author": "Red Hat", 28 | "license": "Apache-2.0", 29 | "devDependencies": { 30 | "@types/chai": "^5.0.0", 31 | "@types/countries-and-timezones": "3.2.6", 32 | "@types/getos": "^3.0.1", 33 | "@types/mocha": "^10.0.0", 34 | "@types/mock-fs": "^4.13.1", 35 | "@types/node": "^20.14.8", 36 | "@types/object-hash": "^3.0.2", 37 | "@types/picomatch": "^3.0.1", 38 | "@types/ua-parser-js": "^0.7.36", 39 | "@types/uuid": "^10.0.0", 40 | "@types/vscode": "1.75.1", 41 | "@typescript-eslint/eslint-plugin": "^8.6.0", 42 | "@typescript-eslint/parser": "^8.6.0", 43 | "chai": "^5.1.1", 44 | "copyfiles": "^2.4.1", 45 | "eslint": "^9.10.0", 46 | "mocha": "11.1.0", 47 | "mock-fs": "^5.1.4", 48 | "nyc": "^17.1.0", 49 | "rimraf": "^6.0.1", 50 | "ts-mocha": "11.1.0", 51 | "ts-node": "^10.9.1", 52 | "typescript": "^5.6.2", 53 | "warnings-to-errors-webpack-plugin": "^2.3.0", 54 | "webpack": "^5.76.1", 55 | "webpack-cli": "^5.0.1" 56 | }, 57 | "dependencies": { 58 | "@segment/analytics-node": "2.2.1", 59 | "countries-and-timezones": "^3.4.1", 60 | "getos": "^3.2.1", 61 | "object-hash": "^3.0.0", 62 | "os-locale": "^5.0.0", 63 | "picomatch": "^4.0.2", 64 | "ua-parser-js": "1.0.39", 65 | "uuid": "^11.0.3" 66 | } 67 | } 68 | -------------------------------------------------------------------------------- /src/common/api/analyticsEvent.ts: -------------------------------------------------------------------------------- 1 | export interface AnalyticsEvent { 2 | userId: string; 3 | event: string; 4 | type: string; 5 | properties?: any; 6 | measures?: any; 7 | traits?: any; 8 | context?: any; 9 | } -------------------------------------------------------------------------------- /src/common/api/cacheService.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Cache Service 3 | */ 4 | export interface CacheService { 5 | /** 6 | * Returns the value in cache for the given key. 7 | */ 8 | get(key: string):Promise; 9 | 10 | put(key: string, value: string):Promise; 11 | } -------------------------------------------------------------------------------- /src/common/api/environment.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Container object holding environment specific data, used to enrich telemetry events. 3 | */ 4 | export interface Environment { 5 | /** 6 | * The extension from which Telemetry events are sent. 7 | */ 8 | extension: Application, 9 | 10 | /** 11 | * The client application from which Telemetry events are sent . 12 | */ 13 | application: Client, 14 | 15 | /** 16 | * The platform (or OS) from from which Telemetry events are sent. 17 | */ 18 | platform: Platform, 19 | 20 | /** 21 | * The platform (or OS) from from which Telemetry events are sent. 22 | */ 23 | browser?: Browser, 24 | 25 | /** 26 | * User timezone, eg. 'Europe/Paris' 27 | */ 28 | timezone?: string, 29 | 30 | /** 31 | * The user locale, eg. 'en-US' 32 | */ 33 | locale?: string, 34 | 35 | /** 36 | * The user's ISO country code, eg. 'CA' for Canada 37 | */ 38 | country?: string, 39 | 40 | /** 41 | * Username (used as basis for stripping PII from data) 42 | */ 43 | username?: string 44 | } 45 | 46 | /** 47 | * The client application or extension from which Telemetry events are sent. 48 | */ 49 | export interface Client extends Application { 50 | /** 51 | * UI Kind (Web / ) 52 | */ 53 | uiKind?: string, 54 | /** 55 | * Runs remotely (eg. in wsl)? 56 | */ 57 | remote?: boolean 58 | /** 59 | * The hosted location of the application. On desktop this is 'desktop'. 60 | * In the web this is the specified embedder i.e. 'github.dev', 'codespaces', or 'web' if the embedder does not provide that information 61 | */ 62 | appHost?: string 63 | } 64 | 65 | export interface Application { 66 | /** 67 | * Client name 68 | */ 69 | name: string, 70 | /** 71 | * Client version 72 | */ 73 | version: string 74 | } 75 | 76 | /** 77 | * The platform (or OS) from which Telemetry events are sent. 78 | */ 79 | export interface Platform { 80 | name: string, 81 | distribution?: string, 82 | version?: string, 83 | } 84 | 85 | /** 86 | * The browser from which Telemetry events are sent. 87 | */ 88 | export interface Browser { 89 | name?: string, 90 | version?: string, 91 | } -------------------------------------------------------------------------------- /src/common/api/idProvider.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service providing the Red Hat anonymous user id. 3 | */ 4 | export interface IdProvider { 5 | /** 6 | * Returns the Red Hat' anonymous user id. 7 | */ 8 | getRedHatUUID():Promise; 9 | } -------------------------------------------------------------------------------- /src/common/api/index.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryService } from './telemetry'; 2 | import { RedHatService } from './redhatService'; 3 | 4 | export { TelemetryService, RedHatService } -------------------------------------------------------------------------------- /src/common/api/redhatService.ts: -------------------------------------------------------------------------------- 1 | import { IdProvider } from "./idProvider"; 2 | import { TelemetryService } from "./telemetry"; 3 | 4 | /** 5 | * Umbrella for Red Hat services. 6 | */ 7 | export interface RedHatService { 8 | /** 9 | * Returns a Telemetry service 10 | */ 11 | getTelemetryService():Promise; 12 | /** 13 | * Returns the Red Hat Id manager 14 | */ 15 | getIdProvider():Promise; 16 | } -------------------------------------------------------------------------------- /src/common/api/reporter.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsEvent } from "./analyticsEvent" 2 | export interface IReporter { 3 | report(event: AnalyticsEvent): Promise 4 | 5 | flush(): Promise; 6 | 7 | closeAndFlush(): Promise; 8 | } -------------------------------------------------------------------------------- /src/common/api/settings.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Service determining if Telemetry is configured and enabled. 3 | */ 4 | export interface TelemetrySettings { 5 | /** 6 | * Returns `true` if Telemetry is enabled. 7 | */ 8 | isTelemetryEnabled() : boolean; 9 | /** 10 | * Returns `true` if Telemetry is configured (enabled or not). 11 | */ 12 | isTelemetryConfigured(): boolean; 13 | 14 | /** 15 | * Returns the telemetry level: value can be either "off", "all", "error" or "crash" 16 | */ 17 | getTelemetryLevel(): string | undefined; 18 | } -------------------------------------------------------------------------------- /src/common/api/telemetry.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * Telemetry Event 3 | */ 4 | export interface TelemetryEvent { 5 | type?: string; // type of telemetry event such as : identify, track, page, etc. 6 | name: string; 7 | properties?: any; 8 | measures?: any; 9 | traits?: any; 10 | context?: any; 11 | } 12 | 13 | /** 14 | * Service for sending Telemetry events 15 | */ 16 | export interface TelemetryService { 17 | /** 18 | * Sends a `startup` Telemetry event 19 | */ 20 | sendStartupEvent(): Promise; 21 | 22 | /** 23 | * Sends the Telemetry event 24 | */ 25 | send(event: TelemetryEvent): Promise; 26 | 27 | /** 28 | * Sends a `shutdown` Telemetry event 29 | */ 30 | sendShutdownEvent(): Promise; 31 | 32 | /** 33 | * Flushes the service's Telemetry events queue 34 | */ 35 | flushQueue(): Promise; 36 | 37 | /** 38 | * Dispose this service 39 | */ 40 | dispose(): Promise; 41 | } 42 | -------------------------------------------------------------------------------- /src/common/envVar.ts: -------------------------------------------------------------------------------- 1 | export interface Dict { 2 | [key: string]: T | undefined; 3 | } 4 | let env = (typeof process === 'undefined') ? {} as Dict : process.env; 5 | 6 | export default env; -------------------------------------------------------------------------------- /src/common/impl/configuration.ts: -------------------------------------------------------------------------------- 1 | import * as picomatch from "picomatch"; 2 | import { isError } from "../utils/events"; 3 | import { numValue } from "../utils/hashcode"; 4 | import { AnalyticsEvent } from "../api/analyticsEvent"; 5 | 6 | interface EventNamePattern { 7 | name: string; 8 | ratio?: string; 9 | dailyLimit?: string; 10 | } 11 | 12 | interface PropertyPattern { 13 | property: string; 14 | value: string; 15 | } 16 | 17 | type EventPattern = EventNamePattern | PropertyPattern; 18 | 19 | export class Configuration { 20 | 21 | json: any; 22 | 23 | constructor(json: any) { 24 | this.json = json; 25 | } 26 | 27 | public isEnabled(): boolean { 28 | return (this.json?.enabled) === undefined || "off" !== (this.json?.enabled) 29 | } 30 | 31 | public canSend(event: AnalyticsEvent): boolean { 32 | if (!this.isEnabled()) { 33 | return false; 34 | } 35 | if ( ["error","crash"].includes(this.json?.enabled) && !isError(event)) { 36 | return false; 37 | } 38 | 39 | const currUserRatioValue = numValue(event.userId); 40 | const configuredRatio = getRatio(this.json?.ratio); 41 | if (configuredRatio === 0 || configuredRatio < currUserRatioValue) { 42 | return false; 43 | } 44 | 45 | const isIncluded = this.isIncluded(event, currUserRatioValue) 46 | && !this.isExcluded(event, currUserRatioValue) 47 | return isIncluded; 48 | } 49 | 50 | public getDailyLimit(event: AnalyticsEvent): number { 51 | const includes = this.getIncludePatterns(); 52 | if (includes.length) { 53 | const pattern = includes.filter(isEventNamePattern).map(p => p as EventNamePattern) 54 | .find(p => picomatch.isMatch(event.event, p.name)) 55 | if (pattern?.dailyLimit) { 56 | try { 57 | return parseInt(pattern.dailyLimit); 58 | } catch(e) { 59 | // ignore 60 | } 61 | } 62 | } 63 | return Number.MAX_VALUE; 64 | } 65 | 66 | isIncluded(event: AnalyticsEvent, currUserRatioValue: number): boolean { 67 | const includes = this.getIncludePatterns(); 68 | if (includes.length) { 69 | return this.isEventMatching(event, includes, currUserRatioValue, true); 70 | } 71 | return true; 72 | } 73 | 74 | isExcluded(event: AnalyticsEvent, currUserRatioValue: number): boolean { 75 | const excludes = this.getExcludePatterns(); 76 | if (excludes.length) { 77 | return this.isEventMatching(event, excludes, currUserRatioValue, false); 78 | } 79 | return false; 80 | } 81 | 82 | getIncludePatterns(): EventPattern[] { 83 | if (this.json?.includes) { 84 | return this.json.includes as EventPattern[]; 85 | } 86 | return []; 87 | } 88 | 89 | getExcludePatterns(): EventPattern[] { 90 | if (this.json?.excludes) { 91 | return this.json.excludes as EventPattern[]; 92 | } 93 | return []; 94 | } 95 | 96 | isEventMatching(event: AnalyticsEvent, patterns:EventPattern[], currUserRatioValue: number, including: boolean):boolean { 97 | if (!patterns || !patterns.length) { 98 | return false; 99 | } 100 | const match = patterns.find(evtPtn => { 101 | if (isPropertyPattern(evtPtn)) { 102 | const props = event.properties; 103 | if (props) { 104 | const value = props[evtPtn.property]; 105 | const propertyPattern = evtPtn.value; 106 | if (value && picomatch.isMatch(value, propertyPattern)) { 107 | return true; 108 | } 109 | } 110 | } else { 111 | const eventNamePattern = evtPtn.name; 112 | if (eventNamePattern && event.event && picomatch.isMatch(event.event, eventNamePattern)) { 113 | const configuredEventRatio = getRatio(evtPtn?.ratio); 114 | if (including) { 115 | return currUserRatioValue <= configuredEventRatio; 116 | } 117 | // excluding 90% of user means keeping 10% 118 | // so user ratio value of 0.11 should be excluded (i.e match) when excluded event ratio = 0.9 119 | return currUserRatioValue > 1 - configuredEventRatio; 120 | } 121 | } 122 | return false; 123 | }); 124 | return !!match; 125 | } 126 | } 127 | 128 | function getRatio(ratioAsString ?:string): number { 129 | if (ratioAsString) { 130 | try { 131 | return parseFloat(ratioAsString); 132 | } catch(e) { 133 | // ignore 134 | } 135 | } 136 | return 1.0; 137 | } 138 | 139 | function isPropertyPattern(event: EventPattern): event is PropertyPattern { 140 | if ((event as PropertyPattern).property) { 141 | return true 142 | } 143 | return false 144 | } 145 | 146 | function isEventNamePattern(event: EventPattern): event is EventNamePattern { 147 | if ((event as EventNamePattern).name) { 148 | return true 149 | } 150 | return false 151 | } 152 | -------------------------------------------------------------------------------- /src/common/impl/configurationManager.ts: -------------------------------------------------------------------------------- 1 | import env from "../envVar"; 2 | import { Logger } from "../utils/logger"; 3 | import { FileSystemStorageService } from "../vscode/fileSystemStorageService"; 4 | import { Configuration } from "./configuration"; 5 | 6 | export const DEFAULT_CONFIG_URL = 'https://raw.githubusercontent.com/redhat-developer/vscode-redhat-telemetry/main/src/config/telemetry-config.json'; 7 | export const TELEMETRY_CONFIG = "telemetry-config.json"; 8 | 9 | export class ConfigurationManager { 10 | public static REMOTE_CONFIG_KEY = 'REDHAT_TELEMETRY_REMOTE_CONFIG_URL'; 11 | public static TEST_CONFIG_KEY = 'REDHAT_TELEMETRY_TEST_CONFIG_KEY'; 12 | 13 | constructor(private extensionId: string, private storageService: FileSystemStorageService){} 14 | 15 | private extensionConfig: Promise|undefined; 16 | 17 | public async refresh(): Promise { 18 | const remoteConfig = await this.fetchRemoteConfiguration(); 19 | if (remoteConfig) { 20 | remoteConfig['timestamp'] = new Date().getTime(); 21 | await this.saveLocalConfiguration(remoteConfig); 22 | } 23 | return remoteConfig; 24 | } 25 | 26 | public async getExtensionConfiguration(): Promise { 27 | let extensionConfig = this.extensionConfig; 28 | if (extensionConfig) { 29 | if (!isStale(await extensionConfig)) { 30 | return extensionConfig; 31 | } 32 | this.extensionConfig = undefined; 33 | } 34 | Logger.log("Loading json config for "+this.extensionId); 35 | this.extensionConfig = this.loadConfiguration(this.extensionId); 36 | return this.extensionConfig; 37 | } 38 | 39 | private async loadConfiguration(extensionId: string): Promise { 40 | let localConfig: any; 41 | try { 42 | localConfig = await this.getLocalConfiguration(); 43 | if (isStale(localConfig)) { 44 | localConfig = await this.refresh(); 45 | } 46 | } catch(e: any) { 47 | console.error(`Failed to load local configuration: ${e?.message}`); 48 | } 49 | let fullConfig:any; 50 | if (localConfig) { 51 | fullConfig = localConfig; 52 | } else { 53 | fullConfig = await this.getEmbeddedConfiguration(); 54 | } 55 | const json = getExtensionConfig(fullConfig, extensionId); 56 | return new Configuration(json); 57 | } 58 | async saveLocalConfiguration(fullConfig: any): Promise { 59 | try { 60 | return this.storageService.writeToFile(TELEMETRY_CONFIG, JSON.stringify(fullConfig, null, 2)); 61 | } catch (e) { 62 | console.error(`Error saving configuration locally: ${e}`); 63 | } 64 | } 65 | 66 | public async fetchRemoteConfiguration(uri?: string): Promise { 67 | let telemetryUri = ( uri )? uri: env[ConfigurationManager.REMOTE_CONFIG_KEY]; 68 | if (!telemetryUri) { 69 | telemetryUri = DEFAULT_CONFIG_URL; 70 | } 71 | Logger.info(`Updating vscode-redhat-telemetry configuration from ${telemetryUri}`); 72 | const response = await fetch(telemetryUri); 73 | try { 74 | return response?.json(); 75 | } catch (e) { 76 | console.error(`Failed to parse:\n`+(await response?.text())+'\n'+e); 77 | } 78 | return undefined; 79 | } 80 | 81 | public async getLocalConfiguration(): Promise { 82 | const content = await this.storageService.readFromFile(TELEMETRY_CONFIG); 83 | if (content) { 84 | return JSON.parse(content); 85 | } 86 | return undefined; 87 | } 88 | 89 | public async getEmbeddedConfiguration(): Promise { 90 | return require('../../config/telemetry-config.json'); 91 | } 92 | 93 | } 94 | const refreshPattern = /\d+/g; 95 | const REFRESH_PERIOD = 6; 96 | const HOUR_IN_MILLISEC = 60 * 60 * 1000; 97 | 98 | function isStale(configOrJson: any): boolean { 99 | if (!configOrJson) { 100 | return true; 101 | } 102 | let config: any; 103 | if (configOrJson instanceof Configuration) { 104 | config = configOrJson.json; 105 | } else { 106 | config = configOrJson; 107 | } 108 | const timestamp = config.timestamp? config.timestamp : 0; 109 | let period = REFRESH_PERIOD; 110 | if (config.refresh) { 111 | const res = (config.refresh as string).match(refreshPattern); 112 | if (res && res.length) { 113 | period = parseInt(res[0]); 114 | } 115 | } 116 | let elapsed = new Date().getTime() - timestamp; 117 | return (elapsed > period * HOUR_IN_MILLISEC); 118 | } 119 | 120 | function getExtensionConfig(fullConfig: any, extensionId: string) { 121 | const extensionConfig = Object.assign({}, fullConfig['*'], fullConfig[extensionId]); 122 | if (fullConfig.timestamp) { 123 | extensionConfig['timestamp'] = fullConfig.timestamp; 124 | } 125 | return extensionConfig; 126 | } -------------------------------------------------------------------------------- /src/common/impl/constants.ts: -------------------------------------------------------------------------------- 1 | /* 2 | OPT_IN_STATUS === "true" if user responded on the notification pop-up 3 | and undefined if the user has not responded or closed the notification pop-up 4 | */ 5 | export const OPT_IN_STATUS_KEY = 'redhat.telemetry.optInRequested'; 6 | export const PRIVACY_STATEMENT_URL: string = 7 | 'https://developers.redhat.com/article/tool-data-collection'; 8 | export const OPT_OUT_INSTRUCTIONS_URL: string = 9 | 'https://github.com/redhat-developer/vscode-redhat-telemetry#how-to-disable-telemetry-reporting'; 10 | export const CONFIG_KEY = 'redhat.telemetry'; 11 | 12 | export const DEFAULT_SEGMENT_KEY = 'MXM7iv13sVaCGqOhnQEGLZxhfy6hecYh'; 13 | export const DEFAULT_SEGMENT_DEBUG_KEY ='eKBn0xqKQcQJVhUOW0vdQtNQiK791OLa'; 14 | -------------------------------------------------------------------------------- /src/common/impl/eventCacheService.ts: -------------------------------------------------------------------------------- 1 | import { CacheService } from '../api/cacheService'; 2 | 3 | import {FileSystemStorageService } from '../vscode/fileSystemStorageService'; 4 | 5 | export class EventCacheService implements CacheService { 6 | 7 | private memCache = new Map(); 8 | 9 | constructor(private fileService: FileSystemStorageService){} 10 | 11 | async get(key: string): Promise { 12 | if (this.memCache.has(key)) { 13 | return this.memCache.get(key); 14 | } 15 | const value = await this.fileService.readFromFile(`${key}.txt`); 16 | if (value) { 17 | this.memCache.set(key, value); 18 | } 19 | return value; 20 | } 21 | 22 | async put(key: string, value: string): Promise { 23 | this.memCache.set(key, value); 24 | await this.fileService.writeToFile(`${key}.txt`, value); 25 | return true; 26 | } 27 | 28 | } -------------------------------------------------------------------------------- /src/common/impl/eventTracker.ts: -------------------------------------------------------------------------------- 1 | import { Memento } from "vscode"; 2 | import { AnalyticsEvent } from '../api/analyticsEvent'; 3 | 4 | 5 | interface EventTracking { 6 | lastUpdated: number; 7 | count: number; 8 | } 9 | 10 | export class EventTracker { 11 | 12 | constructor(private globalState: Memento) { } 13 | 14 | async storeEventCount(payload: AnalyticsEvent, newCount: number): Promise { 15 | const newTracking = { 16 | count: newCount, 17 | lastUpdated: this.getTodaysTimestamp() 18 | } as EventTracking; 19 | return this.globalState.update(this.getEventTrackingKey(payload.event), newTracking); 20 | } 21 | 22 | async getEventCount(payload: AnalyticsEvent): Promise { 23 | const eventTracking = this.globalState.get(this.getEventTrackingKey(payload.event)); 24 | if (eventTracking) { 25 | //Check if eventTracking timestamp is older than today 26 | let today = this.getTodaysTimestamp(); 27 | let lastEventDay = eventTracking.lastUpdated; 28 | //check if now and lastEventTime are in the same day 29 | if (lastEventDay === today) { 30 | return eventTracking.count; 31 | } 32 | // new day, reset count 33 | } 34 | return 0; 35 | } 36 | 37 | private getTodaysTimestamp(): number { 38 | const now = new Date(); 39 | now.setHours(0, 0, 0, 0); 40 | return now.getTime(); 41 | } 42 | 43 | private getEventTrackingKey(eventName: string): string { 44 | //replace all non alphanumeric characters with a _ 45 | const key = eventName.replace(/[^a-zA-Z0-9]/g, '_'); 46 | return `telemetry.events.tracking.${key}`; 47 | } 48 | } 49 | 50 | -------------------------------------------------------------------------------- /src/common/impl/reporter.ts: -------------------------------------------------------------------------------- 1 | import { IReporter } from '../api/reporter'; 2 | 3 | import { CoreAnalytics } from '@segment/analytics-core'; 4 | import { sha1 } from 'object-hash'; 5 | import { CacheService } from '../api/cacheService'; 6 | import { Logger } from '../utils/logger'; 7 | import { AnalyticsEvent } from '../api/analyticsEvent'; 8 | import { toErrorMessage } from '../utils/errorMessages'; 9 | /** 10 | * Sends Telemetry events to a segment.io backend 11 | */ 12 | export class Reporter implements IReporter { 13 | 14 | constructor(private analytics?: CoreAnalytics, private cacheService?: CacheService) { 15 | } 16 | 17 | public async report(event: AnalyticsEvent): Promise { 18 | if (!this.analytics) { 19 | return; 20 | } 21 | const payloadString = JSON.stringify(event); 22 | try { 23 | 24 | switch (event.type) { 25 | case 'identify': 26 | //Avoid identifying the user several times, until some data has changed. 27 | const hash = sha1(payloadString); 28 | const cached = await this.cacheService?.get('identify'); 29 | if (hash === cached) { 30 | Logger.log(`Skipping 'identify' event! Already sent:\n${payloadString}`); 31 | return; 32 | } 33 | Logger.log(`Sending 'identify' event with\n${payloadString}`); 34 | await this.analytics?.identify(event); 35 | this.cacheService?.put('identify', hash); 36 | break; 37 | case 'track': 38 | Logger.log(`Sending 'track' event with\n${payloadString}`); 39 | await this.analytics?.track(event); 40 | break; 41 | case 'page': 42 | Logger.log(`Sending 'page' event with\n${payloadString}`); 43 | await this.analytics?.page(event); 44 | break; 45 | default: 46 | Logger.log(`Skipping unsupported (yet?) '${event.type}' event with\n${payloadString}`); 47 | break; 48 | } 49 | } catch (e ) { 50 | Logger.log("Failed to send event "+ toErrorMessage(e)); 51 | } 52 | 53 | } 54 | 55 | 56 | public async flush(): Promise { 57 | if (isFlusheable(this.analytics)){ 58 | this.analytics.flush(); 59 | } 60 | } 61 | 62 | public async closeAndFlush(): Promise { 63 | if (isCloseAndFlusheable(this.analytics)){ 64 | return this.analytics.closeAndFlush(); 65 | } 66 | } 67 | } 68 | 69 | interface Flusheable { 70 | flush(): void; 71 | } 72 | 73 | interface CloseAndFlusheable { 74 | closeAndFlush(): void; 75 | } 76 | 77 | function isFlusheable(object: any): object is Flusheable { 78 | return 'flush' in object; 79 | } 80 | 81 | function isCloseAndFlusheable(object: any): object is CloseAndFlusheable { 82 | return 'closeAndFlush' in object; 83 | } -------------------------------------------------------------------------------- /src/common/impl/telemetryEventQueue.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryEvent } from '../api/telemetry'; 2 | 3 | export const MAX_QUEUE_SIZE = 35; 4 | 5 | export class TelemetryEventQueue { 6 | events: TelemetryEvent[] | undefined; 7 | 8 | constructor() { 9 | this.events = []; 10 | } 11 | 12 | /* 13 | shift() should work fine until we choose to have high MAX_QUEUE_SIZE 14 | */ 15 | public addEvent(e: TelemetryEvent) { 16 | if (this.events?.length === MAX_QUEUE_SIZE) { 17 | this.events.shift(); 18 | } 19 | this.events?.push(e); 20 | } 21 | 22 | public emptyQueue() { 23 | this.events = undefined; 24 | } 25 | } 26 | -------------------------------------------------------------------------------- /src/common/impl/telemetryServiceImpl.ts: -------------------------------------------------------------------------------- 1 | import { Logger } from '../utils/logger'; 2 | import { TelemetrySettings } from '../api/settings'; 3 | import { TelemetryEventQueue } from '../impl/telemetryEventQueue'; 4 | import { TelemetryService, TelemetryEvent } from '../api/telemetry'; 5 | import { ConfigurationManager } from './configurationManager'; 6 | import { IdProvider } from '../api/idProvider'; 7 | import { Environment } from '../api/environment'; 8 | import { transform, isError } from '../utils/events'; 9 | import { IReporter } from '../api/reporter'; 10 | import { EventTracker } from './eventTracker'; 11 | import { Memento } from 'vscode'; 12 | 13 | /** 14 | * Implementation of a `TelemetryService` 15 | */ 16 | export class TelemetryServiceImpl implements TelemetryService { 17 | private startTime: number; 18 | private eventTracker: EventTracker; 19 | 20 | constructor(globalState: Memento, 21 | private reporter: IReporter, 22 | private queue: TelemetryEventQueue | undefined, 23 | private settings: TelemetrySettings, 24 | private idManager: IdProvider, 25 | private environment: Environment, 26 | private configurationManager?: ConfigurationManager 27 | ) { 28 | this.startTime = this.getCurrentTimeInSeconds(); 29 | this.eventTracker = new EventTracker(globalState); 30 | } 31 | 32 | /* 33 | Collects telemetry data and pushes to a queue when not opted in 34 | and to segment when user has opted for telemetry 35 | */ 36 | public async send(event: TelemetryEvent): Promise { 37 | Logger.log(`Event received: ${event.name}`); 38 | 39 | if (this.settings.isTelemetryEnabled()) { 40 | // flush whatever was in the queue, however it's unlikely there's anything left at this point. 41 | this.flushQueue(); 42 | this.sendEvent(event); 43 | } else if (!this.settings.isTelemetryConfigured()) { 44 | // Still waiting for opt-in?, then queue events 45 | this.queue?.addEvent(event); 46 | } 47 | } 48 | 49 | public async sendStartupEvent(): Promise { 50 | this.startTime = this.getCurrentTimeInSeconds(); 51 | return this.send({ name: 'startup' }); 52 | } 53 | public async sendShutdownEvent(): Promise { 54 | return this.send({ name: 'shutdown', properties: { 55 | //Sends session duration in seconds 56 | session_duration: this.getCurrentTimeInSeconds() - this.startTime 57 | } }); 58 | } 59 | 60 | private async sendEvent(event: TelemetryEvent): Promise { 61 | //Check against VS Code settings 62 | const level = this.settings.getTelemetryLevel(); 63 | if (level && ["error","crash"].includes(level) && !isError(event)) { 64 | return; 65 | } 66 | 67 | const userId = await this.idManager.getRedHatUUID() 68 | const payload = transform(event, userId, this.environment); 69 | 70 | //Check against Extension configuration 71 | const config = await this.configurationManager?.getExtensionConfiguration(); 72 | if (!config || config.canSend(payload)) { 73 | 74 | const dailyLimit = (config)?config.getDailyLimit(payload):Number.MAX_VALUE; 75 | let count = 0; 76 | if (dailyLimit < Number.MAX_VALUE) { 77 | //find currently stored count 78 | count = await this.eventTracker.getEventCount(payload); 79 | if (count >= dailyLimit){ 80 | //daily limit reached, do not send event 81 | Logger.log(`Daily limit reached for ${event.name}: ${dailyLimit}`); 82 | return; 83 | } 84 | } 85 | return this.reporter.report(payload).then(()=>{ 86 | if (dailyLimit < Number.MAX_VALUE) { 87 | //update count 88 | Logger.log(`Storing event count (${count+1}/${dailyLimit}) for ${event.name}`); 89 | return this.eventTracker.storeEventCount(payload, count+1); 90 | } 91 | }); 92 | } 93 | } 94 | 95 | public async flushQueue(): Promise { 96 | const eventsToFlush = this.queue?.events; 97 | if (eventsToFlush && this.settings.isTelemetryEnabled()) { 98 | while (eventsToFlush.length > 0) { 99 | const event = this.queue?.events?.shift(); 100 | if (event) { 101 | this.sendEvent(event); 102 | } 103 | } 104 | } 105 | // No matter what, we need to empty the queue if it exists 106 | this.queue?.emptyQueue(); 107 | } 108 | 109 | public async dispose(): Promise { 110 | this.queue?.emptyQueue(); 111 | return this.reporter.closeAndFlush(); 112 | } 113 | 114 | private getCurrentTimeInSeconds(): number { 115 | const now = Date.now(); 116 | return Math.floor(now/1000); 117 | } 118 | } -------------------------------------------------------------------------------- /src/common/telemetryServiceBuilder.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from './api/environment'; 2 | import { IdProvider } from './api/idProvider'; 3 | import { IReporter } from './api/reporter'; 4 | import { TelemetryService } from './api/telemetry'; 5 | import { TelemetryServiceImpl } from './impl/telemetryServiceImpl'; 6 | import { TelemetryEventQueue } from './impl/telemetryEventQueue'; 7 | import { TelemetrySettings } from './api/settings'; 8 | import { getExtensionId } from './utils/extensions'; 9 | import { ConfigurationManager } from './impl/configurationManager'; 10 | import { ExtensionContext } from 'vscode'; 11 | 12 | /** 13 | * `TelemetryService` builder 14 | */ 15 | export class TelemetryServiceBuilder { 16 | private packageJson: any; 17 | private settings?: TelemetrySettings; 18 | private idProvider?: IdProvider; 19 | private environment?: Environment; 20 | private configurationManager?: ConfigurationManager; 21 | private reporter?: IReporter; 22 | private context?:ExtensionContext; 23 | 24 | constructor(packageJson?: any) { 25 | this.packageJson = packageJson; 26 | } 27 | 28 | public setPackageJson(packageJson: any): TelemetryServiceBuilder { 29 | this.packageJson = packageJson; 30 | return this; 31 | } 32 | 33 | public setSettings(settings: TelemetrySettings): TelemetryServiceBuilder { 34 | this.settings = settings; 35 | return this; 36 | } 37 | 38 | public setIdProvider(idProvider: IdProvider): TelemetryServiceBuilder { 39 | this.idProvider = idProvider; 40 | return this; 41 | } 42 | 43 | public setEnvironment(environment: Environment): TelemetryServiceBuilder { 44 | this.environment = environment; 45 | return this; 46 | } 47 | 48 | public setConfigurationManager(configManager: ConfigurationManager): TelemetryServiceBuilder { 49 | this.configurationManager = configManager; 50 | return this; 51 | } 52 | 53 | public setReporter(reporter: IReporter): TelemetryServiceBuilder { 54 | this.reporter = reporter; 55 | return this; 56 | } 57 | 58 | public setContext(context: ExtensionContext): TelemetryServiceBuilder { 59 | this.context = context; 60 | return this; 61 | } 62 | 63 | public async build(): Promise { 64 | this.validate(); 65 | if (!this.environment) { 66 | this.environment = { 67 | extension: { 68 | name: getExtensionId(this.packageJson), 69 | version: this.packageJson.version 70 | }, 71 | application: { 72 | name: 'Unknown', 73 | version: '-' 74 | }, 75 | platform: { 76 | name: 'Unknown', 77 | version: '-' 78 | } 79 | }; 80 | } 81 | const queue = this.settings!.isTelemetryConfigured() 82 | ? undefined 83 | : new TelemetryEventQueue(); 84 | return new TelemetryServiceImpl(this.context?.globalState!, this.reporter!, queue, this.settings!, this.idProvider!, this.environment!, this.configurationManager); 85 | } 86 | 87 | private validate() { 88 | if (!this.context) { 89 | throw new Error('context is not set'); 90 | } 91 | if (!this.idProvider) { 92 | throw new Error('idProvider is not set'); 93 | } 94 | if (!this.reporter) { 95 | throw new Error('reporter is not set'); 96 | } 97 | if (!this.packageJson) { 98 | throw new Error('packageJson is not set'); 99 | } 100 | if (!this.environment) { 101 | throw new Error('Environment is not set'); 102 | } 103 | } 104 | } -------------------------------------------------------------------------------- /src/common/utils/debug.ts: -------------------------------------------------------------------------------- 1 | function startedInDebugMode(): boolean { 2 | let args: string[] | undefined; 3 | if (!(typeof process === 'undefined')) { 4 | args = (process as any)?.execArgv as string[]; 5 | } 6 | return hasDebugFlag(args); 7 | } 8 | 9 | // exported for tests 10 | function hasDebugFlag(args?: string[]): boolean { 11 | if (args) { 12 | // See https://nodejs.org/en/docs/guides/debugging-getting-started/ 13 | return args.some(arg => /^--inspect/.test(arg) || /^--debug/.test(arg)); 14 | } 15 | return false; 16 | } 17 | 18 | const IS_DEBUG = startedInDebugMode(); 19 | export default IS_DEBUG; -------------------------------------------------------------------------------- /src/common/utils/errorMessages.ts: -------------------------------------------------------------------------------- 1 | export function toErrorMessage(error: any = null, verbose: boolean = false): string { 2 | if (error) { 3 | if (typeof error === 'string') { 4 | return error; 5 | } 6 | 7 | if (error.message) { 8 | return error.message; 9 | } 10 | } 11 | return "An unknown error occurred. Please consult the log for more details."; 12 | } -------------------------------------------------------------------------------- /src/common/utils/events.ts: -------------------------------------------------------------------------------- 1 | import { AnalyticsEvent } from '../api/analyticsEvent'; 2 | import { Environment } from '../api/environment'; 3 | import { TelemetryEvent } from '../api/telemetry'; 4 | 5 | /** 6 | * Enhances a `TelemetryEvent` by injecting environmental data to its properties and context 7 | * 8 | * See segment.com fields: https://segment.com/docs/connections/spec/common/#integrations 9 | * { 10 | "anonymousId": "507f191e810c19729de860ea", 11 | "context": { 12 | "active": true, 13 | "app": { 14 | "name": "InitechGlobal", 15 | "version": "545", 16 | "build": "3.0.1.545", 17 | "namespace": "com.production.segment" 18 | }, 19 | "campaign": { 20 | "name": "TPS Innovation Newsletter", 21 | "source": "Newsletter", 22 | "medium": "email", 23 | "term": "tps reports", 24 | "content": "image link" 25 | }, 26 | "device": { 27 | "id": "B5372DB0-C21E-11E4-8DFC-AA07A5B093DB", 28 | "advertisingId": "7A3CBEA0-BDF5-11E4-8DFC-AA07A5B093DB", 29 | "adTrackingEnabled": true, 30 | "manufacturer": "Apple", 31 | "model": "iPhone7,2", 32 | "name": "maguro", 33 | "type": "ios", 34 | "token": "ff15bc0c20c4aa6cd50854ff165fd265c838e5405bfeb9571066395b8c9da449" 35 | }, 36 | "ip": "8.8.8.8", 37 | "library": { 38 | "name": "analytics.js", 39 | "version": "2.11.1" 40 | }, 41 | "locale": "en-US", 42 | "location": { 43 | "city": "San Francisco", 44 | "country": "United States", 45 | "latitude": 40.2964197, 46 | "longitude": -76.9411617, 47 | "speed": 0 48 | }, 49 | "network": { 50 | "bluetooth": false, 51 | "carrier": "T-Mobile US", 52 | "cellular": true, 53 | "wifi": false 54 | }, 55 | "os": { 56 | "name": "iPhone OS", 57 | "version": "8.1.3" 58 | }, 59 | "page": { 60 | "path": "/academy/", 61 | "referrer": "", 62 | "search": "", 63 | "title": "Analytics Academy", 64 | "url": "https://segment.com/academy/" 65 | }, 66 | "referrer": { 67 | "id": "ABCD582CDEFFFF01919", 68 | "type": "dataxu" 69 | }, 70 | "screen": { 71 | "width": 320, 72 | "height": 568, 73 | "density": 2 74 | }, 75 | "groupId": "12345", 76 | "timezone": "Europe/Amsterdam", 77 | "userAgent": "Mozilla/5.0 (iPhone; CPU iPhone OS 9_1 like Mac OS X) AppleWebKit/601.1.46 (KHTML, like Gecko) Version/9.0 Mobile/13B143 Safari/601.1" 78 | }, 79 | "integrations": { 80 | "All": true, 81 | "Mixpanel": false, 82 | "Salesforce": false 83 | }, 84 | "messageId": "022bb90c-bbac-11e4-8dfc-aa07a5b093db", 85 | "receivedAt": "2015-12-10T04:08:31.909Z", 86 | "sentAt": "2015-12-10T04:08:31.581Z", 87 | "timestamp": "2015-12-10T04:08:31.905Z", 88 | "type": "track", 89 | "userId": "97980cfea0067", 90 | "version": 2 91 | } 92 | * 93 | * @param event the event to enhance 94 | * @param environment the environment data to inject the event with 95 | */ 96 | export const IGNORED_USERS = ['user', 'gitpod', 'theia', 'vscode', 'redhat'] 97 | export const IGNORED_PROPERTIES = ['extension_name', 'extension_version', 'app_name', 'app_version', 'app_kind', 'app_remote', 'app_host', 'browser_name', 'browser_version', ''] 98 | 99 | export function transform(event: TelemetryEvent, userId: string, environment: Environment): AnalyticsEvent { 100 | //Inject Client name and version, Extension id and version, and timezone to the event properties 101 | const properties = event.properties ? sanitize(event.properties, environment) : {}; 102 | if (!(event.type) || event.type == 'track') { 103 | properties.extension_name = environment.extension.name 104 | properties.extension_version = environment.extension.version 105 | properties.app_name = environment.application.name; 106 | properties.app_version = environment.application.version; 107 | if (environment.application.uiKind) { 108 | properties.app_kind = environment.application.uiKind; 109 | } 110 | if (environment.application.remote) { 111 | properties.app_remote = environment.application.remote; 112 | } 113 | if (environment.application.appHost) { 114 | properties.app_host = environment.application.appHost; 115 | } 116 | //WTF 117 | if (environment.browser?.name) { 118 | properties.browser_name = environment.browser.name; 119 | } 120 | if (environment.browser?.version) { 121 | properties.browser_version = environment.browser.version; 122 | } 123 | } 124 | 125 | const traits = event.traits ? sanitize(event.traits, environment) : {}; 126 | if (event.type == 'identify') { 127 | //All those traits should be handled by Woopra in the context block, but are not. Meh. 128 | traits.timezone = environment.timezone; 129 | traits.os_name = environment.platform.name; 130 | traits.os_version = environment.platform.version; 131 | traits.os_distribution = environment.platform.distribution; 132 | traits.locale = environment.locale; 133 | } 134 | 135 | //Inject Platform specific data in segment's context, so it can be recognized by the end destination 136 | // XXX Currently, Woopra ignores app, os, locale and timezone 137 | const context = event.context ? event.context : {}; 138 | context.ip = '0.0.0.0'; 139 | context.app = { 140 | name: environment.application.name, 141 | version: environment.application.version 142 | }; 143 | context.os = { 144 | name: environment.platform.name, 145 | version: environment.platform.version, 146 | }; 147 | context.locale = environment.locale; 148 | context.location = { 149 | // This is inaccurate in some cases (user uses a different locale than from his actual country), 150 | // but still provides an interesting metric in most cases. 151 | country: environment.country 152 | }; 153 | context.timezone = environment.timezone; 154 | 155 | const enhancedEvent: AnalyticsEvent = { 156 | userId: userId, 157 | event: event.name, 158 | type: event.type ? event.type : 'track', // type of telemetry event such as : identify, track, page, etc. 159 | properties: properties, 160 | measures: event.measures, 161 | traits: traits, 162 | context: context 163 | }; 164 | return enhancedEvent; 165 | } 166 | 167 | function sanitize(properties: any, environment: Environment): any { 168 | const sanitized: any = {}; 169 | let usernameRegexp: RegExp | undefined; 170 | if (environment.username && environment.username.length > 3 && !IGNORED_USERS.includes(environment.username)) { 171 | usernameRegexp = new RegExp(environment.username, 'g'); 172 | } 173 | for (const p in properties) { 174 | const rawProperty = properties[p]; 175 | if (!rawProperty || IGNORED_PROPERTIES.includes(p) || !usernameRegexp || isNonStringPrimitive(rawProperty)) { 176 | sanitized[p] = rawProperty; 177 | continue; 178 | } 179 | const isObj = isObject(rawProperty); 180 | let sanitizedProperty = isObj ? JSON.stringify(rawProperty) : rawProperty; 181 | 182 | sanitizedProperty = (sanitizedProperty as string).replace(usernameRegexp, '_username_'); 183 | if (isObj) { 184 | //let's try to deserialize into a sanitized object 185 | try { 186 | sanitizedProperty = JSON.parse(sanitizedProperty); 187 | } catch (e) { 188 | //We messed up, we'll return the sanitized string instead 189 | } 190 | } 191 | sanitized[p] = sanitizedProperty; 192 | } 193 | return sanitized; 194 | } 195 | 196 | function isObject(test: any): boolean { 197 | return test === Object(test); 198 | } 199 | 200 | export function isError(event: any): boolean { 201 | return event.properties?.error || event.properties?.errors; 202 | } 203 | 204 | function isNonStringPrimitive(test: any) { 205 | return typeof test !== "string" && !(test instanceof String) && !isObject(test); 206 | } 207 | -------------------------------------------------------------------------------- /src/common/utils/extensions.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import { ExtensionContext } from "vscode"; 3 | import { readFile } from "../vscode/fsUtils"; 4 | import { DEFAULT_SEGMENT_DEBUG_KEY, DEFAULT_SEGMENT_KEY } from '../impl/constants'; 5 | 6 | export async function getExtension(context: ExtensionContext): Promise { 7 | if (context.extension) { 8 | return context.extension; 9 | } 10 | //When running in older vscode versions: 11 | const packageJson = await loadPackageJson(context.extensionPath); 12 | const info = { 13 | id: getExtensionId(packageJson), 14 | packageJSON: packageJson 15 | }; 16 | return info; 17 | } 18 | 19 | export function getExtensionId(packageJson: any): string { 20 | return `${packageJson.publisher}.${packageJson.name}`; 21 | } 22 | 23 | export async function loadPackageJson(extensionPath: string): Promise { 24 | const packageJsonPath = path.resolve(extensionPath, 'package.json') 25 | const rawdata = await readFile(packageJsonPath); 26 | const packageJson = JSON.parse(rawdata); 27 | return packageJson; 28 | } 29 | 30 | export interface ExtensionInfo { 31 | id: string, 32 | packageJSON: any 33 | } 34 | 35 | export function getPackageJson(extension: ExtensionInfo): any { 36 | const packageJson = extension.packageJSON; 37 | if (!packageJson.segmentWriteKey) { 38 | packageJson.segmentWriteKey = DEFAULT_SEGMENT_KEY; 39 | } 40 | if (!packageJson.segmentWriteKeyDebug) { 41 | packageJson.segmentWriteKeyDebug = DEFAULT_SEGMENT_DEBUG_KEY; 42 | } 43 | return packageJson; 44 | } 45 | -------------------------------------------------------------------------------- /src/common/utils/geolocation.ts: -------------------------------------------------------------------------------- 1 | 2 | import { getTimezone } from 'countries-and-timezones'; 3 | 4 | export function getCountry(timezone: string): string { 5 | const tz = getTimezone(timezone); 6 | if (tz && tz?.countries) { 7 | return tz.countries[0]; 8 | } 9 | //Probably UTC timezone 10 | return 'ZZ'; //Unknown country 11 | } -------------------------------------------------------------------------------- /src/common/utils/hashcode.ts: -------------------------------------------------------------------------------- 1 | //See https://stackoverflow.com/a/8076436/753170 2 | export function hashCode(value: string): number { 3 | let hash = 0; 4 | for (let i = 0; i < value.length; i++) { 5 | const code = value.charCodeAt(i); 6 | hash = ((hash << 5) - hash) + code; 7 | hash = hash & hash; // Convert to 32bit integer 8 | } 9 | return hash; 10 | } 11 | 12 | 13 | const cache = new Map(); 14 | 15 | export function numValue(value: string): number { 16 | let num = cache.get(value); 17 | if (num) { 18 | return num; 19 | } 20 | const hash = Math.abs(hashCode(value)).toString(); 21 | const x = Math.min(4, hash.length); 22 | num = parseFloat(hash.substring(hash.length - x)) / 10000; 23 | cache.set(value, num); 24 | return num; 25 | } 26 | -------------------------------------------------------------------------------- /src/common/utils/keyLocator.ts: -------------------------------------------------------------------------------- 1 | import { getExtensionId } from "./extensions"; 2 | import { Logger } from "./logger"; 3 | import debug from './debug'; 4 | 5 | let DEFAULT_SEGMENT_KEY: string | undefined; 6 | 7 | export function getSegmentKey(clientPackageJson: any): string | undefined { 8 | let segmentWriteKey = readSegmentKey(clientPackageJson); 9 | if (!segmentWriteKey) { 10 | //Using the default key 11 | if (!DEFAULT_SEGMENT_KEY) { 12 | const defaultPackageJson = require('../../../package.json'); 13 | DEFAULT_SEGMENT_KEY = readSegmentKey(defaultPackageJson); 14 | } 15 | segmentWriteKey = DEFAULT_SEGMENT_KEY; 16 | } 17 | return segmentWriteKey; 18 | } 19 | 20 | function readSegmentKey(packageJson: any): string | undefined { 21 | const extensionId = getExtensionId(packageJson); 22 | let keyKey = 'segmentWriteKeyDebug'; 23 | try { 24 | let clientSegmentKey: string | undefined = undefined; 25 | if (debug) { 26 | clientSegmentKey = packageJson[keyKey]; 27 | } else { 28 | keyKey = 'segmentWriteKey'; 29 | clientSegmentKey = packageJson[keyKey]; 30 | } 31 | if (clientSegmentKey) { 32 | Logger.log(`'${extensionId}' ${keyKey} : ${clientSegmentKey}`); 33 | } 34 | return clientSegmentKey; 35 | } catch (error) { 36 | Logger.log(`Unable to get '${extensionId}' ${keyKey}: ${error}`); 37 | } 38 | return undefined; 39 | } -------------------------------------------------------------------------------- /src/common/utils/logger.ts: -------------------------------------------------------------------------------- 1 | import envVars from "../envVar"; 2 | 3 | export let doLog: boolean = envVars.VSCODE_REDHAT_TELEMETRY_DEBUG === 'true'; 4 | 5 | // This exists only for testing purposes. Could delete later. 6 | const VERSION = require('../../../package.json').version; 7 | 8 | 9 | export namespace Logger { 10 | export let extId = 'unknown'; 11 | export function log(s: number | string | boolean | undefined): void { 12 | if (doLog) { 13 | console.log(`vscode-redhat-telemetry ${VERSION} (${extId}): ${s}`); 14 | } 15 | } 16 | export function info(s: number | string | boolean | undefined): void { 17 | console.info(`vscode-redhat-telemetry ${VERSION} (${extId}): ${s}`); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/common/utils/uuid.ts: -------------------------------------------------------------------------------- 1 | import { v4, v5 } from 'uuid'; 2 | 3 | const REDHAT_NAMESPACE_UUID = '44662bc6-c388-4e0e-a652-53bda6f35923'; 4 | 5 | export function generateUUID(source?: string): string { 6 | if (source) { 7 | return v5(source, REDHAT_NAMESPACE_UUID); 8 | } 9 | return v4(); 10 | } -------------------------------------------------------------------------------- /src/common/vscode/fileSystemStorageService.ts: -------------------------------------------------------------------------------- 1 | import { Uri } from "vscode"; 2 | import { exists, mkdir, readFile, writeFile } from './fsUtils'; 3 | 4 | export class FileSystemStorageService { 5 | 6 | private storagePath: Uri; 7 | 8 | constructor(storagePath: Uri) { 9 | this.storagePath = storagePath; 10 | } 11 | 12 | public async readFromFile(fileName: string): Promise { 13 | try { 14 | const filePath = Uri.joinPath(this.storagePath, fileName); 15 | //Need to await here so if file doesn't exist, it's caught by the catch clause 16 | //and we return undefined, instead of a Promise that will fail later 17 | return await readFile(filePath); 18 | } catch (e) { 19 | return undefined; 20 | } 21 | } 22 | 23 | public async writeToFile(filename: string, content: string): Promise { 24 | await this.ensureStoragePathExists(); 25 | const filePath = Uri.joinPath(this.storagePath, filename); 26 | return writeFile(filePath, content); 27 | } 28 | 29 | private async ensureStoragePathExists(): Promise { 30 | if (!(await exists(this.storagePath))) { 31 | await mkdir(this.storagePath); 32 | } 33 | } 34 | 35 | } -------------------------------------------------------------------------------- /src/common/vscode/fsUtils.ts: -------------------------------------------------------------------------------- 1 | import { Uri, workspace } from 'vscode'; 2 | import { Logger } from '../utils/logger'; 3 | 4 | //Hacky way to get Text(En|De)code work in both Node and Webworker environments 5 | const globalObj: any = globalThis; 6 | const decoder = new globalObj.TextDecoder(); 7 | const encoder = new globalObj.TextEncoder(); 8 | 9 | export async function exists(pathOrUri: string | Uri): Promise { 10 | const uri = getUri(pathOrUri); 11 | try { 12 | Logger.log('Checking ' + uri); 13 | await workspace.fs.stat(uri); 14 | return true; 15 | } catch (e) { 16 | Logger.log(uri + ' doesn\'t exist'); 17 | return false; 18 | } 19 | } 20 | export async function readFile(pathOrUri: string | Uri): Promise { 21 | const uri = getUri(pathOrUri); 22 | Logger.log('Reading ' + uri); 23 | const read = await workspace.fs.readFile(uri); 24 | return decoder.decode(read); 25 | } 26 | 27 | export async function writeFile(pathOrUri: string | Uri, content: string): Promise { 28 | const uri = getUri(pathOrUri); 29 | await ensureParentExists(uri); 30 | Logger.log('Writing ' + uri); 31 | const contentAsUint8Array = encoder.encode(content); 32 | return workspace.fs.writeFile(uri, contentAsUint8Array); 33 | } 34 | 35 | export async function mkdir(pathOrUri: string | Uri): Promise { 36 | const uri = getUri(pathOrUri); 37 | Logger.log('Creating ' + uri); 38 | await workspace.fs.createDirectory(uri); 39 | } 40 | 41 | export async function deleteFile(pathOrUri: string | Uri): Promise { 42 | const uri = getUri(pathOrUri); 43 | Logger.log('Deleting ' + uri); 44 | return workspace.fs.delete(uri); 45 | } 46 | 47 | export async function ensureParentExists(uri: Uri): Promise { 48 | const parent = Uri.joinPath(uri, '..'); 49 | if (!(await exists(parent))) { 50 | await mkdir(parent); 51 | } 52 | } 53 | 54 | function getUri(pathOrUri: string | Uri): Uri { 55 | if (pathOrUri instanceof Uri) { 56 | return pathOrUri; 57 | } 58 | return Uri.file(pathOrUri); 59 | } -------------------------------------------------------------------------------- /src/common/vscode/redhatServiceInitializer.ts: -------------------------------------------------------------------------------- 1 | import Analytics from '@segment/analytics-node'; 2 | import { ConfigurationChangeEvent, Disposable, env, ExtensionContext, workspace, window, Uri } from "vscode"; 3 | import { RedHatService } from "../api/redhatService"; 4 | import { TelemetryService } from "../api/telemetry"; 5 | import { Logger } from "../utils/logger"; 6 | import { ExtensionInfo, getExtension } from '../utils/extensions' 7 | import { didUserDisableTelemetry, VSCodeSettings } from '../vscode/settings'; 8 | import { deleteFile, exists, mkdir, readFile, writeFile } from '../vscode/fsUtils' 9 | import { OPT_OUT_INSTRUCTIONS_URL, PRIVACY_STATEMENT_URL } from '../impl/constants'; 10 | import { getSegmentKey } from '../utils/keyLocator'; 11 | 12 | const RETRY_OPTIN_DELAY_IN_MS = 24 * 60 * 60 * 1000; // 24h 13 | 14 | export abstract class AbstractRedHatServiceProvider { 15 | 16 | settings: VSCodeSettings; 17 | extensionInfo?: ExtensionInfo; 18 | extensionId?: string; 19 | context: ExtensionContext; 20 | constructor(context: ExtensionContext) { 21 | this.settings = new VSCodeSettings(); 22 | this.context= context; 23 | } 24 | 25 | public abstract buildRedHatService(): Promise; 26 | 27 | public getSegmentApi(packageJson: any): Analytics { 28 | const writeKey = getSegmentKey(packageJson)!; 29 | const maxEventsInBatch = 1; 30 | const flushInterval = 1000; 31 | const httpRequestTimeout = 3000; 32 | return new Analytics({writeKey, maxEventsInBatch, flushInterval, httpRequestTimeout}); 33 | } 34 | 35 | public getCachePath(): Uri { 36 | return Uri.joinPath(this.getTelemetryWorkingDir(this.context), 'cache'); 37 | } 38 | 39 | /** 40 | * Returns a new `RedHatService` instance for a Visual Studio Code extension. For telemetry, the following is performed: 41 | * - A preference listener enables/disables telemetry based on changes to `redhat.telemetry.enabled` 42 | * - If `redhat.telemetry.enabled` is not set, a popup requesting telemetry opt-in will be displayed 43 | * - when the extension is deactivated, a telemetry shutdown event will be emitted (if telemetry is enabled) 44 | * 45 | * @param context the extension's context 46 | * @returns a Promise of RedHatService 47 | */ 48 | public async getRedHatService(): Promise { 49 | this.extensionInfo = await getExtension(this.context); 50 | this.extensionId = this.extensionInfo?.id; 51 | Logger.extId = this.extensionId; 52 | const redhatService = await this.buildRedHatService(); 53 | 54 | const telemetryService = await redhatService.getTelemetryService(); 55 | // register disposable to send shutdown event 56 | this.context.subscriptions.push(shutdownHook(telemetryService)); 57 | 58 | // register preference listener for that extension, 59 | // so it stops/starts sending data when redhat.telemetry.enabled changes 60 | this.context.subscriptions.push(onDidChangeTelemetryEnabled(telemetryService)); 61 | 62 | this.openTelemetryOptInDialogIfNeeded(); 63 | 64 | telemetryService.send({ 65 | type: 'identify', 66 | name: 'identify' 67 | }); 68 | 69 | return redhatService; 70 | } 71 | 72 | public getTelemetryWorkingDir(context: ExtensionContext): Uri { 73 | return Uri.joinPath(context.globalStorageUri, '..', 'vscode-redhat-telemetry'); 74 | } 75 | 76 | async openTelemetryOptInDialogIfNeeded() { 77 | if (this.settings.isTelemetryConfigured() || didUserDisableTelemetry()) { 78 | return; 79 | } 80 | 81 | let popupInfo: PopupInfo | undefined; 82 | 83 | const parentDir = this.getTelemetryWorkingDir(this.context); 84 | const optinPopupInfo = Uri.joinPath(parentDir, 'redhat.optin.json'); 85 | if (await exists(optinPopupInfo)) { 86 | const rawdata = await readFile(optinPopupInfo); 87 | popupInfo = JSON.parse(rawdata); 88 | } 89 | if (popupInfo) { 90 | if (popupInfo.sessionId !== env.sessionId || popupInfo.owner !== this.extensionId) { 91 | //someone else is showing the popup, bail. 92 | return; 93 | } 94 | } else { 95 | popupInfo = { 96 | owner: this.extensionId!, 97 | sessionId: env.sessionId, 98 | time: new Date().getTime() //for troubleshooting purposes 99 | } 100 | await writeFile(optinPopupInfo, JSON.stringify(popupInfo)); 101 | this.context.subscriptions.push({ 102 | dispose: () => { safeCleanup(optinPopupInfo); } 103 | }); 104 | } 105 | 106 | const message: string = `Help Red Hat improve its extensions by allowing them to collect usage data. 107 | Read our [privacy statement](${PRIVACY_STATEMENT_URL}?from=${this.extensionId!}) 108 | and learn how to [opt out](${OPT_OUT_INSTRUCTIONS_URL}?from=${this.extensionId!}).`; 109 | 110 | const retryOptin = setTimeout(this.openTelemetryOptInDialogIfNeeded, RETRY_OPTIN_DELAY_IN_MS); 111 | let selection: string | undefined; 112 | try { 113 | selection = await window.showInformationMessage(message, 'Accept', 'Deny'); 114 | if (!selection) { 115 | //close was chosen. Ask next time. 116 | return; 117 | } 118 | clearTimeout(retryOptin); 119 | this.settings.updateTelemetryEnabledConfig(selection === 'Accept'); 120 | } finally { 121 | if (selection) { 122 | safeCleanup(optinPopupInfo); 123 | } 124 | } 125 | } 126 | } 127 | 128 | function onDidChangeTelemetryEnabled(telemetryService: TelemetryService): Disposable { 129 | return workspace.onDidChangeConfiguration( 130 | //as soon as user changed the redhat.telemetry setting, we consider 131 | //opt-in (or out) has been set, so whichever the choice is, we flush the queue 132 | (e: ConfigurationChangeEvent) => { 133 | if (e.affectsConfiguration("redhat.telemetry") || e.affectsConfiguration("telemetry")) { 134 | telemetryService.flushQueue(); 135 | } 136 | } 137 | ); 138 | } 139 | 140 | interface PopupInfo { 141 | owner: string, 142 | sessionId: string; 143 | time: number; 144 | } 145 | 146 | function safeCleanup(filePath: Uri) { 147 | try { 148 | deleteFile(filePath); 149 | } catch (err : any) { 150 | Logger.log(err); 151 | } 152 | Logger.log(`Deleted ${filePath}`); 153 | } 154 | 155 | function shutdownHook(telemetryService: TelemetryService): Disposable { 156 | return { 157 | dispose: async () => { 158 | await telemetryService.sendShutdownEvent(); 159 | await telemetryService.dispose(); 160 | Logger.log("disposed telemetry service"); 161 | } 162 | }; 163 | } 164 | -------------------------------------------------------------------------------- /src/common/vscode/settings.ts: -------------------------------------------------------------------------------- 1 | import { env, workspace, WorkspaceConfiguration } from 'vscode'; 2 | import { TelemetrySettings } from '../api/settings'; 3 | import { CONFIG_KEY } from '../impl/constants'; 4 | 5 | export class VSCodeSettings implements TelemetrySettings { 6 | isTelemetryEnabled(): boolean { 7 | return this.getTelemetryLevel() !== 'off' && getTelemetryConfiguration().get('enabled', false); 8 | } 9 | 10 | getTelemetryLevel(): string { 11 | //Respecting old vscode telemetry settings https://github.com/microsoft/vscode/blob/f09c4124a229b4149984e1c2da46f35b873d23fa/src/vs/platform/telemetry/common/telemetryUtils.ts#L131 12 | if (workspace.getConfiguration().get("telemetry.enableTelemetry") == false 13 | || workspace.getConfiguration().get("telemetry.enableCrashReporter") == false 14 | ) { 15 | return "off"; 16 | } 17 | return workspace.getConfiguration().get("telemetry.telemetryLevel", "off"); 18 | } 19 | 20 | isTelemetryConfigured(): boolean { 21 | return isPreferenceOverridden(CONFIG_KEY + '.enabled'); 22 | } 23 | 24 | updateTelemetryEnabledConfig(value: boolean): Thenable { 25 | return getTelemetryConfiguration().update('enabled', value, true); 26 | } 27 | } 28 | 29 | 30 | export function getTelemetryConfiguration(): WorkspaceConfiguration { 31 | return workspace.getConfiguration(CONFIG_KEY); 32 | } 33 | 34 | export function isPreferenceOverridden(section: string): boolean { 35 | const config = workspace.getConfiguration().inspect(section); 36 | return ( 37 | config?.workspaceFolderValue !== undefined || 38 | config?.workspaceFolderLanguageValue !== undefined || 39 | config?.workspaceValue !== undefined || 40 | config?.workspaceLanguageValue !== undefined || 41 | config?.globalValue !== undefined || 42 | config?.globalLanguageValue !== undefined 43 | ); 44 | } 45 | 46 | export function didUserDisableTelemetry(): boolean { 47 | if (env.isTelemetryEnabled) { 48 | return false; 49 | } 50 | //Telemetry is not enabled, but it might not be the user's choice. 51 | //i.e. could be the App's default setting (VS Codium), or 52 | //then the user only asked for reporting errors/crashes, in which case we can do the same. 53 | return isPreferenceOverridden("telemetry.telemetryLevel") && workspace.getConfiguration().get("telemetry.telemetryLevel") === "off"; 54 | } 55 | -------------------------------------------------------------------------------- /src/config/telemetry-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": { 3 | "enabled":"all", 4 | "refresh": "12h", 5 | "includes": [ 6 | { 7 | "name" : "startup", 8 | "dailyLimit": 1 9 | }, 10 | { 11 | "name" : "*" 12 | } 13 | ], 14 | "excludes": [ 15 | { 16 | "name": "shutdown", 17 | "ratio": "1.0" 18 | } 19 | ] 20 | }, 21 | "redhat.java": { 22 | "enabled": "all", 23 | "ratio": "0.5", 24 | "includes": [ 25 | { 26 | "name" : "startup", 27 | "dailyLimit": 1000 28 | }, 29 | { 30 | "name" : "*" 31 | } 32 | ], 33 | "excludes": [ 34 | { 35 | "name": "textCompletion", 36 | "ratio": "0.997" 37 | } 38 | ] 39 | }, 40 | "redhat.fabric8-analytics": { 41 | "enabled": "all", 42 | "ratio": "0.0" 43 | }, 44 | "redhat.vscode-yaml": { 45 | "enabled": "all", 46 | "ratio": "0.0" 47 | }, 48 | "redhat.vscode-xml": { 49 | "enabled": "all", 50 | "ratio": "0.0", 51 | "excludes": [ 52 | { 53 | "name": "server.document.open", 54 | "ratio":"0.9" 55 | } 56 | ] 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | //For legacy compatibility purposes, expose the node bits as default API 2 | import {getRedHatService, RedHatService, TelemetryService, TelemetryEvent, IdProvider} from './node'; 3 | export {getRedHatService, RedHatService, TelemetryService, TelemetryEvent, IdProvider}; -------------------------------------------------------------------------------- /src/node/cloud/cheIdProvider.ts: -------------------------------------------------------------------------------- 1 | import { extensions } from 'vscode'; 2 | import { IdProvider } from '../../common/api/idProvider'; 3 | import { Logger } from '../../common/utils/logger'; 4 | 5 | let userId: string; 6 | export class CheIdProvider implements IdProvider { 7 | 8 | constructor(private delegate: IdProvider) { }; 9 | 10 | async getRedHatUUID(): Promise { 11 | if (!userId) { 12 | userId = await this.loadRedHatUUID(); 13 | } 14 | return userId; 15 | } 16 | 17 | async loadRedHatUUID(): Promise { 18 | try { 19 | Logger.info('Reading user id from @eclipse-che.ext-plugin'); 20 | const che = extensions.getExtension('@eclipse-che.ext-plugin'); 21 | if (che) { 22 | Logger.info('Found Che API'); 23 | // grab user 24 | const user = await che.exports.user?.getCurrentUser(); 25 | 26 | if (user.id) { 27 | Logger.info(`Found Che user id ${user.id}`); 28 | return user.id; 29 | } 30 | Logger.info('No Che user id'); 31 | 32 | } else { 33 | Logger.info('No @eclipse-che.ext-plugin'); 34 | } 35 | } catch (error) { 36 | console.log('Failed to get user id from Che', error); 37 | } 38 | //fall back to generating a random UUID 39 | Logger.info('fall back to generating a random UUID'); 40 | return this.delegate.getRedHatUUID(); 41 | } 42 | } -------------------------------------------------------------------------------- /src/node/cloud/gitpodIdProvider.ts: -------------------------------------------------------------------------------- 1 | import { IdProvider } from "../../common/api/idProvider"; 2 | import env from "../../common/envVar"; 3 | import { FileSystemIdProvider } from "../fileSystemIdManager"; 4 | import { generateUUID } from "../../common/utils/uuid"; 5 | import { Logger } from "../../common/utils/logger"; 6 | 7 | let userId: string; 8 | 9 | export class GitpodIdProvider implements IdProvider { 10 | 11 | constructor(private delegate: FileSystemIdProvider) { }; 12 | 13 | async getRedHatUUID(): Promise { 14 | if (!userId) { 15 | userId = await this.loadRedHatUUID(); 16 | } 17 | return userId; 18 | } 19 | 20 | async loadRedHatUUID(): Promise { 21 | try { 22 | const email = env.GITPOD_GIT_USER_EMAIL; 23 | if (email) { 24 | userId = generateUUID(email); 25 | const anonymousIdFile = this.delegate.getAnonymousIdFile(); 26 | const existingId = this.delegate.readFile(anonymousIdFile); 27 | if (existingId !== userId) { 28 | this.delegate.writeFile(anonymousIdFile, userId); 29 | } 30 | return userId; 31 | } 32 | } catch (error) { 33 | console.log('Failed to get user id from Gitpod', error); 34 | } 35 | //fall back to generating a random UUID 36 | Logger.info('fall back to generating a random UUID'); 37 | return this.delegate.getRedHatUUID(); 38 | } 39 | 40 | } -------------------------------------------------------------------------------- /src/node/fileSystemIdManager.ts: -------------------------------------------------------------------------------- 1 | import { IdProvider } from "../common/api/idProvider"; 2 | 3 | import * as os from 'os'; 4 | import * as fs from 'fs'; 5 | import * as path from 'path'; 6 | import { Logger } from '../common/utils/logger'; 7 | import { generateUUID } from "../common/utils/uuid"; 8 | 9 | let REDHAT_ANONYMOUS_UUID: string | undefined; 10 | 11 | /** 12 | * Service providing the Red Hat anonymous user id, read/stored from the `~/.redhat/anonymousId` file. 13 | */ 14 | export class FileSystemIdProvider implements IdProvider { 15 | 16 | constructor(private redhatDir?: string) { } 17 | 18 | async getRedHatUUID(): Promise { 19 | return this.loadRedHatUUID(); 20 | } 21 | 22 | public loadRedHatUUID(uuidGenerator?: (source?: string) => string): string { 23 | if (REDHAT_ANONYMOUS_UUID) { 24 | return REDHAT_ANONYMOUS_UUID; 25 | } 26 | const redhatUUIDFilePath = this.getAnonymousIdFile(); 27 | try { 28 | REDHAT_ANONYMOUS_UUID = this.readFile(redhatUUIDFilePath); 29 | if (REDHAT_ANONYMOUS_UUID) { 30 | Logger.log(`loaded Red Hat UUID: ${REDHAT_ANONYMOUS_UUID}`); 31 | } else { 32 | Logger.log('No Red Hat UUID found'); 33 | REDHAT_ANONYMOUS_UUID = uuidGenerator ? uuidGenerator() : generateUUID(); 34 | this.writeFile(redhatUUIDFilePath, REDHAT_ANONYMOUS_UUID); 35 | Logger.log(`Written Red Hat UUID: ${REDHAT_ANONYMOUS_UUID} to ${redhatUUIDFilePath}`); 36 | } 37 | } catch (e: any) { 38 | Logger.log('Failed to access Red Hat UUID: ' + e?.message); 39 | } 40 | return REDHAT_ANONYMOUS_UUID!; 41 | } 42 | 43 | public getAnonymousIdFile(): string { 44 | const homedir = os.homedir(); 45 | if (!this.redhatDir) { 46 | this.redhatDir = path.join(homedir, '.redhat'); 47 | } 48 | return path.join(this.redhatDir, 'anonymousId'); 49 | } 50 | 51 | public readFile(filePath: string): string | undefined { 52 | let content: string | undefined; 53 | if (fs.existsSync(filePath)) { 54 | content = fs.readFileSync(filePath, { encoding: 'utf8' }); 55 | if (content) { 56 | content = content.trim(); 57 | } 58 | } 59 | return content; 60 | } 61 | 62 | public writeFile(filePath: string, content: string) { 63 | const parentDir = path.dirname(filePath); 64 | if (!fs.existsSync(parentDir)) { 65 | fs.mkdirSync(parentDir, { recursive: true }); 66 | } 67 | fs.writeFileSync(filePath, content, { encoding: 'utf8' }); 68 | } 69 | 70 | } -------------------------------------------------------------------------------- /src/node/idManagerFactory.ts: -------------------------------------------------------------------------------- 1 | import { IdProvider } from '../common/api/idProvider'; 2 | import { CheIdProvider } from './cloud/cheIdProvider' 3 | import { GitpodIdProvider } from './cloud/gitpodIdProvider' 4 | import { FileSystemIdProvider } from './fileSystemIdManager'; 5 | import env from '../common/envVar'; 6 | 7 | export namespace IdManagerFactory { 8 | 9 | export function getIdManager(): IdProvider { 10 | const fsIdManager = new FileSystemIdProvider(); 11 | if (env['CHE_WORKSPACE_ID']) { 12 | return new CheIdProvider(fsIdManager); 13 | } else if (env['GITPOD_GIT_USER_EMAIL']) { 14 | return new GitpodIdProvider(fsIdManager); 15 | } 16 | return fsIdManager; 17 | } 18 | 19 | } -------------------------------------------------------------------------------- /src/node/index.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import { RedHatService } from "../common/api/redhatService" 3 | import { TelemetryService, TelemetryEvent } from "../common/api/telemetry"; 4 | import { RedHatServiceNodeProvider } from "./redHatServiceNodeProvider"; 5 | import { IdProvider } from "../common/api/idProvider"; 6 | 7 | export { RedHatService, TelemetryService, TelemetryEvent, IdProvider }; 8 | 9 | export function getRedHatService(extension: ExtensionContext): Promise { 10 | const provider = new RedHatServiceNodeProvider(extension); 11 | return provider.getRedHatService(); 12 | } -------------------------------------------------------------------------------- /src/node/platform.ts: -------------------------------------------------------------------------------- 1 | import os from 'os'; 2 | // Can't use os-locale 6.x as it's an ESM module, incompatible with running VS Code electron tests 3 | // See https://github.com/redhat-developer/vscode-redhat-telemetry/issues/30 4 | //import { osLocaleSync } from 'os-locale'; 5 | import osLocaleSync from 'os-locale'; 6 | import getos from 'getos'; 7 | import { LinuxOs } from 'getos'; 8 | import { getCountry } from '../common/utils/geolocation'; 9 | import { Environment } from '../common/api/environment'; 10 | import { env as vscodeEnv, UIKind, version } from 'vscode'; 11 | import { promisify } from 'util'; 12 | 13 | import env from '../common/envVar'; 14 | 15 | export const PLATFORM = getPlatform(); 16 | export const DISTRO = getDistribution(); 17 | export const PLATFORM_VERSION = os.release(); 18 | export const TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; 19 | //export const LOCALE = osLocaleSync().replace('_', '-'); 20 | export const LOCALE = osLocaleSync.sync().replace('_', '-'); 21 | export const COUNTRY = getCountry(TIMEZONE); 22 | export const UI_KIND = getUIKind(); 23 | export const USERNAME = getUsername(); 24 | 25 | 26 | function getPlatform(): string { 27 | const platform: string = os.platform(); 28 | if (platform.startsWith('win')) { 29 | return 'Windows'; 30 | } 31 | if (platform.startsWith('darwin')) { 32 | return 'Mac'; 33 | } 34 | return platform.charAt(0).toUpperCase() + platform.slice(1); 35 | } 36 | async function getDistribution(): Promise { 37 | if (os.platform() === 'linux') { 38 | const platorm = await promisify(getos)() as LinuxOs; 39 | return platorm.dist; 40 | } 41 | return undefined; 42 | } 43 | 44 | export async function getEnvironment(extensionId: string, extensionVersion: string): Promise { 45 | return { 46 | extension: { 47 | name: extensionId, 48 | version: extensionVersion, 49 | }, 50 | application: { 51 | name: vscodeEnv.appName, 52 | version: version, 53 | uiKind: UI_KIND, 54 | remote: vscodeEnv.remoteName !== undefined, 55 | appHost: vscodeEnv.appHost 56 | }, 57 | platform: { 58 | name: PLATFORM, 59 | version: PLATFORM_VERSION, 60 | distribution: await DISTRO 61 | }, 62 | timezone: TIMEZONE, 63 | locale: LOCALE, 64 | country: COUNTRY, 65 | username: USERNAME 66 | }; 67 | } 68 | function getUIKind(): string { 69 | switch (vscodeEnv.uiKind) { 70 | case UIKind.Desktop: 71 | return 'Desktop'; 72 | case UIKind.Web: 73 | return 'Web'; 74 | default: 75 | return 'Unknown'; 76 | } 77 | } 78 | 79 | function getUsername(): string | undefined { 80 | 81 | let username = ( 82 | env.SUDO_USER || 83 | env.C9_USER /* Cloud9 */ || 84 | env.LOGNAME || 85 | env.USER || 86 | env.LNAME || 87 | env.USERNAME 88 | ); 89 | if (!username) { 90 | try { 91 | username = os.userInfo().username; 92 | } catch (_) { } 93 | } 94 | return username; 95 | } 96 | 97 | -------------------------------------------------------------------------------- /src/node/redHatServiceNodeProvider.ts: -------------------------------------------------------------------------------- 1 | import { Reporter } from '../common/impl/reporter'; 2 | import { RedHatService } from '../common/api/redhatService'; 3 | import { ConfigurationManager } from '../common/impl/configurationManager'; 4 | import { TelemetryServiceBuilder } from '../common/telemetryServiceBuilder'; 5 | import { getExtension, getPackageJson } from '../common/utils/extensions'; 6 | import { FileSystemStorageService } from '../common/vscode/fileSystemStorageService'; 7 | import { AbstractRedHatServiceProvider } from '../common/vscode/redhatServiceInitializer'; 8 | import { IdManagerFactory } from './idManagerFactory'; 9 | import { getEnvironment } from './platform'; 10 | import { EventCacheService } from '../common/impl/eventCacheService' 11 | 12 | export class RedHatServiceNodeProvider extends AbstractRedHatServiceProvider { 13 | 14 | public async buildRedHatService(): Promise { 15 | const extensionInfo = await getExtension(this.context); 16 | const extensionId = extensionInfo.id; 17 | const packageJson = getPackageJson(extensionInfo); 18 | const storageService = new FileSystemStorageService(this.getCachePath()); 19 | const reporter = new Reporter(this.getSegmentApi(packageJson), new EventCacheService(storageService)); 20 | const idManager = IdManagerFactory.getIdManager(); 21 | const builder = new TelemetryServiceBuilder(packageJson) 22 | .setContext(this.context) 23 | .setSettings(this.settings) 24 | .setIdProvider(idManager) 25 | .setReporter(reporter) 26 | .setConfigurationManager(new ConfigurationManager(extensionId, storageService)) 27 | .setEnvironment(await getEnvironment(extensionId, packageJson.version)); 28 | 29 | const telemetryService = await builder.build(); 30 | return { 31 | getTelemetryService: () => Promise.resolve(telemetryService), 32 | getIdProvider: () => Promise.resolve(idManager) 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/tests/config/telemetry-config.json: -------------------------------------------------------------------------------- 1 | { 2 | "*": { 3 | "enabled":"all", 4 | "refresh": "2h", 5 | "ratio": "1", 6 | "includes": [ 7 | { 8 | "name" : "*" 9 | } 10 | ] 11 | }, 12 | "redhat.vscode-hypothetical": { 13 | "enabled": "errors", 14 | "ratio": "0.5", 15 | "excludes": [ 16 | { 17 | "property": "error", 18 | "value": "*stackoverflow*" 19 | } 20 | ] 21 | } 22 | } -------------------------------------------------------------------------------- /src/tests/gitpod/gitpodIdManager.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import * as fs from 'fs'; 3 | import { GitpodIdProvider } from '../../node/cloud/gitpodIdProvider' 4 | import env from '../../common/envVar'; 5 | import { FileSystemIdProvider } from '../../node/fileSystemIdManager'; 6 | 7 | const redhatDir = `${process.cwd()}/.redhat/`; 8 | 9 | suite('Test gitpod Id manager', () => { 10 | setup(() => { 11 | if (fs.existsSync(redhatDir)) { 12 | fs.rmSync(redhatDir, { recursive: true, force: true }); 13 | } 14 | }); 15 | teardown(() => { 16 | if (fs.existsSync(redhatDir)) { 17 | fs.rmSync(redhatDir, { recursive: true, force: true }); 18 | } 19 | }); 20 | test('Should generate Red Hat UUID from GITPOD_GIT_USER_EMAIL env', async () => { 21 | env.GITPOD_GIT_USER_EMAIL = 'some.user@company.com'; 22 | console.log(env.GITPOD_GIT_USER_EMAIL); 23 | const fsIdProvider = new FileSystemIdProvider(redhatDir); 24 | const gitpod = new GitpodIdProvider(fsIdProvider); 25 | const id = await gitpod.loadRedHatUUID(); 26 | const expectedId = '465b7cd6-0f77-5fc8-97ed-7b6342df109f'; 27 | assert.strictEqual(id, expectedId); 28 | 29 | //Check anonymousId file was updated 30 | const anonymousId = fsIdProvider.readFile(fsIdProvider.getAnonymousIdFile()); 31 | assert.strictEqual(anonymousId, id); 32 | }); 33 | }); -------------------------------------------------------------------------------- /src/tests/services/configuration.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import { AnalyticsEvent } from "../../common/api/analyticsEvent"; 3 | import { Configuration } from "../../common/impl/configuration"; 4 | import { generateUUID } from "../../common/utils/uuid"; 5 | import { hashCode, numValue } from "../../common/utils/hashcode"; 6 | suite('Test configurations', () => { 7 | 8 | const all = { 9 | "enabled": "all", 10 | "includes": [ 11 | { 12 | "name": "*" 13 | } 14 | ] 15 | }; 16 | 17 | const identify = { 18 | "enabled": "all", 19 | "includes": [ 20 | { 21 | "name": "identify" 22 | } 23 | ] 24 | }; 25 | 26 | const off = { 27 | "enabled": "off", 28 | "includes": [ 29 | { 30 | "name": "*" 31 | } 32 | ] 33 | }; 34 | 35 | const errors = { 36 | "enabled": "error", 37 | "excludes": [ 38 | { 39 | "property": "error", 40 | "value": "*stackoverflow*" 41 | } 42 | ] 43 | } 44 | 45 | const ratioed = { 46 | "ratio":"0.3" 47 | }; 48 | 49 | const ratioedEvent = { 50 | "excludes": [ 51 | { 52 | "name": "verbose-event", 53 | "ratio": "0.7" 54 | } 55 | ] 56 | }; 57 | 58 | const extremeRatioedEvent = { 59 | "excludes": [ 60 | { 61 | "name": "verbose-event", 62 | "ratio": "0.997" // exclude 99.7%, i.e keep 0.3% 63 | } 64 | ] 65 | }; 66 | 67 | const fullyRatioedEvent = { 68 | "ratio": "0" 69 | } 70 | 71 | test('Should allow all events****', async () => { 72 | const config = new Configuration(all); 73 | let event = { event: "something", userId: "abcd"} as AnalyticsEvent; 74 | assert.ok(config.canSend(event) === true); 75 | }); 76 | 77 | test('Should not allow any events', async () => { 78 | const config = new Configuration(off); 79 | let event = { event: "something" } as AnalyticsEvent; 80 | assert.ok(config.canSend(event) === false); 81 | }); 82 | 83 | test('Should filter events by name', async () => { 84 | const config = new Configuration(identify); 85 | let event = { 86 | userId: "abcd", 87 | event: "identify", 88 | } as AnalyticsEvent; 89 | assert.ok(config.canSend(event) === true); 90 | event = { 91 | userId: "abcd", 92 | event: "startup", 93 | } as AnalyticsEvent; 94 | assert.ok(config.canSend(event) === false); 95 | }); 96 | 97 | test('Should only allow errors', async () => { 98 | const config = new Configuration(errors); 99 | let event = { 100 | userId: "abcd", 101 | event: "startup", 102 | } as AnalyticsEvent; 103 | assert.ok(config.canSend(event) === false, `${event.event} shouldn't be sent`); 104 | event = { 105 | userId: "abcd", 106 | event: "failed-analysis", 107 | properties: { 108 | "error": "Ohoh, an error occurred!" 109 | } 110 | } as AnalyticsEvent; 111 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 112 | event = { 113 | userId: "abcd", 114 | event: "crash-analysis", 115 | properties: { 116 | "error": "Bla bla stackoverflow bla" 117 | } 118 | } as AnalyticsEvent; 119 | assert.ok(config.canSend(event) === false, `${event.event} shouldn't be sent`); 120 | }); 121 | 122 | test('Should apply ratio on userId', async () => { 123 | // Generate test UUIDs 124 | // for (let index = 0; index < 20; index++) { 125 | // const uuid = generateUUID(); 126 | // console.log(`${uuid} hashcode:${hashCode(uuid)} numvalue:${numValue(uuid)}`); 127 | // } 128 | 129 | /* 130 | 6c4698ed-85f3-4448-9b0f-10897b8b4178 hashcode:349419899 numvalue:0.9899 131 | 870c8e59-9299-437f-a4dd-5bd331352ec7 hashcode:-2018427608 numvalue:0.7608 132 | c020f453-6811-4545-a3aa-3c5cc17d6fe8 hashcode:-252979871 numvalue:0.9871 133 | db3f9e5e-2dd5-4d81-aac8-aa75333c105c hashcode:1140739481 numvalue:0.9481 134 | 8abd3beb-c930-46a0-b244-7f1c6f9857da hashcode:82715988 numvalue:0.5988 135 | d839a99f-6afc-4309-bcb7-5d1e78eb0241 hashcode:1829289193 numvalue:0.9193 136 | 08f87a61-077a-4cb3-b9f9-4e5751d4dc96 hashcode:1602451551 numvalue:0.1551 137 | 72f09a0e-1fa6-46d1-8322-48ac0ffa4252 hashcode:-633581890 numvalue:0.189 138 | c1d68afc-a39e-4b89-bb95-e9d7684efe7c hashcode:-1103007680 numvalue:0.768 139 | a52ec11d-35bf-4579-88fd-72de5c6a0467 hashcode:158094785 numvalue:0.4785 140 | d136d7b4-518a-43b6-bb1d-1dafd9e1e52b hashcode:2110423401 numvalue:0.3401 141 | ddf95114-333d-41e0-b1ba-d84bc6293634 hashcode:1889783579 numvalue:0.3579 142 | fb833841-75de-435e-98d2-ab0988712340 hashcode:1464118621 numvalue:0.8621 143 | 71f327fa-e8ed-4fdc-92d9-5de6a1f47229 hashcode:-367676488 numvalue:0.6488 144 | 82b4c9f4-73e3-4e4a-b243-dc4aff91b9f6 hashcode:224204832 numvalue:0.4832 145 | 16d122ec-9122-4392-a90f-71504ef40c6f hashcode:1020229945 numvalue:0.9945 146 | 570447c7-168e-4d3d-be40-e5559dd4f86b hashcode:-690069930 numvalue:0.993 147 | cc4ee6ef-6862-4468-ac51-a64f237f84f5 hashcode:-1247454805 numvalue:0.4805 148 | b2ee8320-4dff-44a1-87e2-ca9daa9e24ed hashcode:-1381801037 numvalue:0.1037 149 | 4e97382d-6042-4001-889d-ecc0cb4e8862 hashcode:-1911346601 numvalue:0.6601 150 | */ 151 | 152 | const config = new Configuration(ratioed); 153 | let event = { 154 | userId: "8abd3beb-c930-46a0-b244-7f1c6f9857da", //numvalue:0.5988 > 0.3 155 | event: "startup" 156 | } as AnalyticsEvent; 157 | assert.ok(config.canSend(event) === false, `${event.event} shouldn't be sent`); 158 | event = { 159 | userId: "72f09a0e-1fa6-46d1-8322-48ac0ffa4252",//numvalue:0.189 < 0.3 160 | event: "startup", 161 | } as AnalyticsEvent; 162 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 163 | event = { 164 | userId: "ddf95114-333d-41e0-b1ba-d84bc6293634",//numvalue:0.3579 > 0.3 165 | event: "startup", 166 | } as AnalyticsEvent; 167 | assert.ok(config.canSend(event) === false, `${event.event} shouldn't be sent`); 168 | }); 169 | 170 | test('Should apply ratio on event', async () => { 171 | const config = new Configuration(ratioedEvent); 172 | let event = { 173 | userId: "8668869d-a068-412b-9e59-4fec9dc0483a", 174 | event: "startup" 175 | } as AnalyticsEvent; 176 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 177 | 178 | event = { 179 | userId: "ceef2ce6-72e1-4ebf-9493-8df2d84b3eb9", 180 | event: "startup", 181 | } as AnalyticsEvent; 182 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 183 | 184 | event = { 185 | userId: "72f09a0e-1fa6-46d1-8322-48ac0ffa4252",//numvalue:0.189 < (1- 0.7) 186 | event: "verbose-event", 187 | } as AnalyticsEvent; 188 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 189 | 190 | 191 | event = { 192 | userId: "ddf95114-333d-41e0-b1ba-d84bc6293634",//numvalue:0.3579 > (1 - 0.7) 193 | event: "verbose-event", 194 | } as AnalyticsEvent; 195 | assert.ok(config.canSend(event) === false, `${event.event} should not be sent`); 196 | }); 197 | 198 | 199 | test('Should exclude 99.7% of event', async () => { 200 | // Generate test UUIDs 201 | // const uuid = ''; 202 | // var index = 0; 203 | // while (true) { 204 | // index++; 205 | // const uuid = generateUUID(); 206 | // const num = numValue(uuid); 207 | // if (num <= 0.003) { 208 | // console.log(`${uuid} hashcode:${hashCode(uuid)} numvalue:${numValue(uuid)} after ${index} tries`); 209 | // //55ec5918-6f60-47a5-b46c-63d567d8e367 hashcode:-1247100023 numvalue:0.0023 after 263 tries 210 | // break; 211 | // } 212 | // } 213 | 214 | const config = new Configuration(extremeRatioedEvent); 215 | let event = { 216 | userId: "8668869d-a068-412b-9e59-4fec9dc0483a", 217 | event: "startup" 218 | } as AnalyticsEvent; 219 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 220 | 221 | event = { 222 | userId: "55ec5918-6f60-47a5-b46c-63d567d8e367",//numvalue:0.0023 < (1- 0.997) 223 | event: "verbose-event", 224 | } as AnalyticsEvent; 225 | assert.ok(config.canSend(event) === true, `${event.event} should be sent`); 226 | 227 | 228 | event = { 229 | userId: "72f09a0e-1fa6-46d1-8322-48ac0ffa4252",//numvalue:0.189 > (1 - 0.997) 230 | event: "verbose-event", 231 | } as AnalyticsEvent; 232 | assert.ok(config.canSend(event) === false, `${event.event} should not be sent`); 233 | }); 234 | 235 | test('Should exclude 100.0% of events', async () => { 236 | const config = new Configuration(fullyRatioedEvent); 237 | let event = { 238 | userId: "f2cec861-8a0e-46cf-b385-08c7676e6e7e", // works out to a hash of ###0000, which means a ratio of 0 239 | event: "not-wanted-event" 240 | } as AnalyticsEvent; 241 | assert.ok(config.canSend(event) === false, `${event.event} shouldn't be sent`); 242 | }); 243 | }); -------------------------------------------------------------------------------- /src/tests/services/configurationManager.disabled.ts: -------------------------------------------------------------------------------- 1 | import * as assert from "assert"; 2 | import * as fs from 'fs'; 3 | import { afterEach, beforeEach } from "mocha"; 4 | import * as mockFS from 'mock-fs'; 5 | import * as path from "path"; 6 | import { Uri } from "vscode"; 7 | import env from "../../common/envVar"; 8 | import { ConfigurationManager, DEFAULT_CONFIG_URL, TELEMETRY_CONFIG } from "../../common/impl/configurationManager"; 9 | import { FileSystemStorageService } from "../../common/vscode/fileSystemStorageService"; 10 | 11 | //DISABLED as we need to mock VS Code 12 | suite('Test configuration manager', () => { 13 | 14 | let configurationManager: ConfigurationManager; 15 | 16 | let realFetch: typeof fetch; 17 | 18 | const cacheDir = `${process.cwd()}/extension/cache`; 19 | const storageService = new FileSystemStorageService(Uri.file(cacheDir)); 20 | 21 | const remoteConfig = { 22 | "*": { 23 | "enabled":"all", 24 | "refresh": "3h", 25 | "ratio": "1", 26 | "includes": [ 27 | { 28 | "name" : "*" 29 | } 30 | ] 31 | }, 32 | "redhat.vscode-yaml":{ 33 | "enabled": "errors", 34 | "ratio": "0.5", 35 | "excludes": [ 36 | { 37 | "property": "error", 38 | "value": "*stackoverflow*" 39 | } 40 | ] 41 | }, 42 | "redhat.vscode-hypothetical": { 43 | "enabled": "off" 44 | } 45 | } 46 | 47 | beforeEach(() => { 48 | mockFS({ 49 | 'extension/cache': {} 50 | }); 51 | realFetch = global.fetch; 52 | configurationManager = new ConfigurationManager('redhat.vscode-hypothetical', storageService); 53 | global.fetch = (url) => { 54 | if (url === DEFAULT_CONFIG_URL) { 55 | return Promise.resolve(new Response( 56 | Buffer.from(JSON.stringify(remoteConfig)).buffer, 57 | { status: 200 } 58 | )); 59 | } 60 | }; 61 | }); 62 | 63 | afterEach(() => { 64 | env[ConfigurationManager.TEST_CONFIG_KEY] = undefined; 65 | global.fetch = realFetch; 66 | mockFS.restore(); 67 | }); 68 | 69 | test('Should download remote config', async () => { 70 | const json = await configurationManager.fetchRemoteConfiguration(); 71 | assert.deepStrictEqual(json, remoteConfig); 72 | }); 73 | 74 | test('Should update stale config', async ()=> { 75 | const origTimestamp = '12345678'; 76 | mockFS.restore(); 77 | mockFS({ 78 | 'extension/cache': { 79 | 'telemetry-config.json': '{'+ 80 | '"*": {'+ 81 | '"enabled":"errors",'+ 82 | '"timestamp" : "12345678",'+ 83 | '"refresh": "12h"'+ 84 | '}'+ 85 | '}' 86 | } 87 | }); 88 | const config = await configurationManager.getExtensionConfiguration(); 89 | const referenceTimestamp = config.json.timestamp; 90 | assert.notStrictEqual(referenceTimestamp, origTimestamp); 91 | assert.strictEqual(config.json.enabled, 'off'); 92 | 93 | const configPath = path.join(cacheDir, TELEMETRY_CONFIG); 94 | const jsonConfig = JSON.parse(fs.readFileSync(configPath, { encoding: 'utf8' })); 95 | assert.strictEqual(jsonConfig.timestamp, referenceTimestamp); 96 | }); 97 | 98 | test('Should store remote content locally', async ()=> { 99 | const filePath = path.join(cacheDir, TELEMETRY_CONFIG); 100 | assert.ok(!fs.existsSync(filePath), `${TELEMETRY_CONFIG} should not exist`); 101 | 102 | const config1 = await configurationManager.getExtensionConfiguration(); 103 | assert.ok(fs.existsSync(filePath), `${TELEMETRY_CONFIG} should exist`); 104 | const referenceTimestamp = config1.json.timestamp; 105 | 106 | //No http request was made here 107 | configurationManager = new ConfigurationManager('redhat.vscode-other', storageService); 108 | const config = await configurationManager.getExtensionConfiguration(); 109 | assert.strictEqual(config.json.timestamp, referenceTimestamp);//Same timestamp 110 | delete config.json['timestamp']; 111 | assert.deepStrictEqual(config.json ,{ 112 | "refresh": "3h", 113 | "includes": [ 114 | { 115 | "name" : "*" 116 | } 117 | ], 118 | "enabled": "all", 119 | "ratio": "1" 120 | }); 121 | }); 122 | 123 | test('Should inherit config', async () => { 124 | configurationManager = new ConfigurationManager('random-vscode', storageService); 125 | const config = await configurationManager.getExtensionConfiguration(); 126 | assert.ok(config.json.timestamp); 127 | delete config.json['timestamp']; 128 | assert.deepStrictEqual(config.json ,{ 129 | "enabled":"all", 130 | "refresh": "3h", 131 | "ratio": "1", 132 | "includes": [ 133 | { 134 | "name" : "*" 135 | } 136 | ] 137 | },); 138 | }); 139 | 140 | test('Should read embedded config', async () => { 141 | mockFS.restore(); 142 | env[ConfigurationManager.TEST_CONFIG_KEY] = 'true'; 143 | global.fetch = (url) => { 144 | if (url === DEFAULT_CONFIG_URL) { 145 | return Promise.resolve(new Response( 146 | undefined, 147 | { status: 404 } 148 | )); 149 | } 150 | }; 151 | 152 | const config = await configurationManager.getExtensionConfiguration(); 153 | assert.deepStrictEqual(config.json ,{ 154 | "refresh": "2h", 155 | "includes": [ 156 | { 157 | "name" : "*" 158 | } 159 | ], 160 | "enabled": "errors", 161 | "ratio": "0.5", 162 | "excludes": [ 163 | { 164 | "property": "error", 165 | "value": "*stackoverflow*" 166 | } 167 | ] 168 | }); 169 | }); 170 | }); -------------------------------------------------------------------------------- /src/tests/telemetryEventQueue.test.ts: -------------------------------------------------------------------------------- 1 | import { TelemetryEvent } from "../common/api/telemetry"; 2 | import { 3 | TelemetryEventQueue, 4 | MAX_QUEUE_SIZE, 5 | } from "../common/impl/telemetryEventQueue"; 6 | import * as assert from "assert"; 7 | 8 | let dummyEvent: TelemetryEvent = { name: "test" }; 9 | 10 | suite("Event Queue Test Suite", () => { 11 | let queue = new TelemetryEventQueue(); 12 | test("should generate event queue", () => { 13 | assert.strictEqual(typeof queue.events, typeof []); 14 | }); 15 | test("should push element in event queue", () => { 16 | queue.addEvent(dummyEvent); 17 | assert.strictEqual(queue.events?.length, 1); 18 | }); 19 | test("should test array limits", () => { 20 | for (let index = 0; index < MAX_QUEUE_SIZE + 1; index++) { 21 | queue.addEvent(dummyEvent); 22 | } 23 | assert.strictEqual(queue.events?.length, MAX_QUEUE_SIZE); 24 | }); 25 | test("should destroy the queue", () => { 26 | queue.emptyQueue(); 27 | assert.strictEqual(queue.events, undefined); 28 | }); 29 | }); 30 | -------------------------------------------------------------------------------- /src/tests/utils/events.test.ts: -------------------------------------------------------------------------------- 1 | import * as utils from '../../common/utils/events'; 2 | import * as assert from 'assert'; 3 | import { Environment } from '../../common/api/environment'; 4 | import { TelemetryEvent } from '../../common/api/telemetry'; 5 | 6 | const env: Environment = { 7 | application: { 8 | name:'SuperCode', 9 | version:'6.6.6' 10 | }, 11 | extension: { 12 | name: 'my-ext', 13 | version: '1.2.3' 14 | }, 15 | username:'Fred', 16 | platform: { 17 | name: 'DeathStar II' 18 | }, 19 | } 20 | 21 | const USER_ID = "1234"; 22 | 23 | suite('Test events enhancements', () => { 24 | test('should inject environment data', async () => { 25 | const event: TelemetryEvent = { 26 | name:'Something', 27 | properties: { 28 | foo: 'bar', 29 | } 30 | } 31 | 32 | const betterEvent = utils.transform(event, USER_ID, env); 33 | assert.strictEqual(betterEvent.properties.app_name, 'SuperCode'); 34 | assert.strictEqual(betterEvent.properties.app_version, '6.6.6'); 35 | assert.strictEqual(betterEvent.properties.extension_name, 'my-ext'); 36 | assert.strictEqual(betterEvent.properties.extension_version, '1.2.3'); 37 | assert.strictEqual(betterEvent.properties.foo, 'bar'); 38 | assert.strictEqual(betterEvent.context.ip, '0.0.0.0'); 39 | 40 | }); 41 | 42 | test('should anonymize data', async () => { 43 | const event: TelemetryEvent = { 44 | name:'Something', 45 | properties: { 46 | foo: 'Fred is Fred', 47 | qty: 10, 48 | active: false, 49 | bar: 'That c:\\Fred\\bar looks like a path', 50 | error: 'An error occurred in /Users/Fred/foo/bar.txt! But we\'re fine', 51 | multiline: 'That url file://Fred/bar.txt is gone!\nNot that c:\\user\\bar though', 52 | obj: { 53 | q: 'Who is Fred?', 54 | a: 'Fred who?' 55 | } 56 | } 57 | } 58 | 59 | const betterEvent = utils.transform(event, USER_ID, env); 60 | 61 | assert.strictEqual(betterEvent.properties.qty, 10); 62 | assert.strictEqual(betterEvent.properties.active, false); 63 | assert.strictEqual(betterEvent.properties.foo, '_username_ is _username_'); 64 | assert.strictEqual(betterEvent.properties.bar, 'That c:\\_username_\\bar looks like a path'); 65 | assert.strictEqual(betterEvent.properties.error, 'An error occurred in /Users/_username_/foo/bar.txt! But we\'re fine'); 66 | assert.strictEqual(betterEvent.properties.multiline, 'That url file://_username_/bar.txt is gone!\nNot that c:\\user\\bar though'); 67 | assert.strictEqual(betterEvent.properties.obj.q, 'Who is _username_?'); 68 | assert.strictEqual(betterEvent.properties.obj.a, '_username_ who?'); 69 | }); 70 | 71 | test('should not anonymize special usernames', async () => { 72 | utils.IGNORED_USERS.forEach((user) => { 73 | const cheEnv: Environment = { 74 | application: { 75 | name:'SuperCode', 76 | version:'6.6.6' 77 | }, 78 | extension: { 79 | name: 'my-ext', 80 | version: '1.2.3' 81 | }, 82 | username: user, 83 | platform: { 84 | name: 'DeathStar II' 85 | }, 86 | } 87 | 88 | const event: TelemetryEvent = { 89 | name:'Something', 90 | properties: { 91 | foo: 'vscode likes theia', 92 | multiline: 'That gitpod \nusername is a redhat user', 93 | } 94 | } 95 | 96 | const betterEvent = utils.transform(event, USER_ID, cheEnv); 97 | assert.strictEqual(betterEvent.properties.foo, event.properties.foo); 98 | assert.strictEqual(betterEvent.properties.multiline, event.properties.multiline); 99 | }); 100 | }); 101 | 102 | test('should not anonymize technical properties', async () => { 103 | const someEnv: Environment = { 104 | application: { 105 | name:'codename', 106 | version:'codename' 107 | }, 108 | extension: { 109 | name: 'codename', 110 | version: 'codename' 111 | }, 112 | username: 'codename', 113 | platform: { 114 | name: 'codename' 115 | }, 116 | } 117 | 118 | const event: TelemetryEvent = { 119 | name:'Something', 120 | properties: { 121 | foo: 'codename likes vscode', 122 | multiline: 'That gitpod \ncodename is a redhat user', 123 | } 124 | } 125 | 126 | const betterEvent = utils.transform(event, USER_ID, someEnv); 127 | assert.strictEqual(betterEvent.properties.extension_name, someEnv.extension.name); 128 | assert.strictEqual(betterEvent.properties.extension_version, someEnv.extension.version); 129 | assert.strictEqual(betterEvent.properties.app_name, someEnv.application.name); 130 | assert.strictEqual(betterEvent.properties.app_version, someEnv.application.version); 131 | assert.strictEqual(betterEvent.properties.foo, '_username_ likes vscode'); 132 | assert.strictEqual(betterEvent.properties.multiline, 'That gitpod \n_username_ is a redhat user'); 133 | }); 134 | }); 135 | -------------------------------------------------------------------------------- /src/tests/utils/geolocation.test.ts: -------------------------------------------------------------------------------- 1 | import * as assert from 'assert'; 2 | import { getCountry } from '../../common/utils/geolocation'; 3 | 4 | suite('Test get country from timezone', () => { 5 | test('known country', async () => { 6 | assert.strictEqual('FR', getCountry("Europe/Paris")); 7 | }); 8 | test('unknown country', async () => { 9 | assert.strictEqual('ZZ', getCountry("")); 10 | assert.strictEqual('ZZ', getCountry("Groland/Groville")); 11 | }); 12 | }); -------------------------------------------------------------------------------- /src/webworker/index.ts: -------------------------------------------------------------------------------- 1 | import { ExtensionContext } from "vscode"; 2 | import { RedHatService } from "../common/api/redhatService" 3 | import { TelemetryService, TelemetryEvent} from "../common/api/telemetry"; 4 | import { RedHatServiceWebWorkerProvider } from "./redHatServiceWebWorkerProvider"; 5 | 6 | export {RedHatService, TelemetryService, TelemetryEvent}; 7 | 8 | export function getRedHatService(extension: ExtensionContext): Promise { 9 | const provider = new RedHatServiceWebWorkerProvider(extension); 10 | return provider.getRedHatService(); 11 | } -------------------------------------------------------------------------------- /src/webworker/platform.ts: -------------------------------------------------------------------------------- 1 | import { Environment } from '../common/api/environment'; 2 | import { env as vscodeEnv, UIKind, version} from 'vscode'; 3 | 4 | import { getCountry } from '../common/utils/geolocation'; 5 | import env from '../common/envVar'; 6 | import UAParser from 'ua-parser-js'; 7 | 8 | let userAgentInfo : UAParser.IResult; 9 | 10 | function getUAInfo() { 11 | if (userAgentInfo) { 12 | return userAgentInfo; 13 | } 14 | userAgentInfo = new UAParser(navigator.userAgent).getResult(); 15 | return userAgentInfo; 16 | } 17 | 18 | // list of known Linux distros given by ChatGPT ;-), tweaked a bit 19 | const linuxes = /^((.*)Ubuntu(.*)|Debian|Fedora|CentOS|Red Hat|(.*)Linux(.*)|Gentoo|(.*)SUSE(.*)|Slackware|Solus|Manjaro|Raspbian|Elementary OS|Zorin OS|Pop!_OS|Endless OS|Deepin|Tails|BlackArch|BackBox|Parrot Security|Knoppix|Peppermint|LXLE|Chrome OS|CrunchBang|Finnix|FreeNAS|gNewSense|NimbleX|NixOS|Ophcrack|PFSense|pfSense|Sabayon|SliTaz|Zenwalk|ArchBang|ArchLabs|Artix|BlankOn|BlueOnyx|Calcula|Calculate|CRUX|Devuan|Frugalware|Funtoo|GParted|Joli OS|Kanotix|Lakka|Linspire|Madbox|Makulu|NimbleX|NixOS|NST|OpenELEC|OpenIndiana|OpenMandriva|OpenVZ|Q4OS|Qubes OS|ReactOS|Sabayon|Salix|Slackel|Slackware|Slax|SmartOS|SME Server|Sonic|SystemRescueCd|Trisquel|TrueOS|VyOS|XenServer|Zorin OS|CRUX)$/i 20 | 21 | function getPlatform(): string { 22 | const platform: string = (getUAInfo().os.name)?getUAInfo().os.name!:"Unknown"; 23 | if (platform.startsWith('Windows') ) { 24 | return 'Windows'; 25 | } 26 | if (platform.startsWith('Mac') ) { 27 | return 'Mac'; 28 | } 29 | if (platform.toLowerCase().indexOf('BSD') > -1 ) { 30 | return 'BSD'; 31 | } 32 | //This is brittle AF. Testing against a bunch of hardcoded distros, the list can only go stale, 33 | //but we want to limit the amount of platforms here 34 | if (linuxes.test(platform)) { 35 | return "Linux"; 36 | } 37 | return "Unknown"; 38 | } 39 | function getDistribution(): string|undefined { 40 | const os = getPlatform(); 41 | if (os === 'Linux' || os === 'Unknown'|| os === 'BSD' && getUAInfo().os.name) { 42 | return getUAInfo().os.name 43 | } 44 | return undefined; 45 | } 46 | 47 | export async function getEnvironment(extensionId: string, extensionVersion:string): Promise { 48 | const browser = getUAInfo().browser; 49 | return { 50 | extension: { 51 | name:extensionId, 52 | version:extensionVersion, 53 | }, 54 | application: { 55 | name: vscodeEnv.appName, 56 | version: version, 57 | uiKind: UI_KIND, 58 | remote: vscodeEnv.remoteName !== undefined, 59 | appHost: vscodeEnv.appHost 60 | }, 61 | platform:{ 62 | name:PLATFORM, 63 | version:PLATFORM_VERSION, 64 | distribution: DISTRO 65 | }, 66 | browser: { 67 | name: browser.name, 68 | version: browser.version 69 | }, 70 | timezone:TIMEZONE, 71 | locale:LOCALE, 72 | country: COUNTRY, 73 | username: USERNAME 74 | }; 75 | } 76 | function getUIKind():string { 77 | switch (vscodeEnv.uiKind) { 78 | case UIKind.Desktop: 79 | return 'Desktop'; 80 | case UIKind.Web: 81 | return 'Web'; 82 | default: 83 | return 'Unknown'; 84 | } 85 | } 86 | 87 | function getUsername(): string | undefined { 88 | let username = ( 89 | env.SUDO_USER || 90 | env.C9_USER /* Cloud9 */ || 91 | env.LOGNAME || 92 | env.USER || 93 | env.LNAME || 94 | env.USERNAME 95 | ); 96 | return username; 97 | } 98 | 99 | export const PLATFORM = getPlatform(); 100 | export const DISTRO = getDistribution(); 101 | export const PLATFORM_VERSION = getUAInfo().os.version; 102 | export const TIMEZONE = Intl.DateTimeFormat().resolvedOptions().timeZone; 103 | export const LOCALE = navigator.language.replace('_', '-'); 104 | export const COUNTRY = getCountry(TIMEZONE); 105 | export const UI_KIND = getUIKind(); 106 | export const USERNAME = getUsername(); 107 | -------------------------------------------------------------------------------- /src/webworker/redHatServiceWebWorkerProvider.ts: -------------------------------------------------------------------------------- 1 | import { RedHatService } from '../common/api/redhatService'; 2 | import { ConfigurationManager } from '../common/impl/configurationManager'; 3 | import { TelemetryServiceBuilder } from '../common/telemetryServiceBuilder'; 4 | import { getExtension, getPackageJson } from '../common/utils/extensions'; 5 | import { FileSystemStorageService } from '../common/vscode/fileSystemStorageService'; 6 | import { AbstractRedHatServiceProvider} from '../common/vscode/redhatServiceInitializer'; 7 | import { getEnvironment } from './platform'; 8 | import { Reporter } from '../common/impl/reporter'; 9 | import { VFSSystemIdProvider } from './vfsIdManager'; 10 | import { EventCacheService } from '../common/impl/eventCacheService'; 11 | 12 | export class RedHatServiceWebWorkerProvider extends AbstractRedHatServiceProvider { 13 | 14 | public async buildRedHatService(): Promise { 15 | const extensionInfo = await getExtension(this.context); 16 | const extensionId = extensionInfo.id; 17 | const packageJson = getPackageJson(extensionInfo); 18 | const storageService = new FileSystemStorageService(this.getCachePath()); 19 | const reporter = new Reporter(this.getSegmentApi(packageJson), new EventCacheService(storageService)); 20 | const idManager = new VFSSystemIdProvider(storageService); 21 | const builder = new TelemetryServiceBuilder(packageJson) 22 | .setContext(this.context) 23 | .setSettings(this.settings) 24 | .setIdProvider(idManager) 25 | .setReporter(reporter) 26 | .setConfigurationManager(new ConfigurationManager(extensionId, storageService)) 27 | .setEnvironment(await getEnvironment(extensionId, packageJson.version)); 28 | 29 | const telemetryService = await builder.build(); 30 | return { 31 | getTelemetryService: () => Promise.resolve(telemetryService), 32 | getIdProvider: () => Promise.resolve(idManager) 33 | } 34 | } 35 | } 36 | 37 | -------------------------------------------------------------------------------- /src/webworker/vfsIdManager.ts: -------------------------------------------------------------------------------- 1 | import { IdProvider } from "../common/api/idProvider"; 2 | 3 | import * as path from 'path'; 4 | import { Logger } from '../common/utils/logger'; 5 | import { generateUUID } from "../common/utils/uuid"; 6 | import { FileSystemStorageService } from "../common/vscode/fileSystemStorageService"; 7 | 8 | let REDHAT_ANONYMOUS_UUID: string | undefined; 9 | 10 | /** 11 | * Service providing the Red Hat anonymous user id, read/stored from the `~/.redhat/anonymousId` file. 12 | */ 13 | export class VFSSystemIdProvider implements IdProvider { 14 | 15 | constructor(private storageService: FileSystemStorageService){} 16 | 17 | public async getRedHatUUID(): Promise { 18 | if (REDHAT_ANONYMOUS_UUID) { 19 | return REDHAT_ANONYMOUS_UUID; 20 | } 21 | const redhatUUIDFilePath = this.getAnonymousIdFile(); 22 | try { 23 | REDHAT_ANONYMOUS_UUID = await this.storageService.readFromFile(redhatUUIDFilePath); 24 | if (REDHAT_ANONYMOUS_UUID) { 25 | Logger.log(`loaded Red Hat UUID: ${REDHAT_ANONYMOUS_UUID}`); 26 | } else { 27 | Logger.log('No Red Hat UUID found'); 28 | REDHAT_ANONYMOUS_UUID = generateUUID(); 29 | await this.storageService.writeToFile(redhatUUIDFilePath, REDHAT_ANONYMOUS_UUID); 30 | Logger.log(`Written Red Hat UUID: ${REDHAT_ANONYMOUS_UUID} to ${redhatUUIDFilePath}`); 31 | } 32 | } catch (e: any) { 33 | Logger.log('VFSSystemIdProvider failed to access Red Hat UUID: ' + e?.message); 34 | } 35 | return REDHAT_ANONYMOUS_UUID!; 36 | } 37 | 38 | public getAnonymousIdFile(): string { 39 | return path.join('.redhat', 'anonymousId'); 40 | } 41 | 42 | } -------------------------------------------------------------------------------- /test-webpack/node/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRedHatService, 3 | TelemetryService, 4 | } from "../../lib/node"; 5 | 6 | console.log("Hello World"); 7 | -------------------------------------------------------------------------------- /test-webpack/webworker/index.ts: -------------------------------------------------------------------------------- 1 | import { 2 | getRedHatService, 3 | TelemetryService, 4 | } from "../../lib/webworker"; 5 | 6 | console.log("Hello World"); 7 | -------------------------------------------------------------------------------- /tsconfig.base.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "ES2020", 4 | "lib": [ 5 | "ES2020", 6 | ], 7 | "declaration": true, 8 | "declarationMap": true, 9 | "module": "commonjs", 10 | "moduleResolution": "node", 11 | "sourceMap": true, 12 | "strict": true, 13 | "noImplicitAny": true, 14 | "esModuleInterop": true, 15 | "resolveJsonModule": true, 16 | "skipLibCheck": true, 17 | "forceConsistentCasingInFileNames": true, 18 | "outDir": "./lib" 19 | }, 20 | } -------------------------------------------------------------------------------- /tsconfig.browser.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "compilerOptions": { 4 | "lib": [ 5 | "ES2020", 6 | "WebWorker" 7 | ] 8 | }, 9 | "include": [ 10 | "src/common/*", 11 | "src/webworker/*" 12 | ] 13 | } -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "files": [], 3 | "references": [ 4 | { 5 | "path": "./tsconfig.browser.json" 6 | }, 7 | { 8 | "path": "./tsconfig.node.json" 9 | } 10 | ] 11 | } -------------------------------------------------------------------------------- /tsconfig.node.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "./tsconfig.base.json", 3 | "include": [ 4 | "src/common/*", 5 | "src/node/*", 6 | "src/index.ts" 7 | ] 8 | } -------------------------------------------------------------------------------- /webpack.config.ts: -------------------------------------------------------------------------------- 1 | import * as path from 'path'; 2 | import * as webpack from 'webpack'; 3 | const WarningsToErrorsPlugin = require('warnings-to-errors-webpack-plugin'); 4 | 5 | // in case you run into any typescript error when configuring `devServer` 6 | // import 'webpack-dev-server'; 7 | const nodeConfig: webpack.Configuration = { 8 | mode: 'production', 9 | entry: './test-webpack/node/index.ts', 10 | externals: { 11 | "vscode": "commonjs vscode", 12 | }, 13 | target: "node", // vscode extensions run in a Node.js-context 14 | node: { 15 | __dirname: false, // leave the __dirname-behavior intact 16 | }, 17 | output: { 18 | path: path.resolve(__dirname, 'dist/node'), 19 | filename: 'foo.node.bundle.js', 20 | }, 21 | plugins: [ 22 | new WarningsToErrorsPlugin(), 23 | ], 24 | stats: { 25 | errorDetails: true, 26 | } 27 | }; 28 | 29 | /* 30 | const webworkerConfig: webpack.Configuration = { 31 | mode: 'production', // this leaves the source code as close as possible to the original (when packaging we set this to 'production') 32 | entry: './test-webpack/webworker/index.ts', 33 | target: 'webworker', // extensions run in a webworker context 34 | output: { 35 | path: path.resolve(__dirname, 'dist/webworker'), 36 | filename: 'foo.webworker.bundle.js', 37 | }, 38 | resolve: { 39 | //mainFields: ['module', 'main'], 40 | //extensions: ['.ts', '.js'], // support ts-files and js-files 41 | alias: { 42 | 'node-fetch': 'whatwg-fetch', 43 | 'object-hash': 'object-hash/dist/object_hash.js', 44 | }, 45 | fallback: { 46 | path: require.resolve('path-browserify'), 47 | 'node-fetch': require.resolve('whatwg-fetch'), 48 | util: require.resolve('util'), 49 | }, 50 | }, 51 | plugins: [ 52 | new WarningsToErrorsPlugin(), 53 | new webpack.ProvidePlugin({ 54 | process: path.resolve(path.join(__dirname, 'node_modules/process/browser.js')), // provide a shim for the global `process` variable 55 | }), 56 | ], 57 | externals: { 58 | vscode: 'commonjs vscode', // ignored because it doesn't exist 59 | }, 60 | }; 61 | */ 62 | 63 | module.exports = [nodeConfig]; 64 | --------------------------------------------------------------------------------