@saurabhreo/reo-census 1.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,244 @@
1
+ # Package Tracker
2
+
3
+ A lightweight npm package tracking utility similar to Reo. Tracks package installations and sends analytics data to your backend service.
4
+
5
+ ## Features
6
+
7
+ - 🚀 **Zero Runtime Overhead** - Only runs during installation, never at runtime
8
+ - 🔒 **Privacy-First** - Respects opt-out mechanisms and user preferences
9
+ - ⚡ **Non-Blocking** - Never interrupts package installation
10
+ - 🔄 **Retry Logic** - Automatic retries with exponential backoff
11
+ - 🎛️ **Configurable** - Environment variables and package.json settings
12
+
13
+ ## Installation
14
+
15
+ Add this package as a dependency to any npm package you want to track:
16
+
17
+ ```bash
18
+ npm install --save @saurabhreo/reo-census
19
+ ```
20
+
21
+ Once installed, the tracker will automatically run after `npm install` completes.
22
+
23
+ ## Configuration
24
+
25
+ ### Environment Variables
26
+
27
+ - `PACKAGE_TRACKER_ANALYTICS=false` - Disable tracking (opt-out)
28
+ - `PACKAGE_TRACKER_ANALYTICS=true` - Enable tracking (for opt-in mode)
29
+ - `PACKAGE_TRACKER_VERBOSE=true` - Enable verbose logging
30
+ - `PACKAGE_TRACKER_ENDPOINT=https://telemetry.reo.dev/data` - Set tracking endpoint URL
31
+ - `PACKAGE_TRACKER_DATA_MODE=partial` - Data collection mode (default: `partial`)
32
+ - `partial`: Sends basic metadata (email domain only, no full email, no dependencies)
33
+ - `full`: Includes full email and dependency information
34
+
35
+ ### Package.json Settings
36
+
37
+ Add configuration to your `package.json` under `reoSettings`:
38
+
39
+ ```json
40
+ {
41
+ "reoSettings": {
42
+ "defaultOptIn": false,
43
+ "endpoint": "https://telemetry.reo.dev/data",
44
+ "dataMode": "full"
45
+ }
46
+ }
47
+ ```
48
+
49
+ #### Settings
50
+
51
+ - `defaultOptIn` (boolean, default: `true`)
52
+ - `true`: Tracking enabled by default (opt-out mode)
53
+ - `false`: Tracking disabled by default (opt-in mode)
54
+
55
+ - `endpoint` (string, optional)
56
+ - Custom endpoint URL for tracking data
57
+ - Falls back to `PACKAGE_TRACKER_ENDPOINT` environment variable
58
+
59
+ - `dataMode` (string, default: `"partial"`)
60
+ - `"partial"`: Email domain only, no full email, no dependencies
61
+ - `"full"`: Full email and dependency list included
62
+ - **Note:** `PACKAGE_TRACKER_DATA_MODE` environment variable takes priority over this setting
63
+
64
+ ## Data Collection Modes
65
+
66
+ The tracker supports two data collection modes controlled by `PACKAGE_TRACKER_DATA_MODE`:
67
+
68
+ ### Partial Mode (Default)
69
+
70
+ **Default behavior** - Privacy-focused data collection:
71
+
72
+ ```bash
73
+ export PACKAGE_TRACKER_DATA_MODE=partial
74
+ # or omit the variable (partial is default)
75
+ ```
76
+
77
+ **What's collected:**
78
+ - ✅ Package name and version
79
+ - ✅ System information (platform, arch, Node version, OS)
80
+ - ✅ Installation context (timestamp, cwd, npm version)
81
+ - ✅ Git username (if available)
82
+ - ✅ Email domain only (e.g., `reo.dev`) - **not full email**
83
+ - ✅ CI/CD detection
84
+ - ❌ Full email address
85
+ - ❌ Dependency list (other packages used)
86
+
87
+ ### Full Mode
88
+
89
+ **Enhanced data collection** - Includes additional information:
90
+
91
+ ```bash
92
+ export PACKAGE_TRACKER_DATA_MODE=full
93
+ ```
94
+
95
+ **What's collected:**
96
+ - ✅ Everything from partial mode, plus:
97
+ - ✅ Full email address (from git config)
98
+ - ✅ Dependency list (dependencies, devDependencies, peerDependencies, optionalDependencies)
99
+
100
+ **Example payload in full mode:**
101
+
102
+ ```json
103
+ {
104
+ "package": {
105
+ "name": "my-package",
106
+ "version": "1.0.0",
107
+ "dependencies": {
108
+ "dependencies": {
109
+ "express": "^4.18.0",
110
+ "lodash": "^4.17.21"
111
+ },
112
+ "devDependencies": {
113
+ "jest": "^29.0.0"
114
+ }
115
+ }
116
+ },
117
+ "metadata": {
118
+ "gitUser": "John Doe",
119
+ "email": "john@example.com",
120
+ "emailDomain": "example.com"
121
+ }
122
+ }
123
+ ```
124
+
125
+ **Privacy Note:** Use `full` mode only when you need detailed analytics and have user consent. The default `partial` mode protects user privacy by not collecting sensitive information.
126
+
127
+ ## Privacy & Opt-Out
128
+
129
+ ### For Package Authors
130
+
131
+ To make tracking opt-in (disabled by default):
132
+
133
+ ```json
134
+ {
135
+ "reoSettings": {
136
+ "defaultOptIn": false
137
+ }
138
+ }
139
+ ```
140
+
141
+ ### For End Users
142
+
143
+ Users can opt-out by setting:
144
+
145
+ ```bash
146
+ export PACKAGE_TRACKER_ANALYTICS=false
147
+ npm install
148
+ ```
149
+
150
+ Or on Windows:
151
+
152
+ ```cmd
153
+ set PACKAGE_TRACKER_ANALYTICS=false
154
+ npm install
155
+ ```
156
+
157
+ ## Integration Example
158
+
159
+ ### Basic Integration
160
+
161
+ 1. Add the tracker to your package:
162
+
163
+ ```bash
164
+ npm install --save @saurabhreo/reo-census
165
+ ```
166
+
167
+ 2. Configure your endpoint in `package.json`:
168
+
169
+ ```json
170
+ {
171
+ "reoSettings": {
172
+ "endpoint": "https://telemetry.reo.dev/data"
173
+ }
174
+ }
175
+ ```
176
+
177
+ 3. Publish your package - tracking will work automatically!
178
+
179
+ ### Advanced Configuration
180
+
181
+ ```json
182
+ {
183
+ "name": "my-package",
184
+ "version": "1.0.0",
185
+ "reoSettings": {
186
+ "defaultOptIn": false,
187
+ "endpoint": "https://telemetry.reo.dev/data",
188
+ "dataMode": "full"
189
+ },
190
+ "dependencies": {
191
+ "@saurabhreo/reo-census": "^1.1.0"
192
+ }
193
+ }
194
+ ```
195
+
196
+ ## Development
197
+
198
+ ### Testing
199
+
200
+ See [TESTING.md](./TESTING.md) for comprehensive testing guide.
201
+
202
+ **Quick Test:**
203
+
204
+ ```bash
205
+ # Run test suite
206
+ npm test
207
+
208
+ # Start mock server for testing
209
+ npm run test:server
210
+
211
+ # Test directly with verbose output
212
+ PACKAGE_TRACKER_VERBOSE=true \
213
+ PACKAGE_TRACKER_ENDPOINT=https://httpbin.org/post \
214
+ node index.js
215
+ ```
216
+
217
+ ### Testing Locally
218
+
219
+ 1. Set verbose mode:
220
+ ```bash
221
+ export PACKAGE_TRACKER_VERBOSE=true
222
+ ```
223
+
224
+ 2. Test with a mock endpoint or your backend:
225
+ ```bash
226
+ export PACKAGE_TRACKER_ENDPOINT=https://telemetry.reo.dev/data
227
+ npm install
228
+ ```
229
+
230
+ ### Debugging
231
+
232
+ Enable verbose logging to see what data is being collected and sent:
233
+
234
+ ```bash
235
+ PACKAGE_TRACKER_VERBOSE=true npm install
236
+ ```
237
+
238
+ ## License
239
+
240
+ MIT
241
+
242
+ ## Similar Projects
243
+
244
+ This package is inspired by [@reo/reo](https://www.npmjs.com/package/@reo/reo) and provides similar functionality for custom backend services.
package/index.js ADDED
@@ -0,0 +1,29 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Entry point for package tracker
5
+ * This file is executed when the postinstall script runs
6
+ */
7
+
8
+ const Tracker = require('./src/tracker');
9
+
10
+ // Run tracker asynchronously with a maximum timeout
11
+ // This ensures it never blocks the installation process
12
+ const tracker = new Tracker();
13
+
14
+ // Maximum time to wait before exiting (10 seconds)
15
+ const MAX_WAIT_TIME = 10000;
16
+
17
+ // Execute tracking with timeout protection
18
+ const trackingPromise = tracker.track().catch(() => {
19
+ // Silently handle any errors - never break installations
20
+ });
21
+
22
+ // Race between tracking completion and maximum wait time
23
+ Promise.race([
24
+ trackingPromise,
25
+ new Promise((resolve) => setTimeout(resolve, MAX_WAIT_TIME)),
26
+ ]).finally(() => {
27
+ // Exit after tracking completes or timeout, ensuring we don't hang
28
+ process.exit(0);
29
+ });
package/package.json ADDED
@@ -0,0 +1,39 @@
1
+ {
2
+ "name": "@saurabhreo/reo-census",
3
+ "version": "1.2.0",
4
+ "description": "Census - tracks npm package installations for Reo analytics",
5
+ "main": "index.js",
6
+ "scripts": {
7
+ "postinstall": "node index.js",
8
+ "test": "node test.js",
9
+ "test:server": "node test-mock-server.js",
10
+ "prepublishOnly": "npm test",
11
+ "publish:patch": "npm version patch && npm publish --access public",
12
+ "publish:minor": "npm version minor && npm publish --access public",
13
+ "publish:major": "npm version major && npm publish --access public"
14
+ },
15
+ "keywords": [
16
+ "analytics",
17
+ "tracking",
18
+ "npm",
19
+ "package-analytics",
20
+ "telemetry"
21
+ ],
22
+ "author": "Saurabh <saurabh@reo.dev>",
23
+ "license": "MIT",
24
+ "engines": {
25
+ "node": ">=12.0.0"
26
+ },
27
+ "repository": {
28
+ "type": "git",
29
+ "url": "git+https://github.com/reodotdev/package_tracker.git"
30
+ },
31
+ "files": [
32
+ "index.js",
33
+ "src/**/*",
34
+ "README.md"
35
+ ],
36
+ "publishConfig": {
37
+ "access": "public"
38
+ }
39
+ }
@@ -0,0 +1,299 @@
1
+ const os = require('os');
2
+ const path = require('path');
3
+ const fs = require('fs');
4
+
5
+ /**
6
+ * Data collector for package tracking
7
+ * Gathers system information, package metadata, and installation context
8
+ */
9
+ class Collector {
10
+ constructor(config) {
11
+ this.config = config;
12
+ }
13
+
14
+ /**
15
+ * Collect all tracking data
16
+ */
17
+ collect() {
18
+ try {
19
+ const data = {
20
+ // Package information
21
+ package: {
22
+ name: this.config.getPackageName(),
23
+ version: this.config.getPackageVersion(),
24
+ },
25
+
26
+ // System information
27
+ system: {
28
+ platform: process.platform,
29
+ arch: process.arch,
30
+ nodeVersion: process.version,
31
+ osType: os.type(),
32
+ osRelease: os.release(),
33
+ osPlatform: os.platform(),
34
+ cpuCount: os.cpus().length,
35
+ },
36
+
37
+ // Installation context
38
+ installation: {
39
+ timestamp: new Date().toISOString(),
40
+ cwd: process.cwd(),
41
+ isTopLevel: this.config.isTopLevelInstall(),
42
+ npmVersion: process.env.npm_version || 'unknown',
43
+ nodeEnv: process.env.NODE_ENV || 'unknown',
44
+ },
45
+
46
+ // Optional: Company/user information
47
+ metadata: this.collectMetadata(),
48
+ };
49
+
50
+ // Add dependencies if full data mode is enabled
51
+ if (this.config.isFullDataMode()) {
52
+ const dependencies = this.collectDependencies();
53
+ if (dependencies && Object.keys(dependencies).length > 0) {
54
+ data.package.dependencies = dependencies;
55
+ }
56
+ }
57
+
58
+ return data;
59
+ } catch (error) {
60
+ // Return minimal data if collection fails
61
+ return {
62
+ package: {
63
+ name: this.config.getPackageName(),
64
+ version: this.config.getPackageVersion(),
65
+ },
66
+ error: 'Collection failed',
67
+ timestamp: new Date().toISOString(),
68
+ };
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Collect optional metadata (company info, git info, etc.)
74
+ */
75
+ collectMetadata() {
76
+ const metadata = {};
77
+
78
+ try {
79
+ // Try to get git user info
80
+ const gitConfig = this.getGitConfig();
81
+ if (gitConfig.user) {
82
+ metadata.gitUser = gitConfig.user;
83
+ }
84
+
85
+ // Email handling based on data mode
86
+ if (gitConfig.email) {
87
+ if (this.config.isFullDataMode()) {
88
+ // Full mode: send full email
89
+ metadata.email = gitConfig.email;
90
+ } else {
91
+ // Partial mode: only send domain
92
+ const emailDomain = gitConfig.email.split('@')[1];
93
+ if (emailDomain) {
94
+ metadata.emailDomain = emailDomain;
95
+ }
96
+ }
97
+ }
98
+
99
+ // Try to detect CI/CD environment
100
+ const ciInfo = this.detectCI();
101
+ if (ciInfo) {
102
+ metadata.ci = ciInfo;
103
+ }
104
+
105
+ // Try to get npm user info (if available)
106
+ const npmUser = process.env.npm_config_user;
107
+ if (npmUser) {
108
+ metadata.npmUser = npmUser;
109
+ }
110
+
111
+ // Debug logging if verbose mode
112
+ if (this.config && this.config.isVerbose()) {
113
+ console.log('[@saurabhreo/reo-census] Git config found:', {
114
+ user: gitConfig.user || 'not found',
115
+ email: gitConfig.email ? 'found (domain extracted)' : 'not found',
116
+ emailDomain: gitConfig.email ? gitConfig.email.split('@')[1] : 'none',
117
+ gitConfigPath: path.join(require('os').homedir(), '.gitconfig')
118
+ });
119
+ }
120
+ } catch (error) {
121
+ // Silently fail - metadata is optional
122
+ if (this.config && this.config.isVerbose()) {
123
+ console.log('[@saurabhreo/reo-census] Metadata collection error:', error.message);
124
+ }
125
+ }
126
+
127
+ return metadata;
128
+ }
129
+
130
+ /**
131
+ * Read git config to get user information
132
+ */
133
+ getGitConfig() {
134
+ const gitConfig = {};
135
+ const gitConfigPath = path.join(os.homedir(), '.gitconfig');
136
+
137
+ try {
138
+ if (fs.existsSync(gitConfigPath)) {
139
+ const content = fs.readFileSync(gitConfigPath, 'utf8');
140
+
141
+ // Parse git config by lines - more robust than regex
142
+ const lines = content.split('\n');
143
+ let inUserSection = false;
144
+
145
+ for (let i = 0; i < lines.length; i++) {
146
+ const line = lines[i];
147
+ const trimmedLine = line.trim();
148
+
149
+ // Check if we're entering [user] section
150
+ if (trimmedLine.match(/^\[user\]$/i)) {
151
+ inUserSection = true;
152
+ continue;
153
+ }
154
+
155
+ // Check if we're leaving the section (new section starts)
156
+ if (trimmedLine.match(/^\[/)) {
157
+ inUserSection = false;
158
+ continue;
159
+ }
160
+
161
+ // If we're in [user] section, parse name and email
162
+ if (inUserSection) {
163
+ // Parse name = value (handle leading whitespace, quotes, spaces, comments)
164
+ // Matches: " name = value", "\tname=value", "name = value # comment"
165
+ const nameMatch = line.match(/^\s*name\s*=\s*(.+?)(?:\s*[#;]|$)/i);
166
+ if (nameMatch && !gitConfig.user) {
167
+ let name = nameMatch[1].trim();
168
+ name = name.replace(/^["']|["']$/g, ''); // Remove quotes
169
+ gitConfig.user = name;
170
+ }
171
+
172
+ // Parse email = value (handle leading whitespace, quotes, spaces, comments)
173
+ const emailMatch = line.match(/^\s*email\s*=\s*(.+?)(?:\s*[#;]|$)/i);
174
+ if (emailMatch && !gitConfig.email) {
175
+ let email = emailMatch[1].trim();
176
+ email = email.replace(/^["']|["']$/g, ''); // Remove quotes
177
+ gitConfig.email = email;
178
+ }
179
+ }
180
+ }
181
+
182
+ // Fallback: try regex if line-by-line parsing didn't work
183
+ if (!gitConfig.user || !gitConfig.email) {
184
+ const userSectionMatch = content.match(/\[user\]([\s\S]*?)(?=\[|$)/i);
185
+ if (userSectionMatch) {
186
+ const userSection = userSectionMatch[1];
187
+
188
+ if (!gitConfig.user) {
189
+ const nameMatch = userSection.match(/name\s*=\s*["']?([^"'\n]+)["']?/i);
190
+ if (nameMatch) {
191
+ gitConfig.user = nameMatch[1].trim();
192
+ }
193
+ }
194
+
195
+ if (!gitConfig.email) {
196
+ const emailMatch = userSection.match(/email\s*=\s*["']?([^"'\n]+)["']?/i);
197
+ if (emailMatch) {
198
+ gitConfig.email = emailMatch[1].trim();
199
+ }
200
+ }
201
+ }
202
+ }
203
+ }
204
+ } catch (error) {
205
+ // Silently fail - git config might not exist or be unreadable
206
+ if (this.config && this.config.isVerbose()) {
207
+ console.log('[@saurabhreo/reo-census] Git config read error:', error.message);
208
+ console.log('[@saurabhreo/reo-census] Git config path:', gitConfigPath);
209
+ console.log('[@saurabhreo/reo-census] Git config exists:', fs.existsSync(gitConfigPath));
210
+ }
211
+ }
212
+
213
+ // Always log in verbose mode for debugging
214
+ if (this.config && this.config.isVerbose()) {
215
+ console.log('[@saurabhreo/reo-census] Git config result:', gitConfig);
216
+ }
217
+
218
+ return gitConfig;
219
+ }
220
+
221
+ /**
222
+ * Detect CI/CD environment
223
+ */
224
+ detectCI() {
225
+ const ciEnvs = {
226
+ CI: process.env.CI,
227
+ GITHUB_ACTIONS: process.env.GITHUB_ACTIONS ? 'GitHub Actions' : null,
228
+ GITLAB_CI: process.env.GITLAB_CI ? 'GitLab CI' : null,
229
+ JENKINS: process.env.JENKINS_URL ? 'Jenkins' : null,
230
+ TRAVIS: process.env.TRAVIS ? 'Travis CI' : null,
231
+ CIRCLE_CI: process.env.CIRCLE_CI ? 'CircleCI' : null,
232
+ BITBUCKET_BUILD_NUMBER: process.env.BITBUCKET_BUILD_NUMBER ? 'Bitbucket Pipelines' : null,
233
+ };
234
+
235
+ const detected = Object.entries(ciEnvs)
236
+ .filter(([_, value]) => value !== null && value !== undefined)
237
+ .map(([key, value]) => ({ [key]: value }));
238
+
239
+ return detected.length > 0 ? detected : null;
240
+ }
241
+
242
+ /**
243
+ * Collect dependencies from package.json
244
+ * Only called in full data mode
245
+ */
246
+ collectDependencies() {
247
+ try {
248
+ const dependencies = {};
249
+ // Get package.json from config
250
+ const packageJson = this.config.packageJson;
251
+
252
+ if (!packageJson) {
253
+ return null;
254
+ }
255
+
256
+ // Collect dependencies
257
+ if (packageJson.dependencies && typeof packageJson.dependencies === 'object') {
258
+ dependencies.dependencies = packageJson.dependencies;
259
+ }
260
+
261
+ // Collect devDependencies
262
+ if (packageJson.devDependencies && typeof packageJson.devDependencies === 'object') {
263
+ dependencies.devDependencies = packageJson.devDependencies;
264
+ }
265
+
266
+ // Collect peerDependencies
267
+ if (packageJson.peerDependencies && typeof packageJson.peerDependencies === 'object') {
268
+ dependencies.peerDependencies = packageJson.peerDependencies;
269
+ }
270
+
271
+ // Collect optionalDependencies
272
+ if (packageJson.optionalDependencies && typeof packageJson.optionalDependencies === 'object') {
273
+ dependencies.optionalDependencies = packageJson.optionalDependencies;
274
+ }
275
+
276
+ // Return null if no dependencies found
277
+ if (Object.keys(dependencies).length === 0) {
278
+ return null;
279
+ }
280
+
281
+ return dependencies;
282
+ } catch (error) {
283
+ // Silently fail - dependencies are optional
284
+ if (this.config && this.config.isVerbose()) {
285
+ console.log('[@saurabhreo/reo-census] Dependency collection error:', error.message);
286
+ }
287
+ return null;
288
+ }
289
+ }
290
+
291
+ /**
292
+ * Format data for sending (can be overridden for custom formatting)
293
+ */
294
+ format(data) {
295
+ return JSON.stringify(data);
296
+ }
297
+ }
298
+
299
+ module.exports = Collector;
package/src/config.js ADDED
@@ -0,0 +1,194 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ /**
5
+ * Configuration handler for package tracker
6
+ * Reads environment variables and package.json settings
7
+ */
8
+ class Config {
9
+ constructor() {
10
+ this.packageJsonPath = this.findPackageJson();
11
+ this.packageJson = this.loadPackageJson();
12
+ this.reoSettings = this.packageJson?.reoSettings || {};
13
+ }
14
+
15
+ /**
16
+ * Find package.json by walking up the directory tree
17
+ * Skips the tracker's own package.json to find the parent package
18
+ */
19
+ findPackageJson() {
20
+ let currentDir = process.cwd();
21
+ const root = path.parse(currentDir).root;
22
+ const trackerPackageName = '@saurabhreo/reo-census';
23
+
24
+ // If we're in node_modules, walk up until we exit node_modules
25
+ // to find the parent package that installed the tracker
26
+ if (currentDir.includes('node_modules')) {
27
+ // Walk up until we exit node_modules
28
+ // e.g., /project/node_modules/@saurabhreo/reo-census -> /project
29
+ while (currentDir !== root && currentDir.includes('node_modules')) {
30
+ currentDir = path.dirname(currentDir);
31
+ }
32
+
33
+ // Now look for package.json in the parent package directory (first one we find)
34
+ // This should be the package that installed the tracker
35
+ while (currentDir !== root) {
36
+ const packagePath = path.join(currentDir, 'package.json');
37
+ if (fs.existsSync(packagePath)) {
38
+ try {
39
+ const content = fs.readFileSync(packagePath, 'utf8');
40
+ const pkg = JSON.parse(content);
41
+ // Skip if this is the tracker's own package.json
42
+ if (pkg.name !== trackerPackageName) {
43
+ return packagePath;
44
+ }
45
+ } catch (error) {
46
+ // Continue searching if package.json is invalid
47
+ }
48
+ }
49
+ currentDir = path.dirname(currentDir);
50
+ }
51
+ } else {
52
+ // Not in node_modules, search normally but skip tracker's package.json
53
+ while (currentDir !== root) {
54
+ const packagePath = path.join(currentDir, 'package.json');
55
+ if (fs.existsSync(packagePath)) {
56
+ try {
57
+ const content = fs.readFileSync(packagePath, 'utf8');
58
+ const pkg = JSON.parse(content);
59
+ // Skip if this is the tracker's own package.json
60
+ if (pkg.name !== trackerPackageName) {
61
+ return packagePath;
62
+ }
63
+ } catch (error) {
64
+ // Continue searching if package.json is invalid
65
+ }
66
+ }
67
+ currentDir = path.dirname(currentDir);
68
+ }
69
+ }
70
+
71
+ // Fallback: return tracker's package.json if parent not found
72
+ return path.join(process.cwd(), 'package.json');
73
+ }
74
+
75
+ /**
76
+ * Load package.json content
77
+ */
78
+ loadPackageJson() {
79
+ try {
80
+ if (fs.existsSync(this.packageJsonPath)) {
81
+ const content = fs.readFileSync(this.packageJsonPath, 'utf8');
82
+ return JSON.parse(content);
83
+ }
84
+ } catch (error) {
85
+ // Silently fail - package.json might not exist or be invalid
86
+ }
87
+ return null;
88
+ }
89
+
90
+ /**
91
+ * Check if tracking is enabled
92
+ * Respects environment variable and package.json settings
93
+ */
94
+ isTrackingEnabled() {
95
+ // Check environment variable first (highest priority)
96
+ const envOptOut = process.env.PACKAGE_TRACKER_ANALYTICS === 'false';
97
+ if (envOptOut) {
98
+ return false;
99
+ }
100
+
101
+ // Check if defaultOptIn is set to false in package.json
102
+ if (this.reoSettings.defaultOptIn === false) {
103
+ // In opt-in mode, check if explicitly enabled
104
+ return process.env.PACKAGE_TRACKER_ANALYTICS === 'true';
105
+ }
106
+
107
+ // Default: opt-out mode (enabled by default)
108
+ return true;
109
+ }
110
+
111
+ /**
112
+ * Check if verbose logging is enabled
113
+ */
114
+ isVerbose() {
115
+ return process.env.PACKAGE_TRACKER_VERBOSE === 'true';
116
+ }
117
+
118
+ /**
119
+ * Get data collection mode
120
+ * Returns 'partial' (default) or 'full'
121
+ * Priority: env var > reoSettings.dataMode in package.json > default ('partial')
122
+ */
123
+ getDataMode() {
124
+ const mode =
125
+ process.env.PACKAGE_TRACKER_DATA_MODE ||
126
+ this.reoSettings.dataMode ||
127
+ 'partial';
128
+ return mode === 'full' ? 'full' : 'partial';
129
+ }
130
+
131
+ /**
132
+ * Check if full data mode is enabled
133
+ */
134
+ isFullDataMode() {
135
+ return this.getDataMode() === 'full';
136
+ }
137
+
138
+ /**
139
+ * Get tracking endpoint URL
140
+ * Can be configured via environment variable or package.json
141
+ */
142
+ getEndpointUrl() {
143
+ return (
144
+ process.env.PACKAGE_TRACKER_ENDPOINT ||
145
+ this.reoSettings.endpoint ||
146
+ 'https://telemetry.reo.dev/data' // Default endpoint
147
+ );
148
+ }
149
+
150
+ /**
151
+ * Get package name from package.json
152
+ */
153
+ getPackageName() {
154
+ return this.packageJson?.name || 'unknown';
155
+ }
156
+
157
+ /**
158
+ * Get package version from package.json
159
+ */
160
+ getPackageVersion() {
161
+ return this.packageJson?.version || 'unknown';
162
+ }
163
+
164
+ /**
165
+ * Check if this is a top-level installation
166
+ * Reo only tracks when installed as dependency or globally
167
+ */
168
+ isTopLevelInstall() {
169
+ // Check if we're in node_modules (dependency) or global install
170
+ const cwd = process.cwd();
171
+ const isInNodeModules = cwd.includes('node_modules');
172
+ const isGlobal = process.env.npm_config_global === 'true';
173
+
174
+ // If not in node_modules and not global, it's likely top-level
175
+ return !isInNodeModules && !isGlobal;
176
+ }
177
+
178
+ /**
179
+ * Get all configuration for logging
180
+ */
181
+ getConfig() {
182
+ return {
183
+ enabled: this.isTrackingEnabled(),
184
+ verbose: this.isVerbose(),
185
+ endpoint: this.getEndpointUrl(),
186
+ packageName: this.getPackageName(),
187
+ packageVersion: this.getPackageVersion(),
188
+ isTopLevel: this.isTopLevelInstall(),
189
+ dataMode: this.getDataMode(),
190
+ };
191
+ }
192
+ }
193
+
194
+ module.exports = Config;
package/src/sender.js ADDED
@@ -0,0 +1,143 @@
1
+ const https = require('https');
2
+ const http = require('http');
3
+
4
+ /**
5
+ * HTTP client for sending tracking data
6
+ * Implements retry logic, timeouts, and error handling
7
+ */
8
+ class Sender {
9
+ constructor(config) {
10
+ this.config = config;
11
+ this.endpoint = config.getEndpointUrl();
12
+ this.maxRetries = 3;
13
+ this.timeout = 5000; // 5 seconds per request
14
+ this.maxTotalTimeout = 10000; // 10 seconds total maximum (including retries)
15
+ }
16
+
17
+ /**
18
+ * Send tracking data to the endpoint
19
+ * Non-blocking and best-effort (never throws)
20
+ */
21
+ async send(data) {
22
+ if (!this.config.isTrackingEnabled()) {
23
+ if (this.config.isVerbose()) {
24
+ console.log('[@saurabhreo/reo-census] Tracking disabled');
25
+ }
26
+ return { success: false, reason: 'disabled' };
27
+ }
28
+
29
+ // Don't track top-level installations by default (like Reo)
30
+ if (this.config.isTopLevelInstall()) {
31
+ if (this.config.isVerbose()) {
32
+ console.log('[@saurabhreo/reo-census] Skipping top-level installation');
33
+ }
34
+ return { success: false, reason: 'top-level-install' };
35
+ }
36
+
37
+ const payload = typeof data === 'string' ? data : JSON.stringify(data);
38
+
39
+ if (this.config.isVerbose()) {
40
+ console.log('[@saurabhreo/reo-census] Sending data:', payload);
41
+ }
42
+
43
+ try {
44
+ // Enforce maximum total timeout to prevent hanging
45
+ const timeoutPromise = this.sleep(this.maxTotalTimeout).then(() => {
46
+ throw new Error('Maximum timeout exceeded');
47
+ });
48
+
49
+ const result = await Promise.race([
50
+ this.sendWithRetry(payload),
51
+ timeoutPromise,
52
+ ]);
53
+ return result;
54
+ } catch (error) {
55
+ // Never throw - this is best-effort
56
+ if (this.config.isVerbose()) {
57
+ console.error('[@saurabhreo/reo-census] Failed to send:', error.message);
58
+ }
59
+ return { success: false, error: error.message };
60
+ }
61
+ }
62
+
63
+ /**
64
+ * Send with retry logic
65
+ */
66
+ async sendWithRetry(payload, retryCount = 0) {
67
+ try {
68
+ const result = await this.makeRequest(payload);
69
+ return { success: true, statusCode: result.statusCode };
70
+ } catch (error) {
71
+ if (retryCount < this.maxRetries) {
72
+ // Exponential backoff: 1s, 2s, 4s
73
+ const delay = Math.pow(2, retryCount) * 1000;
74
+ await this.sleep(delay);
75
+ return this.sendWithRetry(payload, retryCount + 1);
76
+ }
77
+ throw error;
78
+ }
79
+ }
80
+
81
+ /**
82
+ * Make HTTP/HTTPS request
83
+ */
84
+ makeRequest(payload) {
85
+ return new Promise((resolve, reject) => {
86
+ const url = new URL(this.endpoint);
87
+ const isHttps = url.protocol === 'https:';
88
+ const client = isHttps ? https : http;
89
+
90
+ const options = {
91
+ hostname: url.hostname,
92
+ port: url.port || (isHttps ? 443 : 80),
93
+ path: url.pathname + url.search,
94
+ method: 'POST',
95
+ headers: {
96
+ 'Content-Type': 'application/json',
97
+ 'Content-Length': Buffer.byteLength(payload),
98
+ 'User-Agent': `@saurabhreo/reo-census/${this.config.getPackageVersion()}`,
99
+ },
100
+ timeout: this.timeout,
101
+ };
102
+
103
+ const req = client.request(options, (res) => {
104
+ let data = '';
105
+
106
+ res.on('data', (chunk) => {
107
+ data += chunk;
108
+ });
109
+
110
+ res.on('end', () => {
111
+ if (res.statusCode >= 200 && res.statusCode < 300) {
112
+ resolve({ statusCode: res.statusCode, data });
113
+ } else {
114
+ reject(
115
+ new Error(`Request failed with status ${res.statusCode}`)
116
+ );
117
+ }
118
+ });
119
+ });
120
+
121
+ req.on('error', (error) => {
122
+ reject(error);
123
+ });
124
+
125
+ req.on('timeout', () => {
126
+ req.destroy();
127
+ reject(new Error('Request timeout'));
128
+ });
129
+
130
+ req.write(payload);
131
+ req.end();
132
+ });
133
+ }
134
+
135
+ /**
136
+ * Sleep utility for retry delays
137
+ */
138
+ sleep(ms) {
139
+ return new Promise((resolve) => setTimeout(resolve, ms));
140
+ }
141
+ }
142
+
143
+ module.exports = Sender;
package/src/tracker.js ADDED
@@ -0,0 +1,69 @@
1
+ const Config = require('./config');
2
+ const Collector = require('./collector');
3
+ const Sender = require('./sender');
4
+
5
+ /**
6
+ * Main tracker orchestrator
7
+ * Coordinates configuration, data collection, and sending
8
+ */
9
+ class Tracker {
10
+ constructor() {
11
+ this.config = new Config();
12
+ this.collector = new Collector(this.config);
13
+ this.sender = new Sender(this.config);
14
+ }
15
+
16
+ /**
17
+ * Run the tracking process
18
+ * This is the main entry point
19
+ */
20
+ async track() {
21
+ try {
22
+ // Log tracking status if verbose
23
+ if (this.config.isVerbose()) {
24
+ const config = this.config.getConfig();
25
+ console.log('[@saurabhreo/reo-census] Configuration:', JSON.stringify(config, null, 2));
26
+ }
27
+
28
+ // Check if tracking is enabled
29
+ if (!this.config.isTrackingEnabled()) {
30
+ if (this.config.isVerbose()) {
31
+ console.log('[@saurabhreo/reo-census] Tracking is disabled');
32
+ }
33
+ return { success: false, reason: 'disabled' };
34
+ }
35
+
36
+ // Collect data
37
+ const data = this.collector.collect();
38
+
39
+ if (this.config.isVerbose()) {
40
+ console.log('[@saurabhreo/reo-census] Collected data:', JSON.stringify(data, null, 2));
41
+ }
42
+
43
+ // Send data (non-blocking, best-effort)
44
+ const result = await this.sender.send(data);
45
+
46
+ // Log result if verbose
47
+ if (this.config.isVerbose()) {
48
+ console.log('[@saurabhreo/reo-census] Send result:', result);
49
+ }
50
+
51
+ return result;
52
+ } catch (error) {
53
+ // Never throw - this should never break installations
54
+ if (this.config.isVerbose()) {
55
+ console.error('[@saurabhreo/reo-census] Error:', error.message);
56
+ }
57
+ return { success: false, error: error.message };
58
+ }
59
+ }
60
+
61
+ /**
62
+ * Get current configuration (for debugging)
63
+ */
64
+ getConfig() {
65
+ return this.config.getConfig();
66
+ }
67
+ }
68
+
69
+ module.exports = Tracker;