Просмотр исходного кода

feat(core): Add anonymous telemetry collection module (#4192)

David Höck 16 часов назад
Родитель
Сommit
4d092d1b83
32 измененных файлов с 3336 добавлено и 0 удалено
  1. 3 0
      .gitignore
  2. 123 0
      docs/docs/guides/developer-guide/telemetry/index.mdx
  3. 23 0
      docs/docs/reference/typescript-api/telemetry/telemetry-module.mdx
  4. 48 0
      docs/docs/reference/typescript-api/telemetry/telemetry-service.mdx
  5. 5 0
      docs/src/manifest.ts
  6. 2 0
      packages/core/src/app.module.ts
  7. 7 0
      packages/core/src/bootstrap.ts
  8. 8 0
      packages/core/src/job-queue/job-queue.service.ts
  9. 248 0
      packages/core/src/telemetry/collectors/config.collector.spec.ts
  10. 107 0
      packages/core/src/telemetry/collectors/config.collector.ts
  11. 210 0
      packages/core/src/telemetry/collectors/database.collector.spec.ts
  12. 120 0
      packages/core/src/telemetry/collectors/database.collector.ts
  13. 312 0
      packages/core/src/telemetry/collectors/deployment.collector.spec.ts
  14. 128 0
      packages/core/src/telemetry/collectors/deployment.collector.ts
  15. 6 0
      packages/core/src/telemetry/collectors/index.ts
  16. 514 0
      packages/core/src/telemetry/collectors/installation-id.collector.spec.ts
  17. 142 0
      packages/core/src/telemetry/collectors/installation-id.collector.ts
  18. 254 0
      packages/core/src/telemetry/collectors/plugin.collector.spec.ts
  19. 161 0
      packages/core/src/telemetry/collectors/plugin.collector.ts
  20. 50 0
      packages/core/src/telemetry/collectors/system-info.collector.spec.ts
  21. 20 0
      packages/core/src/telemetry/collectors/system-info.collector.ts
  22. 81 0
      packages/core/src/telemetry/helpers/ci-detector.helper.spec.ts
  23. 35 0
      packages/core/src/telemetry/helpers/ci-detector.helper.ts
  24. 2 0
      packages/core/src/telemetry/helpers/index.ts
  25. 10 0
      packages/core/src/telemetry/helpers/is-telemetry-disabled.helper.ts
  26. 80 0
      packages/core/src/telemetry/helpers/range-bucket.helper.spec.ts
  27. 15 0
      packages/core/src/telemetry/helpers/range-bucket.helper.ts
  28. 48 0
      packages/core/src/telemetry/telemetry.module.ts
  29. 352 0
      packages/core/src/telemetry/telemetry.service.spec.ts
  30. 143 0
      packages/core/src/telemetry/telemetry.service.ts
  31. 78 0
      packages/core/src/telemetry/telemetry.types.ts
  32. 1 0
      packages/create/templates/gitignore.template

+ 3 - 0
.gitignore

@@ -390,3 +390,6 @@ Network Trash Folder
 Temporary Items
 .apdisk
 __admin-ui/
+
+# Vendure telemetry installation ID
+.vendure/

+ 123 - 0
docs/docs/guides/developer-guide/telemetry/index.mdx

@@ -0,0 +1,123 @@
+---
+title: "Telemetry"
+sidebar_position: 38
+---
+
+Starting with Vendure v3.6.0, the framework collects anonymous usage telemetry to help the core team understand how Vendure is being used. This data helps prioritize development efforts and identify common deployment patterns.
+
+Telemetry collection is designed with privacy as a core principle. No personally identifiable information (PII) is ever collected, and the data is anonymized before transmission.
+
+## What Data is Collected
+
+The following data is collected once per server startup:
+
+### Installation ID
+
+A randomly generated UUID that identifies your Vendure installation. This ID is:
+
+- Generated using `crypto.randomUUID()` (not derived from any system information)
+- Stored primarily in the database (via the internal Settings Store), with a filesystem fallback at `.vendure/.installation-id` in your project root
+- Used only to deduplicate telemetry events
+
+### Version Information
+
+- Vendure version (e.g., `3.6.0`)
+- Node.js version (e.g., `20.10.0`)
+- Operating system platform and architecture (e.g., `linux x64`, `darwin arm64`, `win32 x64`)
+- `NODE_ENV` environment variable value (e.g., `production`, `development`)
+
+### Database Type
+
+The type of database being used:
+
+- `postgres`
+- `mysql`
+- `mariadb`
+- `sqlite`
+
+### Plugins
+
+- **Official Vendure plugins**: Package names are collected (e.g., `@vendure/email-plugin`, `@vendure/elasticsearch-plugin`)
+- **Third-party npm plugins**: Package names are collected if resolvable from `node_modules`
+- **Custom plugins**: Only a count is collected, **names are not collected**
+
+### Entity Metrics
+
+Record counts for core Vendure entities, reported as **ranges** rather than exact numbers:
+
+| Range | Description |
+|-------|-------------|
+| `0` | No records |
+| `1-100` | 1 to 100 records |
+| `101-1k` | 101 to 1,000 records |
+| `1k-10k` | 1,001 to 10,000 records |
+| `10k-100k` | 10,001 to 100,000 records |
+| `100k+` | More than 100,000 records |
+
+For custom entities, only the count of custom entity types is collected (not their names), plus an aggregate record count range.
+
+### Deployment Information
+
+- Whether the server is running in a container (Docker, Kubernetes)
+- Cloud provider detection (AWS, GCP, Azure, Vercel, Railway, Render, Fly, Heroku, DigitalOcean, Northflank)
+- Whether running in a serverless environment
+- Worker mode (integrated or separate)
+
+### Configuration
+
+Only **class names** of configured strategies are collected, not their configuration values:
+
+- Asset storage strategy class name (e.g., `LocalAssetStorageStrategy`, `S3AssetStorageStrategy`)
+- Job queue strategy class name
+- Entity ID strategy class name
+- Authentication strategy class names
+- Default language code
+- Total count of custom fields
+
+## What is NOT Collected
+
+The following data is explicitly **not** collected:
+
+- Hostnames, IP addresses, or domain names
+- Customer data, order data, or any business data
+- Custom plugin names (only a count)
+- API keys, secrets, or credentials
+- File paths or source code
+- Configuration values (only strategy class names)
+- Any personally identifiable information (PII)
+
+## How We Use This Data
+
+Telemetry data helps the Vendure team:
+
+- **Understand adoption patterns**: Which plugins are most commonly used, what database types are preferred
+- **Prioritize development**: Focus efforts on the most-used features and platforms
+- **Identify deployment trends**: Understand common infrastructure patterns to optimize performance
+- **Detect version distribution**: Plan deprecation timelines and migration paths
+
+## Disabling Telemetry
+
+Telemetry can be disabled by setting an environment variable:
+
+```bash
+VENDURE_DISABLE_TELEMETRY=true
+```
+
+Or:
+
+```bash
+VENDURE_DISABLE_TELEMETRY=1
+```
+
+:::note
+Telemetry is **automatically disabled** in CI environments. The following CI systems are detected:
+Travis CI, CircleCI, GitHub Actions, GitLab CI, Jenkins, Bitbucket Pipelines, Azure Pipelines, AppVeyor, Drone, Buildkite, TeamCity, AWS CodeBuild, Heroku CI, Netlify, and Vercel. Any environment setting the generic `CI` environment variable is also detected.
+:::
+
+## Technical Details
+
+- **Endpoint**: `https://telemetry.vendure.io/api/v1/collect`
+- **Timing**: Once per server startup after a 5-second delay, non-blocking (fire-and-forget)
+- **Timeout**: 5 seconds (telemetry collection never delays server startup)
+- **Failure handling**: All errors are silently ignored
+- **Worker processes**: Telemetry is only sent from the main server process, not from workers

+ 23 - 0
docs/docs/reference/typescript-api/telemetry/telemetry-module.mdx

@@ -0,0 +1,23 @@
+---
+title: "TelemetryModule"
+generated: true
+---
+<GenerationInfo sourceFile="packages/core/src/telemetry/telemetry.module.ts" sourceLine="34" packageName="@vendure/core" since="3.6.0" />
+
+The TelemetryModule provides anonymous usage data collection for Vendure.
+It collects data on application startup and sends it to the Vendure telemetry endpoint.
+
+**Privacy guarantees:**
+- Installation ID is a random UUID
+- Custom plugin names are NOT collected
+- Entity counts use ranges, not exact numbers
+- No PII is collected
+
+**Opt-out:**
+Set `VENDURE_DISABLE_TELEMETRY=true` to disable.
+
+```ts title="Signature"
+class TelemetryModule {
+
+}
+```

+ 48 - 0
docs/docs/reference/typescript-api/telemetry/telemetry-service.mdx

@@ -0,0 +1,48 @@
+---
+title: "TelemetryService"
+generated: true
+---
+<GenerationInfo sourceFile="packages/core/src/telemetry/telemetry.service.ts" sourceLine="40" packageName="@vendure/core" since="3.6.0" />
+
+The TelemetryService collects anonymous usage data on Vendure application startup
+and sends it to the Vendure telemetry endpoint. This data helps the Vendure team
+understand how the framework is being used and prioritize development efforts.
+
+**Privacy guarantees:**
+- Installation ID is a random UUID, not derived from any system information
+- Custom plugin names are NOT collected (only count)
+- Entity counts use ranges, not exact numbers
+- No PII (no hostnames, IPs, user data) is collected
+- All failures are silently ignored
+
+**Opt-out:**
+Set the environment variable `VENDURE_DISABLE_TELEMETRY=true` to disable telemetry.
+
+**CI environments:**
+Telemetry is automatically disabled in CI environments.
+
+```ts title="Signature"
+class TelemetryService implements OnApplicationBootstrap {
+    constructor(processContext: ProcessContext, installationIdCollector: InstallationIdCollector, systemInfoCollector: SystemInfoCollector, databaseCollector: DatabaseCollector, pluginCollector: PluginCollector, configCollector: ConfigCollector, deploymentCollector: DeploymentCollector)
+    onApplicationBootstrap() => ;
+}
+```
+* Implements: OnApplicationBootstrap
+
+
+
+<div className="members-wrapper">
+
+### constructor
+
+<MemberInfo kind="method" type={`(processContext: <a href='/reference/typescript-api/common/process-context#processcontext'>ProcessContext</a>, installationIdCollector: InstallationIdCollector, systemInfoCollector: SystemInfoCollector, databaseCollector: DatabaseCollector, pluginCollector: PluginCollector, configCollector: ConfigCollector, deploymentCollector: DeploymentCollector) => TelemetryService`}   />
+
+
+### onApplicationBootstrap
+
+<MemberInfo kind="method" type={`() => `}   />
+
+
+
+
+</div>

+ 5 - 0
docs/src/manifest.ts

@@ -291,6 +291,11 @@ const manifestInput: DocsPackageManifestInput = {
                     slug: 'nest-devtools',
                     file: file('docs/guides/developer-guide/nest-devtools/index.mdx'),
                 },
+                {
+                    title: 'Telemetry',
+                    slug: 'telemetry',
+                    file: file('docs/guides/developer-guide/telemetry/index.mdx'),
+                },
             ],
         },
         {

+ 2 - 0
packages/core/src/app.module.ts

@@ -13,6 +13,7 @@ import { I18nService } from './i18n/i18n.service';
 import { PluginModule } from './plugin/plugin.module';
 import { ProcessContextModule } from './process-context/process-context.module';
 import { ServiceModule } from './service/service.module';
+import { TelemetryModule } from './telemetry/telemetry.module';
 
 @Module({
     imports: [
@@ -24,6 +25,7 @@ import { ServiceModule } from './service/service.module';
         HealthCheckModule,
         ServiceModule,
         ConnectionModule,
+        TelemetryModule,
     ],
 })
 export class AppModule implements NestModule, OnApplicationShutdown {

+ 7 - 0
packages/core/src/bootstrap.ts

@@ -25,6 +25,7 @@ import { validateCustomFieldsConfig } from './entity/validate-custom-fields-conf
 import { getCompatibility, getConfigurationFunction, getEntitiesFromPlugins } from './plugin/plugin-metadata';
 import { getPluginStartupMessages } from './plugin/plugin-utils';
 import { setProcessContext } from './process-context/process-context';
+import { isTelemetryDisabled } from './telemetry/helpers/is-telemetry-disabled.helper';
 import { VENDURE_VERSION } from './version';
 import { VendureWorker } from './worker/vendure-worker';
 
@@ -409,6 +410,12 @@ function logWelcomeMessage(config: RuntimeVendureConfig) {
     Logger.info('-'.repeat(maxLineLength).padStart(titlePadLength));
     columnarGreetings.forEach(line => Logger.info(line));
     Logger.info('='.repeat(maxLineLength));
+    if (isTelemetryDisabled()) {
+        Logger.info('Anonymous telemetry is disabled.');
+    } else {
+        Logger.info('Anonymous telemetry is enabled to help us improve Vendure.');
+        Logger.info('To disable, set VENDURE_DISABLE_TELEMETRY=true.');
+    }
 }
 
 function arrangeCliGreetingsInColumns(lines: Array<readonly [string, string]>): string[] {

+ 8 - 0
packages/core/src/job-queue/job-queue.service.ts

@@ -52,6 +52,14 @@ export class JobQueueService implements OnModuleDestroy {
     private queues: Array<JobQueue<any>> = [];
     private hasStarted = false;
 
+    /**
+     * @description
+     * Returns `true` if the job queues have been started.
+     */
+    get started(): boolean {
+        return this.hasStarted;
+    }
+
     private get jobQueueStrategy(): JobQueueStrategy {
         return this.configService.jobQueueOptions.jobQueueStrategy;
     }

+ 248 - 0
packages/core/src/telemetry/collectors/config.collector.spec.ts

@@ -0,0 +1,248 @@
+import { LanguageCode } from '@vendure/common/lib/generated-types';
+import { beforeEach, describe, expect, it } from 'vitest';
+
+import { ConfigService } from '../../config/config.service';
+
+import { ConfigCollector } from './config.collector';
+
+describe('ConfigCollector', () => {
+    let collector: ConfigCollector;
+    let mockConfigService: Record<string, any>;
+
+    beforeEach(() => {
+        mockConfigService = {
+            assetOptions: {
+                assetStorageStrategy: {
+                    constructor: { name: 'LocalAssetStorageStrategy' },
+                },
+            } as any,
+            jobQueueOptions: {
+                jobQueueStrategy: {
+                    constructor: { name: 'InMemoryJobQueueStrategy' },
+                },
+            } as any,
+            entityOptions: {
+                entityIdStrategy: {
+                    constructor: { name: 'AutoIncrementIdStrategy' },
+                },
+            } as any,
+            entityIdStrategy: {
+                constructor: { name: 'FallbackIdStrategy' },
+            } as any,
+            defaultLanguageCode: LanguageCode.en,
+            customFields: {
+                Product: [{ name: 'customField1' }, { name: 'customField2' }],
+                Customer: [{ name: 'customField3' }],
+            } as any,
+            authOptions: {
+                adminAuthenticationStrategy: [
+                    { name: 'native', constructor: { name: 'NativeAuthenticationStrategy' } },
+                ],
+                shopAuthenticationStrategy: [
+                    { name: 'native', constructor: { name: 'NativeAuthenticationStrategy' } },
+                    { name: 'google', constructor: { name: 'GoogleAuthenticationStrategy' } },
+                ],
+            } as any,
+        };
+        collector = new ConfigCollector(mockConfigService as ConfigService);
+    });
+
+    describe('collect()', () => {
+        // Test success paths first before error paths
+        describe('happy path', () => {
+            it('returns strategy constructor names', () => {
+                const result = collector.collect();
+
+                expect(result.assetStorageType).toBe('LocalAssetStorageStrategy');
+                expect(result.jobQueueType).toBe('InMemoryJobQueueStrategy');
+                expect(result.entityIdStrategy).toBe('AutoIncrementIdStrategy');
+            });
+
+            it('returns defaultLanguage from config', () => {
+                const result = collector.collect();
+
+                expect(result.defaultLanguage).toBe(LanguageCode.en);
+            });
+
+            it('counts custom fields across all entity types', () => {
+                const result = collector.collect();
+
+                expect(result.customFieldsCount).toBe(3); // 2 + 1
+            });
+
+            it('returns authentication method names sorted and deduplicated', () => {
+                const result = collector.collect();
+
+                expect(result.authenticationMethods).toEqual(['google', 'native']);
+            });
+        });
+
+        describe('entityIdStrategy fallback', () => {
+            it('falls back to entityIdStrategy when entityOptions.entityIdStrategy is undefined', () => {
+                mockConfigService.entityOptions = {} as any;
+
+                const result = collector.collect();
+
+                expect(result.entityIdStrategy).toBe('FallbackIdStrategy');
+            });
+
+            it('returns unknown when entityOptions is undefined (cannot access fallback)', () => {
+                // When entityOptions is undefined, accessing entityOptions.entityIdStrategy
+                // throws an error which is caught, returning 'unknown'.
+                // This is different from entityOptions being an empty object.
+                mockConfigService.entityOptions = undefined as any;
+
+                const result = collector.collect();
+
+                expect(result.entityIdStrategy).toBe('unknown');
+            });
+        });
+
+        describe('custom fields handling', () => {
+            it('returns 0 when no custom fields', () => {
+                mockConfigService.customFields = {} as any;
+
+                const result = collector.collect();
+
+                expect(result.customFieldsCount).toBe(0);
+            });
+
+            it('handles custom fields with non-array values', () => {
+                mockConfigService.customFields = {
+                    Product: [{ name: 'field1' }],
+                    Customer: undefined,
+                    Order: null,
+                } as any;
+
+                const result = collector.collect();
+
+                expect(result.customFieldsCount).toBe(1);
+            });
+
+            it('handles complex custom fields configuration', () => {
+                mockConfigService.customFields = {
+                    Product: [{ name: 'f1' }, { name: 'f2' }, { name: 'f3' }],
+                    ProductVariant: [{ name: 'f4' }],
+                    Customer: [{ name: 'f5' }, { name: 'f6' }],
+                    Order: [],
+                    OrderLine: [{ name: 'f7' }],
+                } as any;
+
+                const result = collector.collect();
+
+                expect(result.customFieldsCount).toBe(7);
+            });
+        });
+
+        describe('authentication methods handling', () => {
+            it('handles empty strategy arrays', () => {
+                mockConfigService.authOptions = {
+                    adminAuthenticationStrategy: [],
+                    shopAuthenticationStrategy: [],
+                } as any;
+
+                const result = collector.collect();
+
+                expect(result.authenticationMethods).toEqual([]);
+            });
+
+            it('handles null strategy array', () => {
+                mockConfigService.authOptions = {
+                    adminAuthenticationStrategy: null as any,
+                    shopAuthenticationStrategy: [{ name: 'some', constructor: { name: 'SomeStrategy' } }],
+                } as any;
+
+                const result = collector.collect();
+
+                // Should return empty array due to error handling
+                expect(result.authenticationMethods).toEqual([]);
+            });
+        });
+
+        describe('minification resilience', () => {
+            it('falls back to constructor.name when no .name property', () => {
+                mockConfigService.assetOptions = {
+                    assetStorageStrategy: {
+                        constructor: { name: 'LocalAssetStorageStrategy' },
+                    },
+                } as any;
+
+                const result = collector.collect();
+
+                expect(result.assetStorageType).toBe('LocalAssetStorageStrategy');
+            });
+
+            it('returns unknown when constructor.name is minified (single char)', () => {
+                mockConfigService.assetOptions = {
+                    assetStorageStrategy: {
+                        constructor: { name: 'a' },
+                    },
+                } as any;
+
+                const result = collector.collect();
+
+                expect(result.assetStorageType).toBe('unknown');
+            });
+
+            it('prefers .name property over constructor.name for auth strategies', () => {
+                mockConfigService.authOptions = {
+                    adminAuthenticationStrategy: [{ name: 'native', constructor: { name: 'a' } }],
+                    shopAuthenticationStrategy: [],
+                } as any;
+
+                const result = collector.collect();
+
+                expect(result.authenticationMethods).toEqual(['native']);
+            });
+        });
+
+        describe('error handling', () => {
+            // These tests verify graceful degradation when config is malformed
+
+            it('returns "unknown" when assetOptions is undefined', () => {
+                mockConfigService.assetOptions = undefined as any;
+
+                const result = collector.collect();
+
+                expect(result.assetStorageType).toBe('unknown');
+            });
+
+            it('returns "unknown" when jobQueueOptions is undefined', () => {
+                mockConfigService.jobQueueOptions = undefined as any;
+
+                const result = collector.collect();
+
+                expect(result.jobQueueType).toBe('unknown');
+            });
+
+            it('returns "unknown" when both entityOptions and entityIdStrategy are undefined', () => {
+                mockConfigService.entityOptions = undefined as any;
+                mockConfigService.entityIdStrategy = undefined;
+
+                const result = collector.collect();
+
+                expect(result.entityIdStrategy).toBe('unknown');
+            });
+
+            it('returns 0 when customFields throws', () => {
+                Object.defineProperty(mockConfigService, 'customFields', {
+                    get() {
+                        throw new Error('Config error');
+                    },
+                });
+
+                const result = collector.collect();
+
+                expect(result.customFieldsCount).toBe(0);
+            });
+
+            it('returns empty array when authOptions is undefined', () => {
+                mockConfigService.authOptions = undefined as any;
+
+                const result = collector.collect();
+
+                expect(result.authenticationMethods).toEqual([]);
+            });
+        });
+    });
+});

+ 107 - 0
packages/core/src/telemetry/collectors/config.collector.ts

@@ -0,0 +1,107 @@
+import { Injectable } from '@nestjs/common';
+
+import { ConfigService } from '../../config/config.service';
+import { TelemetryConfig } from '../telemetry.types';
+
+/**
+ * Collects configuration information for telemetry.
+ * Only collects strategy class names and non-sensitive configuration values.
+ */
+@Injectable()
+export class ConfigCollector {
+    constructor(private readonly configService: ConfigService) {}
+
+    collect(): TelemetryConfig {
+        return {
+            assetStorageType: this.getAssetStorageType(),
+            jobQueueType: this.getJobQueueType(),
+            entityIdStrategy: this.getEntityIdStrategy(),
+            defaultLanguage: this.configService.defaultLanguageCode,
+            customFieldsCount: this.countCustomFields(),
+            authenticationMethods: this.getAuthenticationMethods(),
+        };
+    }
+
+    private getAssetStorageType(): string {
+        try {
+            return getStrategyName(this.configService.assetOptions.assetStorageStrategy);
+        } catch {
+            return 'unknown';
+        }
+    }
+
+    private getJobQueueType(): string {
+        try {
+            return getStrategyName(this.configService.jobQueueOptions.jobQueueStrategy);
+        } catch {
+            return 'unknown';
+        }
+    }
+
+    private getEntityIdStrategy(): string {
+        try {
+            const strategy =
+                this.configService.entityOptions.entityIdStrategy ?? this.configService.entityIdStrategy;
+            return strategy ? getStrategyName(strategy) : 'unknown';
+        } catch {
+            return 'unknown';
+        }
+    }
+
+    private countCustomFields(): number {
+        try {
+            const customFields = this.configService.customFields;
+            let count = 0;
+
+            for (const entityName of Object.keys(customFields)) {
+                const fields = customFields[entityName as keyof typeof customFields];
+                if (Array.isArray(fields)) {
+                    count += fields.length;
+                }
+            }
+
+            return count;
+        } catch {
+            return 0;
+        }
+    }
+
+    private getAuthenticationMethods(): string[] {
+        try {
+            const methods = new Set<string>();
+
+            const adminStrategies = this.configService.authOptions.adminAuthenticationStrategy;
+            const shopStrategies = this.configService.authOptions.shopAuthenticationStrategy;
+
+            for (const strategy of adminStrategies) {
+                methods.add(getStrategyName(strategy));
+            }
+
+            for (const strategy of shopStrategies) {
+                methods.add(getStrategyName(strategy));
+            }
+
+            return Array.from(methods).sort((a, b) => a.localeCompare(b));
+        } catch {
+            return [];
+        }
+    }
+}
+
+/**
+ * Gets the name of a strategy, resilient to code minification.
+ * Prefers an explicit `name` property (e.g. AuthenticationStrategy.name),
+ * then falls back to `constructor.name`. Returns 'unknown' if the name
+ * appears to be minified (single char or empty).
+ */
+function getStrategyName(strategy: object): string {
+    const name = (strategy as any).name;
+    if (typeof name === 'string' && name.length > 1) {
+        return name;
+    }
+    const ctorName = strategy.constructor?.name;
+    if (ctorName && ctorName.length > 1) {
+        return ctorName;
+    }
+    return 'unknown';
+}

+ 210 - 0
packages/core/src/telemetry/collectors/database.collector.spec.ts

@@ -0,0 +1,210 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from '../../config/config.service';
+import { TransactionalConnection } from '../../connection/transactional-connection';
+import { coreEntitiesMap } from '../../entity/entities';
+
+import { DatabaseCollector } from './database.collector';
+
+describe('DatabaseCollector', () => {
+    let collector: DatabaseCollector;
+    let mockConfigService: Record<string, any>;
+    let mockConnection: Partial<TransactionalConnection>;
+    let mockRepository: { count: ReturnType<typeof vi.fn> };
+
+    beforeEach(() => {
+        mockRepository = { count: vi.fn().mockResolvedValue(50) };
+        mockConnection = {
+            rawConnection: {
+                isInitialized: true,
+                getRepository: vi.fn().mockReturnValue(mockRepository),
+            } as any,
+        };
+        mockConfigService = {
+            dbConnectionOptions: {
+                type: 'postgres',
+                entities: [],
+            } as any,
+        };
+        collector = new DatabaseCollector(
+            mockConfigService as ConfigService,
+            mockConnection as TransactionalConnection,
+        );
+    });
+
+    describe('database type normalization', () => {
+        it('normalizes "better-sqlite3" to "sqlite"', async () => {
+            mockConfigService.dbConnectionOptions = { type: 'better-sqlite3', entities: [] } as any;
+
+            const result = await collector.collect();
+
+            expect(result.databaseType).toBe('sqlite');
+        });
+
+        it('normalizes "sqlite" to "sqlite"', async () => {
+            mockConfigService.dbConnectionOptions = { type: 'sqlite', entities: [] } as any;
+
+            const result = await collector.collect();
+
+            expect(result.databaseType).toBe('sqlite');
+        });
+
+        it('passes through "postgres"', async () => {
+            mockConfigService.dbConnectionOptions = { type: 'postgres', entities: [] } as any;
+
+            const result = await collector.collect();
+
+            expect(result.databaseType).toBe('postgres');
+        });
+
+        it('passes through "mysql"', async () => {
+            mockConfigService.dbConnectionOptions = { type: 'mysql', entities: [] } as any;
+
+            const result = await collector.collect();
+
+            expect(result.databaseType).toBe('mysql');
+        });
+
+        it('passes through "mariadb"', async () => {
+            mockConfigService.dbConnectionOptions = { type: 'mariadb', entities: [] } as any;
+
+            const result = await collector.collect();
+
+            expect(result.databaseType).toBe('mariadb');
+        });
+
+        it('defaults to "other" for unsupported database types', async () => {
+            mockConfigService.dbConnectionOptions = { type: 'oracle', entities: [] } as any;
+
+            const result = await collector.collect();
+
+            expect(result.databaseType).toBe('other');
+        });
+    });
+
+    describe('entity metrics collection', () => {
+        it('collects metrics for all core entities', async () => {
+            const result = await collector.collect();
+
+            const coreEntityNames = Object.keys(coreEntitiesMap);
+            for (const name of coreEntityNames) {
+                expect(result.metrics.entities[name]).toBeDefined();
+            }
+        });
+
+        it('calls toRangeBucket for entity counts', async () => {
+            // Verify that the collector uses range buckets (the actual bucket logic
+            // is tested in range-bucket.helper.spec.ts)
+            mockRepository.count.mockResolvedValue(0);
+
+            const result = await collector.collect();
+
+            // Count of 0 should result in '0' bucket
+            expect(result.metrics.entities.Product).toBe('0');
+        });
+
+        it('handles count failures gracefully (returns 0 bucket)', async () => {
+            mockRepository.count.mockRejectedValue(new Error('Database error'));
+
+            const result = await collector.collect();
+
+            // Should use '0' bucket for failed counts
+            expect(result.metrics.entities.Product).toBe('0');
+        });
+    });
+
+    describe('custom entity detection', () => {
+        it('counts custom entities without collecting names', async () => {
+            class CustomEntity {}
+            class AnotherCustomEntity {}
+
+            mockConfigService.dbConnectionOptions = {
+                type: 'postgres',
+                entities: [...Object.values(coreEntitiesMap), CustomEntity, AnotherCustomEntity],
+            } as any;
+
+            const result = await collector.collect();
+
+            expect(result.metrics.custom.entityCount).toBe(2);
+        });
+
+        it('returns 0 custom entities when only core entities are present', async () => {
+            mockConfigService.dbConnectionOptions = {
+                type: 'postgres',
+                entities: Object.values(coreEntitiesMap),
+            } as any;
+
+            const result = await collector.collect();
+
+            expect(result.metrics.custom.entityCount).toBe(0);
+            expect(result.metrics.custom.totalRecords).toBeUndefined();
+        });
+
+        it('includes totalRecords for custom entities when present', async () => {
+            class CustomEntity {}
+            mockRepository.count.mockResolvedValue(150);
+
+            mockConfigService.dbConnectionOptions = {
+                type: 'postgres',
+                entities: [...Object.values(coreEntitiesMap), CustomEntity],
+            } as any;
+
+            const result = await collector.collect();
+
+            expect(result.metrics.custom.entityCount).toBe(1);
+            expect(result.metrics.custom.totalRecords).toBe('101-1k');
+        });
+
+        it('handles non-array entities config', async () => {
+            mockConfigService.dbConnectionOptions = {
+                type: 'postgres',
+                entities: undefined,
+            } as any;
+
+            const result = await collector.collect();
+
+            expect(result.metrics.custom.entityCount).toBe(0);
+        });
+
+        it('filters out non-function entities (string paths)', async () => {
+            mockConfigService.dbConnectionOptions = {
+                type: 'postgres',
+                entities: [...Object.values(coreEntitiesMap), 'string-entity-path', { notAFunction: true }],
+            } as any;
+
+            const result = await collector.collect();
+
+            expect(result.metrics.custom.entityCount).toBe(0);
+        });
+
+        it('sums total records from all custom entities', async () => {
+            class CustomEntity1 {}
+            class CustomEntity2 {}
+            class CustomEntity3 {}
+
+            const coreEntityCount = Object.keys(coreEntitiesMap).length;
+            let callCount = 0;
+            mockRepository.count.mockImplementation(() => {
+                callCount++;
+                // Core entities return 50, custom entities return specific values
+                if (callCount > coreEntityCount) {
+                    // Custom entity counts: 100, 200, 300 = 600 total
+                    const customIndex = callCount - coreEntityCount;
+                    return Promise.resolve(customIndex * 100);
+                }
+                return Promise.resolve(50);
+            });
+
+            mockConfigService.dbConnectionOptions = {
+                type: 'postgres',
+                entities: [...Object.values(coreEntitiesMap), CustomEntity1, CustomEntity2, CustomEntity3],
+            } as any;
+
+            const result = await collector.collect();
+
+            expect(result.metrics.custom.entityCount).toBe(3);
+            // 100 + 200 + 300 = 600 falls into '101-1k' bucket
+            expect(result.metrics.custom.totalRecords).toBe('101-1k');
+        });
+    });
+});

+ 120 - 0
packages/core/src/telemetry/collectors/database.collector.ts

@@ -0,0 +1,120 @@
+import { Injectable } from '@nestjs/common';
+
+import { ConfigService } from '../../config/config.service';
+import { TransactionalConnection } from '../../connection/transactional-connection';
+import { coreEntitiesMap } from '../../entity/entities';
+import { toRangeBucket } from '../helpers/range-bucket.helper';
+import { RangeBucket, SupportedDatabaseType, TelemetryEntityMetrics } from '../telemetry.types';
+
+export interface DatabaseInfo {
+    databaseType: SupportedDatabaseType;
+    metrics: TelemetryEntityMetrics;
+}
+
+/**
+ * Collects database type and entity metrics for telemetry.
+ */
+@Injectable()
+export class DatabaseCollector {
+    constructor(
+        private readonly configService: ConfigService,
+        private readonly connection: TransactionalConnection,
+    ) {}
+
+    async collect(): Promise<DatabaseInfo> {
+        const databaseType = this.getDatabaseType();
+        let metrics: TelemetryEntityMetrics;
+
+        try {
+            metrics = await this.collectEntityMetrics();
+        } catch {
+            metrics = { entities: {}, custom: { entityCount: 0 } };
+        }
+
+        return {
+            databaseType,
+            metrics,
+        };
+    }
+
+    private getDatabaseType(): SupportedDatabaseType {
+        const dbType = this.configService.dbConnectionOptions.type;
+        if (dbType === 'better-sqlite3' || dbType === 'sqlite') {
+            return 'sqlite';
+        }
+        if (dbType === 'postgres' || dbType === 'mysql' || dbType === 'mariadb') {
+            return dbType;
+        }
+        return 'other';
+    }
+
+    private async collectEntityMetrics(): Promise<TelemetryEntityMetrics> {
+        // Check if connection is ready before attempting to collect metrics
+        const rawConnection = this.connection.rawConnection;
+        if (!rawConnection?.isInitialized) {
+            return { entities: {}, custom: { entityCount: 0 } };
+        }
+
+        const coreEntityEntries = Object.entries(coreEntitiesMap);
+        const counts = await Promise.all(coreEntityEntries.map(([, entity]) => this.safeCount(entity)));
+
+        const entities: Partial<Record<string, RangeBucket>> = {};
+        coreEntityEntries.forEach(([name], index) => {
+            entities[name] = toRangeBucket(counts[index]);
+        });
+
+        const customEntities = this.getCustomEntities();
+        const customEntityCount = customEntities.length;
+
+        // Only count custom entity records if there are custom entities
+        let totalCustomRecords: number | undefined;
+        if (customEntityCount > 0) {
+            const customCounts = await Promise.all(customEntities.map(entity => this.safeCount(entity)));
+            totalCustomRecords = customCounts.reduce((sum, count) => sum + count, 0);
+        }
+
+        return {
+            entities,
+            custom: {
+                entityCount: customEntityCount,
+                ...(totalCustomRecords !== undefined && { totalRecords: toRangeBucket(totalCustomRecords) }),
+            },
+        };
+    }
+
+    // eslint-disable-next-line @typescript-eslint/ban-types
+    private async safeCount(entity: Function): Promise<number> {
+        try {
+            const rawConnection = this.connection.rawConnection;
+            if (!rawConnection?.isInitialized) {
+                return 0;
+            }
+            return await rawConnection.getRepository(entity).count();
+        } catch {
+            return 0;
+        }
+    }
+
+    // eslint-disable-next-line @typescript-eslint/ban-types
+    private getCustomEntities(): Function[] {
+        const entities = this.configService.dbConnectionOptions.entities;
+        if (!Array.isArray(entities)) {
+            return [];
+        }
+
+        const coreEntityNames = new Set(Object.keys(coreEntitiesMap));
+        // eslint-disable-next-line @typescript-eslint/ban-types
+        const customEntities: Function[] = [];
+
+        for (const entity of entities) {
+            if (typeof entity === 'function') {
+                const entityName = entity.name;
+                if (!coreEntityNames.has(entityName)) {
+                    customEntities.push(entity);
+                }
+            }
+        }
+
+        return customEntities;
+    }
+}

+ 312 - 0
packages/core/src/telemetry/collectors/deployment.collector.spec.ts

@@ -0,0 +1,312 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ConfigService } from '../../config/config.service';
+import { InMemoryJobQueueStrategy } from '../../job-queue/in-memory-job-queue-strategy';
+import { JobQueueService } from '../../job-queue/job-queue.service';
+import { ProcessContext } from '../../process-context/process-context';
+
+import { CLOUD_PROVIDERS, DeploymentCollector, SERVERLESS_ENV_VARS } from './deployment.collector';
+
+vi.mock('fs');
+describe('DeploymentCollector', () => {
+    let collector: DeploymentCollector;
+    let mockProcessContext: Record<string, any>;
+    let mockConfigService: Record<string, any>;
+    let mockJobQueueService: Record<string, any>;
+    let mockFs: typeof import('fs');
+
+    // Collect all env vars that need to be cleaned (from source of truth)
+    const allEnvVarsToClean = [
+        'KUBERNETES_SERVICE_HOST',
+        ...CLOUD_PROVIDERS.flatMap(p => p.envVars),
+        ...SERVERLESS_ENV_VARS,
+    ];
+    // Deduplicate
+    const envVarsToClean = [...new Set(allEnvVarsToClean)];
+
+    const savedEnv: Record<string, string | undefined> = {};
+
+    beforeEach(async () => {
+        vi.resetAllMocks();
+
+        // Save and clear relevant env vars
+        for (const envVar of envVarsToClean) {
+            savedEnv[envVar] = process.env[envVar];
+            delete process.env[envVar];
+        }
+
+        mockProcessContext = {
+            isWorker: false,
+        };
+
+        mockConfigService = {
+            jobQueueOptions: {
+                jobQueueStrategy: new InMemoryJobQueueStrategy(),
+                activeQueues: [],
+            },
+        };
+
+        mockJobQueueService = {
+            started: false,
+        };
+
+        mockFs = await import('fs');
+
+        collector = new DeploymentCollector(
+            mockProcessContext as ProcessContext,
+            mockConfigService as ConfigService,
+            mockJobQueueService as JobQueueService,
+        );
+    });
+
+    afterEach(() => {
+        // Restore saved env vars
+        for (const envVar of envVarsToClean) {
+            if (savedEnv[envVar] !== undefined) {
+                process.env[envVar] = savedEnv[envVar];
+            } else {
+                delete process.env[envVar];
+            }
+        }
+        vi.resetAllMocks();
+    });
+
+    describe('container detection', () => {
+        it('detects Docker via /.dockerenv', () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                return p === '/.dockerenv';
+            });
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(true);
+        });
+
+        it('detects Kubernetes via KUBERNETES_SERVICE_HOST', () => {
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+            process.env.KUBERNETES_SERVICE_HOST = '10.0.0.1';
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(true);
+        });
+
+        it('detects Docker via /proc/1/cgroup containing "docker"', () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                return p === '/proc/1/cgroup';
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue('12:memory:/docker/abc123\n');
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(true);
+        });
+
+        it('detects Kubernetes via /proc/1/cgroup containing "kubepods"', () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                return p === '/proc/1/cgroup';
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue('12:memory:/kubepods/abc123\n');
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(true);
+        });
+
+        it('detects containerd via /proc/1/cgroup', () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                return p === '/proc/1/cgroup';
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue('12:memory:/containerd/abc123\n');
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(true);
+        });
+
+        it('returns false when not containerized', () => {
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(false);
+        });
+
+        it('handles cgroup read errors gracefully', () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                return p === '/proc/1/cgroup';
+            });
+            vi.mocked(mockFs.readFileSync).mockImplementation(() => {
+                throw new Error('Permission denied');
+            });
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(false);
+        });
+
+        it('does not detect podman (documents current behavior)', () => {
+            // Note: The collector currently only checks for docker, kubepods, and containerd.
+            // Podman and other container runtimes are not detected.
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                return p === '/proc/1/cgroup';
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue('12:memory:/podman/abc123\n');
+
+            const result = collector.collect();
+
+            expect(result.containerized).toBe(false);
+        });
+    });
+
+    describe('cloud provider detection', () => {
+        // Dynamically generate tests from the exported CLOUD_PROVIDERS
+        // This ensures tests stay in sync with source
+        for (const provider of CLOUD_PROVIDERS) {
+            describe(provider.name, () => {
+                for (const envVar of provider.envVars) {
+                    it(`detects ${provider.name} via ${envVar}`, () => {
+                        vi.mocked(mockFs.existsSync).mockReturnValue(false);
+                        process.env[envVar] = 'some-value';
+
+                        const result = collector.collect();
+
+                        expect(result.cloudProvider).toBe(provider.name);
+                    });
+                }
+            });
+        }
+
+        it('returns undefined when no cloud provider detected', () => {
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.cloudProvider).toBeUndefined();
+        });
+
+        it('ignores empty string values', () => {
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+            process.env.AWS_REGION = '';
+
+            const result = collector.collect();
+
+            expect(result.cloudProvider).toBeUndefined();
+        });
+
+        it('returns first matching provider when multiple are set (order is defined by CLOUD_PROVIDERS array)', () => {
+            // Note: This test documents that provider detection order matters.
+            // AWS comes before GCP in the CLOUD_PROVIDERS array.
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+            process.env.AWS_REGION = 'us-east-1';
+            process.env.GOOGLE_CLOUD_PROJECT = 'my-project';
+
+            const result = collector.collect();
+
+            expect(result.cloudProvider).toBe('aws');
+        });
+    });
+
+    describe('worker mode', () => {
+        it('returns "separate" when worker process', () => {
+            mockProcessContext.isWorker = true;
+            collector = new DeploymentCollector(
+                mockProcessContext as ProcessContext,
+                mockConfigService as ConfigService,
+                mockJobQueueService as JobQueueService,
+            );
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.workerMode).toBe('separate');
+        });
+
+        it('returns "integrated" when using InMemoryJobQueueStrategy', () => {
+            mockProcessContext.isWorker = false;
+            mockConfigService.jobQueueOptions = {
+                jobQueueStrategy: new InMemoryJobQueueStrategy(),
+                activeQueues: [],
+            };
+            collector = new DeploymentCollector(
+                mockProcessContext as ProcessContext,
+                mockConfigService as ConfigService,
+                mockJobQueueService as JobQueueService,
+            );
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.workerMode).toBe('integrated');
+        });
+
+        it('returns "integrated" when external strategy and job queue started', () => {
+            mockProcessContext.isWorker = false;
+            mockConfigService.jobQueueOptions = {
+                jobQueueStrategy: {}, // Non-InMemory strategy
+                activeQueues: [],
+            };
+            mockJobQueueService.started = true;
+            collector = new DeploymentCollector(
+                mockProcessContext as ProcessContext,
+                mockConfigService as ConfigService,
+                mockJobQueueService as JobQueueService,
+            );
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.workerMode).toBe('integrated');
+        });
+
+        it('returns "separate" when external strategy and job queue not started', () => {
+            mockProcessContext.isWorker = false;
+            mockConfigService.jobQueueOptions = {
+                jobQueueStrategy: {}, // Non-InMemory strategy
+                activeQueues: [],
+            };
+            mockJobQueueService.started = false;
+            collector = new DeploymentCollector(
+                mockProcessContext as ProcessContext,
+                mockConfigService as ConfigService,
+                mockJobQueueService as JobQueueService,
+            );
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.workerMode).toBe('separate');
+        });
+    });
+
+    describe('serverless detection', () => {
+        // Dynamically test all serverless env vars from source
+        for (const envVar of SERVERLESS_ENV_VARS) {
+            it(`detects serverless via ${envVar}`, () => {
+                vi.mocked(mockFs.existsSync).mockReturnValue(false);
+                process.env[envVar] = 'some-value';
+
+                const result = collector.collect();
+
+                expect(result.serverless).toBe(true);
+            });
+        }
+
+        it('returns false when not serverless', () => {
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+
+            const result = collector.collect();
+
+            expect(result.serverless).toBe(false);
+        });
+
+        it('ignores empty string values', () => {
+            vi.mocked(mockFs.existsSync).mockReturnValue(false);
+            process.env.AWS_LAMBDA_FUNCTION_NAME = '';
+
+            const result = collector.collect();
+
+            expect(result.serverless).toBe(false);
+        });
+    });
+});

+ 128 - 0
packages/core/src/telemetry/collectors/deployment.collector.ts

@@ -0,0 +1,128 @@
+import { Injectable } from '@nestjs/common';
+import fs from 'node:fs';
+
+import { ConfigService } from '../../config/config.service';
+import { InMemoryJobQueueStrategy } from '../../job-queue/in-memory-job-queue-strategy';
+import { JobQueueService } from '../../job-queue/job-queue.service';
+import { ProcessContext } from '../../process-context/process-context';
+import { TelemetryDeployment } from '../telemetry.types';
+
+/**
+ * Cloud provider detection based on environment variables.
+ * Exported for testing purposes.
+ */
+export const CLOUD_PROVIDERS: Array<{ name: string; envVars: string[] }> = [
+    { name: 'aws', envVars: ['AWS_REGION', 'AWS_LAMBDA_FUNCTION_NAME', 'AWS_EXECUTION_ENV'] },
+    { name: 'gcp', envVars: ['GOOGLE_CLOUD_PROJECT', 'GCLOUD_PROJECT', 'GCP_PROJECT'] },
+    { name: 'azure', envVars: ['AZURE_FUNCTIONS_ENVIRONMENT', 'WEBSITE_SITE_NAME'] },
+    { name: 'vercel', envVars: ['VERCEL', 'VERCEL_ENV'] },
+    { name: 'railway', envVars: ['RAILWAY_ENVIRONMENT', 'RAILWAY_PROJECT_ID'] },
+    { name: 'render', envVars: ['RENDER', 'RENDER_SERVICE_ID'] },
+    { name: 'fly', envVars: ['FLY_APP_NAME', 'FLY_REGION'] },
+    { name: 'heroku', envVars: ['DYNO', 'HEROKU_APP_NAME'] },
+    { name: 'digitalocean', envVars: ['DIGITALOCEAN_APP_NAME', 'DO_APP_PLATFORM'] },
+    { name: 'northflank', envVars: ['NORTHFLANK_APP_NAME', 'NF_PROJECT_NAME'] },
+];
+
+/**
+ * Serverless environment detection.
+ * Exported for testing purposes.
+ */
+export const SERVERLESS_ENV_VARS = [
+    'AWS_LAMBDA_FUNCTION_NAME',
+    'FUNCTION_NAME', // GCP Cloud Functions
+    'AZURE_FUNCTIONS_ENVIRONMENT',
+    'VERCEL_ENV',
+    'NETLIFY_FUNCTIONS',
+];
+
+/**
+ * Collects deployment environment information for telemetry.
+ */
+@Injectable()
+export class DeploymentCollector {
+    constructor(
+        private readonly processContext: ProcessContext,
+        private readonly configService: ConfigService,
+        private readonly jobQueueService: JobQueueService,
+    ) {}
+
+    collect(): TelemetryDeployment {
+        return {
+            containerized: this.isContainerized(),
+            cloudProvider: this.detectCloudProvider(),
+            workerMode: this.getWorkerMode(),
+            serverless: this.isServerless(),
+        };
+    }
+
+    private isContainerized(): boolean {
+        // Check for Docker
+        if (fs.existsSync('/.dockerenv')) {
+            return true;
+        }
+
+        // Check for Kubernetes
+        if (process.env.KUBERNETES_SERVICE_HOST) {
+            return true;
+        }
+
+        // Check cgroup for containerization (Linux only)
+        try {
+            if (fs.existsSync('/proc/1/cgroup')) {
+                const cgroup = fs.readFileSync('/proc/1/cgroup', 'utf-8');
+                if (
+                    cgroup.includes('docker') ||
+                    cgroup.includes('kubepods') ||
+                    cgroup.includes('containerd')
+                ) {
+                    return true;
+                }
+            }
+        } catch {
+            // Ignore errors
+        }
+
+        return false;
+    }
+
+    private detectCloudProvider(): string | undefined {
+        for (const provider of CLOUD_PROVIDERS) {
+            const hasEnvVar = provider.envVars.some(envVar => {
+                const value = process.env[envVar];
+                return value !== undefined && value !== '';
+            });
+
+            if (hasEnvVar) {
+                return provider.name;
+            }
+        }
+
+        return undefined;
+    }
+
+    private getWorkerMode(): 'integrated' | 'separate' {
+        // If we're in a worker process, definitely separate
+        if (this.processContext.isWorker) {
+            return 'separate';
+        }
+
+        // Check JobQueueStrategy type - InMemoryJobQueueStrategy CANNOT work with separate workers
+        const strategy = this.configService.jobQueueOptions.jobQueueStrategy;
+        if (strategy instanceof InMemoryJobQueueStrategy) {
+            return 'integrated';
+        }
+
+        // For external queue strategies, check if job queue was started in this server process
+        // If started here → integrated mode (server is processing jobs)
+        // If not started here → separate mode (a separate worker process handles jobs)
+        return this.jobQueueService.started ? 'integrated' : 'separate';
+    }
+
+    private isServerless(): boolean {
+        return SERVERLESS_ENV_VARS.some(envVar => {
+            const value = process.env[envVar];
+            return value !== undefined && value !== '';
+        });
+    }
+}

+ 6 - 0
packages/core/src/telemetry/collectors/index.ts

@@ -0,0 +1,6 @@
+export * from './config.collector';
+export * from './database.collector';
+export * from './deployment.collector';
+export * from './installation-id.collector';
+export * from './plugin.collector';
+export * from './system-info.collector';

+ 514 - 0
packages/core/src/telemetry/collectors/installation-id.collector.spec.ts

@@ -0,0 +1,514 @@
+import path from 'path';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { RequestContext } from '../../api/common/request-context';
+
+import { InstallationIdCollector } from './installation-id.collector';
+
+vi.mock('fs');
+vi.mock('crypto');
+
+describe('InstallationIdCollector', () => {
+    let collector: InstallationIdCollector;
+    let mockFs: typeof import('fs');
+    let mockCrypto: typeof import('crypto');
+    let mockSettingsStoreService: {
+        register: ReturnType<typeof vi.fn>;
+        get: ReturnType<typeof vi.fn>;
+        set: ReturnType<typeof vi.fn>;
+    };
+    let mockConnection: {
+        rawConnection: { isInitialized: boolean } | undefined;
+    };
+
+    const VALID_UUID = '550e8400-e29b-41d4-a716-446655440000';
+    const NEW_UUID = 'a1b2c3d4-e5f6-7890-abcd-ef1234567890';
+
+    beforeEach(async () => {
+        vi.resetAllMocks();
+
+        mockSettingsStoreService = {
+            register: vi.fn(),
+            get: vi.fn(),
+            set: vi.fn(),
+        };
+
+        mockConnection = {
+            rawConnection: { isInitialized: true },
+        };
+
+        collector = new InstallationIdCollector(mockSettingsStoreService as any, mockConnection as any);
+
+        mockFs = await import('fs');
+        mockCrypto = await import('crypto');
+
+        // Default mock for randomUUID
+        vi.mocked(mockCrypto.randomUUID).mockReturnValue(NEW_UUID);
+    });
+
+    describe('onModuleInit()', () => {
+        it('registers the settings store field', () => {
+            collector.onModuleInit();
+
+            expect(mockSettingsStoreService.register).toHaveBeenCalledWith({
+                namespace: 'telemetry',
+                fields: [{ name: 'installationId', scope: expect.any(Function), readonly: true }],
+            });
+        });
+    });
+
+    describe('collect() - DB primary flow', () => {
+        it('returns ID from database when available', async () => {
+            mockSettingsStoreService.get.mockResolvedValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+            expect(mockFs.existsSync).not.toHaveBeenCalled();
+            expect(mockFs.readFileSync).not.toHaveBeenCalled();
+        });
+
+        it('caches database ID on subsequent calls', async () => {
+            mockSettingsStoreService.get.mockResolvedValue(VALID_UUID);
+
+            const result1 = await collector.collect();
+            const result2 = await collector.collect();
+            const result3 = await collector.collect();
+
+            expect(result1).toBe(VALID_UUID);
+            expect(result2).toBe(VALID_UUID);
+            expect(result3).toBe(VALID_UUID);
+            expect(mockSettingsStoreService.get).toHaveBeenCalledTimes(1);
+        });
+    });
+
+    describe('collect() - migration flow (filesystem to DB)', () => {
+        it('migrates filesystem ID to database', async () => {
+            // DB returns nothing
+            mockSettingsStoreService.get.mockResolvedValue(undefined);
+
+            // Filesystem has a valid ID
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+            // Should save filesystem ID to DB
+            expect(mockSettingsStoreService.set).toHaveBeenCalledWith(
+                expect.any(RequestContext),
+                'telemetry.installationId',
+                VALID_UUID,
+            );
+        });
+    });
+
+    describe('collect() - fresh install flow', () => {
+        it('generates new UUID and saves to both DB and filesystem', async () => {
+            // DB returns nothing
+            mockSettingsStoreService.get.mockResolvedValue(undefined);
+
+            // Filesystem has nothing
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return false;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+            // Should save to DB
+            expect(mockSettingsStoreService.set).toHaveBeenCalledWith(
+                expect.any(RequestContext),
+                'telemetry.installationId',
+                NEW_UUID,
+            );
+            // Should save to filesystem
+            expect(mockFs.writeFileSync).toHaveBeenCalledWith(
+                expect.stringContaining('.installation-id'),
+                NEW_UUID,
+                'utf-8',
+            );
+        });
+
+        it('creates .vendure directory if it does not exist', async () => {
+            mockSettingsStoreService.get.mockResolvedValue(undefined);
+
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return false;
+                if (pathStr.includes('.vendure')) return false;
+                return false;
+            });
+
+            await collector.collect();
+
+            expect(mockFs.mkdirSync).toHaveBeenCalledWith(expect.stringContaining('.vendure'), {
+                recursive: true,
+            });
+        });
+    });
+
+    describe('collect() - DB unavailable fallback', () => {
+        it('falls back to filesystem when connection is not initialized', async () => {
+            mockConnection.rawConnection = { isInitialized: false };
+
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+            expect(mockSettingsStoreService.get).not.toHaveBeenCalled();
+        });
+
+        it('falls back to filesystem when rawConnection is undefined', async () => {
+            mockConnection.rawConnection = undefined;
+
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+            expect(mockSettingsStoreService.get).not.toHaveBeenCalled();
+        });
+
+        it('generates new ID when both DB and filesystem are unavailable', async () => {
+            mockConnection.rawConnection = { isInitialized: false };
+
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return false;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.writeFileSync).mockImplementation(() => {
+                throw new Error('Permission denied');
+            });
+
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+        });
+    });
+
+    describe('collect() - DB error fallback', () => {
+        it('falls back to filesystem when DB query throws', async () => {
+            mockSettingsStoreService.get.mockRejectedValue(new Error('DB connection lost'));
+
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+        });
+
+        it('silently ignores DB write errors during migration', async () => {
+            mockSettingsStoreService.get.mockResolvedValue(undefined);
+            mockSettingsStoreService.set.mockRejectedValue(new Error('DB write failed'));
+
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            // Should still return the filesystem ID despite DB write failure
+            expect(result).toBe(VALID_UUID);
+        });
+    });
+
+    describe('collect() - filesystem fallback behavior', () => {
+        beforeEach(() => {
+            // DB returns nothing for all filesystem tests
+            mockSettingsStoreService.get.mockResolvedValue(undefined);
+        });
+
+        it('reads existing valid UUID from file', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+        });
+
+        it('generates new UUID when file does not exist', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return false;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+            expect(mockFs.writeFileSync).toHaveBeenCalledWith(
+                expect.stringContaining('.installation-id'),
+                NEW_UUID,
+                'utf-8',
+            );
+        });
+
+        it('regenerates UUID when file contains invalid UUID', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue('invalid-uuid-format');
+
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+        });
+
+        it('regenerates UUID when file is empty', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue('');
+
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+        });
+
+        it('falls back to ephemeral ID on filesystem read error', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockImplementation(() => {
+                throw new Error('Permission denied');
+            });
+
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+        });
+
+        it('falls back to ephemeral ID on filesystem write error', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return false;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.writeFileSync).mockImplementation(() => {
+                throw new Error('Permission denied');
+            });
+
+            const result = await collector.collect();
+
+            // Should still return a UUID even if write fails
+            expect(result).toBe(NEW_UUID);
+        });
+
+        it('trims whitespace from read UUID', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(`  ${VALID_UUID}  \n`);
+
+            const result = await collector.collect();
+
+            expect(result).toBe(VALID_UUID);
+        });
+    });
+
+    describe('project root detection', () => {
+        beforeEach(() => {
+            mockSettingsStoreService.get.mockResolvedValue(undefined);
+        });
+
+        it('finds project root via node_modules', async () => {
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                if (pathStr.includes('node_modules')) return true;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            await collector.collect();
+
+            expect(mockFs.existsSync).toHaveBeenCalledWith(expect.stringContaining('node_modules'));
+        });
+
+        it('falls back to cwd when node_modules not found', async () => {
+            const cwd = process.cwd();
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                const pathStr = p.toString();
+                // node_modules not found anywhere
+                if (pathStr.includes('node_modules')) return false;
+                if (pathStr.includes('.installation-id')) return true;
+                return false;
+            });
+            vi.mocked(mockFs.readFileSync).mockReturnValue(VALID_UUID);
+
+            await collector.collect();
+
+            // Verify the fallback path uses cwd
+            expect(mockFs.existsSync).toHaveBeenCalledWith(path.join(cwd, '.vendure', '.installation-id'));
+        });
+
+        it('handles traversal from root directory without infinite loop', async () => {
+            // Simulate starting from root - the loop should terminate
+            // when currentDir === path.dirname(currentDir) (i.e., '/' === '/')
+            let callCount = 0;
+            vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                callCount++;
+                // Prevent infinite loop in test - fail after reasonable iterations
+                if (callCount > 100) {
+                    throw new Error('Infinite loop detected in directory traversal');
+                }
+                const pathStr = p.toString();
+                // node_modules never found
+                if (pathStr.includes('node_modules')) return false;
+                if (pathStr.includes('.installation-id')) return false;
+                if (pathStr.includes('.vendure')) return true;
+                return false;
+            });
+
+            // Should not throw and should return a UUID
+            const result = await collector.collect();
+
+            expect(result).toBe(NEW_UUID);
+            // Verify we didn't hit the infinite loop guard
+            expect(callCount).toBeLessThan(100);
+        });
+    });
+
+    describe('UUID validation', () => {
+        const validUUIDs = [
+            '550e8400-e29b-41d4-a716-446655440000',
+            'A550E840-E29B-41D4-A716-446655440000', // uppercase is valid per RFC 4122
+            '00000000-0000-0000-0000-000000000000',
+            'ffffffff-ffff-ffff-ffff-ffffffffffff',
+        ];
+
+        const invalidUUIDs = [
+            'not-a-uuid',
+            '550e8400-e29b-41d4-a716', // too short
+            '550e8400-e29b-41d4-a716-4466554400000', // too long
+            '550e8400e29b41d4a716446655440000', // no dashes
+            '550e8400-e29b-41d4-a716-44665544000g', // invalid character
+            '',
+            '   ',
+        ];
+
+        for (const uuid of validUUIDs) {
+            it(`accepts valid UUID: ${uuid}`, async () => {
+                // DB returns the UUID
+                const freshCollector = new InstallationIdCollector(
+                    mockSettingsStoreService as any,
+                    mockConnection as any,
+                );
+                mockSettingsStoreService.get.mockResolvedValue(uuid);
+
+                const result = await freshCollector.collect();
+
+                expect(result).toBe(uuid);
+            });
+        }
+
+        for (const uuid of invalidUUIDs) {
+            it(`rejects invalid UUID from DB: "${uuid}"`, async () => {
+                const freshCollector = new InstallationIdCollector(
+                    mockSettingsStoreService as any,
+                    mockConnection as any,
+                );
+                // DB returns invalid UUID
+                mockSettingsStoreService.get.mockResolvedValue(uuid);
+
+                // Filesystem also not available
+                vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                    const pathStr = p.toString();
+                    if (pathStr.includes('node_modules')) return true;
+                    if (pathStr.includes('.installation-id')) return false;
+                    if (pathStr.includes('.vendure')) return true;
+                    return false;
+                });
+
+                const result = await freshCollector.collect();
+
+                expect(result).toBe(NEW_UUID);
+            });
+        }
+
+        for (const uuid of invalidUUIDs) {
+            it(`rejects invalid UUID from filesystem: "${uuid}"`, async () => {
+                const freshCollector = new InstallationIdCollector(
+                    mockSettingsStoreService as any,
+                    mockConnection as any,
+                );
+                // DB returns nothing
+                mockSettingsStoreService.get.mockResolvedValue(undefined);
+
+                vi.mocked(mockFs.existsSync).mockImplementation((p: any) => {
+                    const pathStr = p.toString();
+                    if (pathStr.includes('node_modules')) return true;
+                    if (pathStr.includes('.installation-id')) return true;
+                    if (pathStr.includes('.vendure')) return true;
+                    return false;
+                });
+                vi.mocked(mockFs.readFileSync).mockReturnValue(uuid);
+
+                const result = await freshCollector.collect();
+
+                expect(result).toBe(NEW_UUID);
+            });
+        }
+    });
+});

+ 142 - 0
packages/core/src/telemetry/collectors/installation-id.collector.ts

@@ -0,0 +1,142 @@
+import { Injectable, OnModuleInit } from '@nestjs/common';
+import crypto from 'node:crypto';
+import fs from 'node:fs';
+import path from 'node:path';
+
+import { RequestContext } from '../../api/common/request-context';
+import { SettingsStoreScopes } from '../../config/settings-store/settings-store-types';
+import { TransactionalConnection } from '../../connection/transactional-connection';
+import { SettingsStoreService } from '../../service/helpers/settings-store/settings-store.service';
+
+const SETTINGS_KEY = 'telemetry.installationId';
+
+/**
+ * Manages a persistent installation ID for telemetry purposes.
+ * The ID is a random UUID stored primarily in the database via SettingsStoreService,
+ * with a filesystem fallback at .vendure/.installation-id for when the DB is unavailable.
+ *
+ * Using the database ensures the installation ID persists across container restarts
+ * in containerized deployments (Docker, K8s).
+ */
+@Injectable()
+export class InstallationIdCollector implements OnModuleInit {
+    private cachedId: string | undefined;
+
+    constructor(
+        private readonly settingsStoreService: SettingsStoreService,
+        private readonly connection: TransactionalConnection,
+    ) {}
+
+    onModuleInit() {
+        this.settingsStoreService.register({
+            namespace: 'telemetry',
+            fields: [{ name: 'installationId', scope: SettingsStoreScopes.global, readonly: true }],
+        });
+    }
+
+    /**
+     * Returns the installation ID, creating one if it doesn't exist.
+     * Checks DB first, then filesystem, then generates a new one.
+     */
+    async collect(): Promise<string> {
+        if (this.cachedId) {
+            return this.cachedId;
+        }
+
+        // 1. Try database
+        const dbId = await this.readFromDatabase();
+        if (dbId) {
+            this.cachedId = dbId;
+            return dbId;
+        }
+
+        // 2. Try filesystem
+        const fsId = this.readFromFilesystem();
+        if (fsId) {
+            // Migrate filesystem ID to database
+            await this.saveToDatabase(fsId);
+            this.cachedId = fsId;
+            return fsId;
+        }
+
+        // 3. Generate new ID
+        const newId = crypto.randomUUID();
+        await this.saveToDatabase(newId);
+        this.saveToFilesystem(newId);
+        this.cachedId = newId;
+        return newId;
+    }
+
+    private async readFromDatabase(): Promise<string | undefined> {
+        try {
+            if (!this.connection.rawConnection?.isInitialized) {
+                return undefined;
+            }
+            const value = await this.settingsStoreService.get(RequestContext.empty(), SETTINGS_KEY);
+            if (typeof value === 'string' && this.isValidUUID(value)) {
+                return value;
+            }
+            return undefined;
+        } catch {
+            return undefined;
+        }
+    }
+
+    private async saveToDatabase(id: string): Promise<void> {
+        try {
+            if (!this.connection.rawConnection?.isInitialized) {
+                return;
+            }
+            await this.settingsStoreService.set(RequestContext.empty(), SETTINGS_KEY, id as any);
+        } catch {
+            // Best-effort: silently ignore DB write failures
+        }
+    }
+
+    private readFromFilesystem(): string | undefined {
+        try {
+            const idPath = this.getInstallationIdPath();
+            if (fs.existsSync(idPath)) {
+                const existingId = fs.readFileSync(idPath, 'utf-8').trim();
+                if (existingId && this.isValidUUID(existingId)) {
+                    return existingId;
+                }
+            }
+            return undefined;
+        } catch {
+            return undefined;
+        }
+    }
+
+    private saveToFilesystem(id: string): void {
+        try {
+            const idPath = this.getInstallationIdPath();
+            const vendureDir = path.dirname(idPath);
+            fs.mkdirSync(vendureDir, { recursive: true });
+            fs.writeFileSync(idPath, id, 'utf-8');
+        } catch {
+            // Best-effort: silently ignore filesystem write failures
+        }
+    }
+
+    private getInstallationIdPath(): string {
+        // Find the project root by looking for node_modules
+        let currentDir = process.cwd();
+
+        // Walk up to find project root (where node_modules is)
+        while (currentDir !== path.dirname(currentDir)) {
+            if (fs.existsSync(path.join(currentDir, 'node_modules'))) {
+                return path.join(currentDir, '.vendure', '.installation-id');
+            }
+            currentDir = path.dirname(currentDir);
+        }
+
+        // Fallback to cwd
+        return path.join(process.cwd(), '.vendure', '.installation-id');
+    }
+
+    private isValidUUID(str: string): boolean {
+        const uuidRegex = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
+        return uuidRegex.test(str);
+    }
+}

+ 254 - 0
packages/core/src/telemetry/collectors/plugin.collector.spec.ts

@@ -0,0 +1,254 @@
+import { DynamicModule } from '@nestjs/common';
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { ConfigService } from '../../config/config.service';
+
+import { PluginCollector } from './plugin.collector';
+
+describe('PluginCollector', () => {
+    let collector: PluginCollector;
+    let mockConfigService: Record<string, any>;
+    const addedCacheKeys: string[] = [];
+
+    beforeEach(() => {
+        mockConfigService = {
+            plugins: [],
+        };
+        collector = new PluginCollector(mockConfigService as ConfigService);
+    });
+
+    afterEach(() => {
+        // Clean up any cache entries we added during tests
+        for (const key of addedCacheKeys) {
+            delete require.cache[key];
+        }
+        addedCacheKeys.length = 0;
+    });
+
+    function addToRequireCache(modulePath: string, exports: any) {
+        require.cache[modulePath] = { exports } as any;
+        addedCacheKeys.push(modulePath);
+    }
+
+    describe('collect()', () => {
+        it('returns empty npm array and zero customCount when no plugins', () => {
+            mockConfigService.plugins = [];
+
+            const result = collector.collect();
+
+            expect(result.npm).toEqual([]);
+            expect(result.customCount).toBe(0);
+        });
+
+        it('counts custom plugins without collecting names', () => {
+            class CustomPlugin {}
+            class AnotherCustomPlugin {}
+
+            mockConfigService.plugins = [CustomPlugin, AnotherCustomPlugin];
+
+            const result = collector.collect();
+
+            expect(result.customCount).toBe(2);
+            expect(result.npm).toEqual([]);
+        });
+
+        it('detects npm packages from node_modules paths', () => {
+            class NpmPlugin {}
+
+            addToRequireCache('/project/node_modules/@vendure/some-plugin/dist/index.js', NpmPlugin);
+
+            mockConfigService.plugins = [NpmPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('@vendure/some-plugin');
+            expect(result.customCount).toBe(0);
+        });
+
+        it('extracts scoped package names (@scope/package)', () => {
+            class ScopedPlugin {}
+
+            addToRequireCache('/project/node_modules/@myorg/my-plugin/dist/plugin.js', ScopedPlugin);
+
+            mockConfigService.plugins = [ScopedPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('@myorg/my-plugin');
+        });
+
+        it('extracts regular package names', () => {
+            class RegularPlugin {}
+
+            addToRequireCache('/project/node_modules/vendure-plugin-foo/dist/index.js', RegularPlugin);
+
+            mockConfigService.plugins = [RegularPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('vendure-plugin-foo');
+        });
+
+        it('handles DynamicModule plugins', () => {
+            class DynamicModulePlugin {}
+
+            const dynamicModule: DynamicModule = {
+                module: DynamicModulePlugin,
+                providers: [],
+            };
+
+            addToRequireCache(
+                '/project/node_modules/@vendure/dynamic-plugin/dist/index.js',
+                DynamicModulePlugin,
+            );
+
+            mockConfigService.plugins = [dynamicModule];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('@vendure/dynamic-plugin');
+        });
+
+        it('handles DynamicModule with undefined module property as custom plugin', () => {
+            // Edge case: object that looks like DynamicModule but has no module
+            const weirdPlugin = { providers: [] } as any;
+
+            mockConfigService.plugins = [weirdPlugin];
+
+            const result = collector.collect();
+
+            // Should count as custom since it can't be resolved
+            expect(result.customCount).toBe(1);
+        });
+
+        it('handles default exports', () => {
+            class DefaultExportPlugin {}
+
+            addToRequireCache('/project/node_modules/default-export-plugin/dist/index.js', {
+                default: DefaultExportPlugin,
+            });
+
+            mockConfigService.plugins = [DefaultExportPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('default-export-plugin');
+        });
+
+        it('handles named exports', () => {
+            class NamedExportPlugin {}
+
+            addToRequireCache('/project/node_modules/named-export-plugin/dist/index.js', {
+                NamedExportPlugin,
+                OtherExport: class {},
+            });
+
+            mockConfigService.plugins = [NamedExportPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('named-export-plugin');
+        });
+
+        it('deduplicates npm package names', () => {
+            class Plugin1 {}
+            class Plugin2 {}
+
+            addToRequireCache('/project/node_modules/@vendure/multi-export/dist/index.js', {
+                Plugin1,
+                Plugin2,
+            });
+
+            mockConfigService.plugins = [Plugin1, Plugin2];
+
+            const result = collector.collect();
+
+            expect(result.npm).toEqual(['@vendure/multi-export']);
+            expect(result.customCount).toBe(0);
+        });
+
+        it('sorts npm package names alphabetically', () => {
+            class PluginZ {}
+            class PluginA {}
+            class PluginM {}
+
+            addToRequireCache('/project/node_modules/z-plugin/dist/index.js', PluginZ);
+            addToRequireCache('/project/node_modules/a-plugin/dist/index.js', PluginA);
+            addToRequireCache('/project/node_modules/m-plugin/dist/index.js', PluginM);
+
+            mockConfigService.plugins = [PluginZ, PluginA, PluginM];
+
+            const result = collector.collect();
+
+            expect(result.npm).toEqual(['a-plugin', 'm-plugin', 'z-plugin']);
+        });
+
+        it('handles mixed npm and custom plugins', () => {
+            class NpmPlugin {}
+            class CustomPlugin {}
+
+            addToRequireCache('/project/node_modules/@vendure/npm-plugin/dist/index.js', NpmPlugin);
+
+            mockConfigService.plugins = [NpmPlugin, CustomPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('@vendure/npm-plugin');
+            expect(result.customCount).toBe(1);
+        });
+
+        it('ignores cache entries without exports', () => {
+            class SomePlugin {}
+
+            addToRequireCache('/project/node_modules/broken-plugin/dist/index.js', undefined);
+
+            mockConfigService.plugins = [SomePlugin];
+
+            const result = collector.collect();
+
+            expect(result.customCount).toBe(1);
+        });
+
+        it('ignores cache entries not in node_modules', () => {
+            class LocalPlugin {}
+
+            addToRequireCache('/project/src/plugins/local-plugin.ts', LocalPlugin);
+
+            mockConfigService.plugins = [LocalPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toEqual([]);
+            expect(result.customCount).toBe(1);
+        });
+
+        it('handles Windows-style paths', () => {
+            class WindowsPlugin {}
+
+            // Windows path with backslashes
+            addToRequireCache('C:\\project\\node_modules\\windows-plugin\\dist\\index.js', WindowsPlugin);
+
+            mockConfigService.plugins = [WindowsPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('windows-plugin');
+        });
+
+        it('handles nested node_modules (uses last occurrence for correct resolution)', () => {
+            class NestedPlugin {}
+
+            // Plugin in nested node_modules - uses lastIndexOf to find the correct package
+            addToRequireCache(
+                '/project/node_modules/parent-pkg/node_modules/nested-plugin/dist/index.js',
+                NestedPlugin,
+            );
+
+            mockConfigService.plugins = [NestedPlugin];
+
+            const result = collector.collect();
+
+            expect(result.npm).toContain('nested-plugin');
+        });
+    });
+});

+ 161 - 0
packages/core/src/telemetry/collectors/plugin.collector.ts

@@ -0,0 +1,161 @@
+import { DynamicModule, Injectable, Type } from '@nestjs/common';
+
+import { ConfigService } from '../../config/config.service';
+import { isDynamicModule } from '../../plugin/plugin-metadata';
+import { TelemetryPluginInfo } from '../telemetry.types';
+
+/**
+ * Known Vendure plugins mapped to their npm package names.
+ * This is more reliable than require.cache inspection which fails with ESM/TypeScript.
+ */
+const KNOWN_VENDURE_PLUGINS: Record<string, string> = {
+    // @vendure/core
+    DefaultSearchPlugin: '@vendure/core',
+    DefaultJobQueuePlugin: '@vendure/core',
+    DefaultSchedulerPlugin: '@vendure/core',
+    // @vendure/asset-server-plugin
+    AssetServerPlugin: '@vendure/asset-server-plugin',
+    // @vendure/email-plugin
+    EmailPlugin: '@vendure/email-plugin',
+    // @vendure/admin-ui-plugin
+    AdminUiPlugin: '@vendure/admin-ui-plugin',
+    // @vendure/dashboard
+    DashboardPlugin: '@vendure/dashboard',
+    // @vendure/job-queue-plugin
+    BullMQJobQueuePlugin: '@vendure/job-queue-plugin',
+    // @vendure/elasticsearch-plugin
+    ElasticsearchPlugin: '@vendure/elasticsearch-plugin',
+    // @vendure/graphiql-plugin
+    GraphiqlPlugin: '@vendure/graphiql-plugin',
+    // @vendure/harden-plugin
+    HardenPlugin: '@vendure/harden-plugin',
+    // @vendure/sentry-plugin
+    SentryPlugin: '@vendure/sentry-plugin',
+    // @vendure/payments-plugin
+    StripePlugin: '@vendure/payments-plugin',
+    MolliePlugin: '@vendure/payments-plugin',
+    BraintreePlugin: '@vendure/payments-plugin',
+};
+
+/**
+ * Collects information about plugins used in the Vendure installation.
+ * Detects npm packages by checking if the plugin originates from node_modules.
+ * Custom plugin names are NOT collected for privacy.
+ */
+@Injectable()
+export class PluginCollector {
+    constructor(private readonly configService: ConfigService) {}
+
+    collect(): TelemetryPluginInfo {
+        try {
+            const plugins = this.configService.plugins;
+            const npmPlugins = new Set<string>();
+            let customCount = 0;
+
+            for (const plugin of plugins) {
+                try {
+                    const npmPackage = this.findNpmPackage(plugin);
+
+                    if (npmPackage) {
+                        npmPlugins.add(npmPackage);
+                    } else {
+                        customCount++;
+                    }
+                } catch {
+                    customCount++;
+                }
+            }
+
+            return {
+                npm: Array.from(npmPlugins).sort((a, b) => a.localeCompare(b)),
+                customCount,
+            };
+        } catch {
+            return { npm: [], customCount: 0 };
+        }
+    }
+
+    /**
+     * Finds the npm package name for a plugin.
+     * First checks against known Vendure plugins, then falls back to require.cache inspection.
+     */
+    private findNpmPackage(plugin: Type<any> | DynamicModule): string | undefined {
+        const pluginClass = isDynamicModule(plugin) ? plugin.module : plugin;
+        if (!pluginClass) {
+            return undefined;
+        }
+        const pluginName = pluginClass.name ?? 'unknown';
+
+        // First, check against known Vendure plugins (most reliable)
+        const knownPackage = KNOWN_VENDURE_PLUGINS[pluginName];
+        if (knownPackage) {
+            return knownPackage;
+        }
+
+        // Fall back to require.cache inspection for third-party npm plugins
+        return this.findInRequireCache(pluginClass);
+    }
+
+    /**
+     * Searches the require cache for a plugin class.
+     * This is a fallback for third-party npm plugins not in our known list.
+     */
+    private findInRequireCache(pluginClass: Type<any>): string | undefined {
+        // Check if require.cache is available (may not be in ESM-only environments)
+        if (typeof require === 'undefined' || !require.cache) {
+            return undefined;
+        }
+
+        try {
+            for (const [modulePath, moduleObj] of Object.entries(require.cache)) {
+                if (!moduleObj?.exports || !modulePath.includes('node_modules')) {
+                    continue;
+                }
+
+                try {
+                    const exports = moduleObj.exports;
+
+                    // Direct match or default export match
+                    if (exports === pluginClass || exports?.default === pluginClass) {
+                        return this.extractPackageName(modulePath);
+                    }
+
+                    // Check named exports
+                    if (typeof exports === 'object' && exports !== null) {
+                        const exportValues = Object.values(exports);
+                        if (exportValues.includes(pluginClass)) {
+                            return this.extractPackageName(modulePath);
+                        }
+                    }
+                } catch {
+                    // Skip modules with problematic exports
+                    continue;
+                }
+            }
+        } catch {
+            // Ignore errors accessing require.cache
+        }
+
+        return undefined;
+    }
+
+    /**
+     * Extracts the npm package name from a node_modules path.
+     * Handles both scoped (@scope/package) and unscoped packages.
+     */
+    private extractPackageName(modulePath: string): string | undefined {
+        const nodeModulesIndex = modulePath.lastIndexOf('node_modules');
+        if (nodeModulesIndex === -1) {
+            return undefined;
+        }
+
+        const pathAfterNodeModules = modulePath.slice(nodeModulesIndex + 'node_modules/'.length);
+        const parts = pathAfterNodeModules.split(/[/\\]/);
+
+        if (parts[0].startsWith('@')) {
+            // Scoped package: @scope/package
+            return `${parts[0]}/${parts[1]}`;
+        }
+        return parts[0];
+    }
+}

+ 50 - 0
packages/core/src/telemetry/collectors/system-info.collector.spec.ts

@@ -0,0 +1,50 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { SystemInfoCollector } from './system-info.collector';
+
+vi.mock('os');
+
+describe('SystemInfoCollector', () => {
+    let collector: SystemInfoCollector;
+    let mockOs: typeof import('os');
+
+    beforeEach(async () => {
+        vi.resetAllMocks();
+        collector = new SystemInfoCollector();
+        mockOs = await import('os');
+    });
+
+    afterEach(() => {
+        vi.resetAllMocks();
+    });
+
+    describe('collect()', () => {
+        it('returns system info with nodeVersion and platform', () => {
+            vi.mocked(mockOs.platform).mockReturnValue('linux');
+            vi.mocked(mockOs.arch).mockReturnValue('x64');
+
+            const result = collector.collect();
+
+            // Verify nodeVersion comes from process.version
+            expect(result.nodeVersion).toBe(process.version);
+            // Verify platform combines os.platform() and os.arch()
+            expect(result.platform).toBe('linux x64');
+        });
+
+        it('does not throw for any platform/arch combination', () => {
+            // Test a few combinations to ensure the function is robust
+            const combinations = [
+                { platform: 'darwin', arch: 'arm64' },
+                { platform: 'win32', arch: 'x64' },
+                { platform: 'linux', arch: 'ia32' },
+            ] as const;
+
+            for (const { platform, arch } of combinations) {
+                vi.mocked(mockOs.platform).mockReturnValue(platform);
+                vi.mocked(mockOs.arch).mockReturnValue(arch);
+
+                expect(() => collector.collect()).not.toThrow();
+            }
+        });
+    });
+});

+ 20 - 0
packages/core/src/telemetry/collectors/system-info.collector.ts

@@ -0,0 +1,20 @@
+import { Injectable } from '@nestjs/common';
+import os from 'node:os';
+
+export interface SystemInfo {
+    nodeVersion: string;
+    platform: string;
+}
+
+/**
+ * Collects basic system information for telemetry.
+ */
+@Injectable()
+export class SystemInfoCollector {
+    collect(): SystemInfo {
+        return {
+            nodeVersion: process.version,
+            platform: `${os.platform()} ${os.arch()}`,
+        };
+    }
+}

+ 81 - 0
packages/core/src/telemetry/helpers/ci-detector.helper.spec.ts

@@ -0,0 +1,81 @@
+import { afterEach, beforeEach, describe, expect, it } from 'vitest';
+
+import { CI_ENV_VARS, isCI } from './ci-detector.helper';
+
+describe('isCI()', () => {
+    const originalEnv = { ...process.env };
+
+    beforeEach(() => {
+        // Clear all CI env vars before each test (using the source of truth)
+        for (const envVar of CI_ENV_VARS) {
+            delete process.env[envVar];
+        }
+    });
+
+    afterEach(() => {
+        // Restore original environment
+        process.env = { ...originalEnv };
+    });
+
+    it('returns false when no CI vars are set', () => {
+        expect(isCI()).toBe(false);
+    });
+
+    describe('returns true for each CI env var', () => {
+        // Dynamically generate tests from the exported CI_ENV_VARS
+        // This ensures tests stay in sync with source
+        for (const envVar of CI_ENV_VARS) {
+            it(`returns true when ${envVar} is set to "true"`, () => {
+                process.env[envVar] = 'true';
+                expect(isCI()).toBe(true);
+            });
+
+            it(`returns true when ${envVar} is set to "1"`, () => {
+                process.env[envVar] = '1';
+                expect(isCI()).toBe(true);
+            });
+
+            it(`returns true when ${envVar} is set to any non-empty value`, () => {
+                process.env[envVar] = 'some-value';
+                expect(isCI()).toBe(true);
+            });
+        }
+    });
+
+    describe('returns false for falsy values', () => {
+        it('returns false when CI is set to "false"', () => {
+            process.env.CI = 'false';
+            expect(isCI()).toBe(false);
+        });
+
+        it('returns false when CI is set to "0"', () => {
+            process.env.CI = '0';
+            expect(isCI()).toBe(false);
+        });
+
+        it('returns false when CI is set to empty string', () => {
+            process.env.CI = '';
+            expect(isCI()).toBe(false);
+        });
+    });
+
+    it('handles multiple CI vars simultaneously', () => {
+        process.env.CI = 'true';
+        process.env.GITHUB_ACTIONS = 'true';
+        process.env.GITLAB_CI = 'true';
+        expect(isCI()).toBe(true);
+    });
+
+    it('returns true if at least one CI var is truthy', () => {
+        process.env.CI = 'false';
+        process.env.GITHUB_ACTIONS = 'true';
+        expect(isCI()).toBe(true);
+    });
+
+    it('returns false if all CI vars are falsy', () => {
+        process.env.CI = 'false';
+        process.env.GITHUB_ACTIONS = '0';
+        process.env.GITLAB_CI = '';
+        expect(isCI()).toBe(false);
+    });
+});

+ 35 - 0
packages/core/src/telemetry/helpers/ci-detector.helper.ts

@@ -0,0 +1,35 @@
+/**
+ * CI environment variables to check for.
+ * These are standard environment variables set by popular CI/CD systems.
+ * Exported for testing purposes.
+ */
+export const CI_ENV_VARS = [
+    'CI',
+    'GITHUB_ACTIONS',
+    'GITLAB_CI',
+    'CIRCLECI',
+    'TRAVIS',
+    'JENKINS_URL',
+    'BUILDKITE',
+    'DRONE',
+    'TEAMCITY_VERSION',
+    'BITBUCKET_BUILD_NUMBER',
+    'TF_BUILD',
+    'CODEBUILD_BUILD_ID',
+    'HEROKU_TEST_RUN_ID',
+    'APPVEYOR',
+    'NETLIFY',
+    'VERCEL',
+    'NOW_BUILDER',
+];
+
+/**
+ * Detects if the current process is running in a CI/CD environment.
+ * Returns true if any known CI environment variable is set.
+ */
+export function isCI(): boolean {
+    return CI_ENV_VARS.some(envVar => {
+        const value = process.env[envVar];
+        return value !== undefined && value !== '' && value !== 'false' && value !== '0';
+    });
+}

+ 2 - 0
packages/core/src/telemetry/helpers/index.ts

@@ -0,0 +1,2 @@
+export * from './ci-detector.helper';
+export * from './range-bucket.helper';

+ 10 - 0
packages/core/src/telemetry/helpers/is-telemetry-disabled.helper.ts

@@ -0,0 +1,10 @@
+import { isCI } from './ci-detector.helper';
+
+/**
+ * Checks if telemetry is disabled via the VENDURE_DISABLE_TELEMETRY environment
+ * variable or CI environment detection.
+ */
+export function isTelemetryDisabled(): boolean {
+    const disableEnv = process.env.VENDURE_DISABLE_TELEMETRY?.toLowerCase();
+    return disableEnv === 'true' || disableEnv === '1' || isCI();
+}

+ 80 - 0
packages/core/src/telemetry/helpers/range-bucket.helper.spec.ts

@@ -0,0 +1,80 @@
+import { describe, expect, it } from 'vitest';
+
+import { toRangeBucket } from './range-bucket.helper';
+
+describe('toRangeBucket()', () => {
+    describe('boundary values', () => {
+        it('returns "0" for count of 0', () => {
+            expect(toRangeBucket(0)).toBe('0');
+        });
+
+        it('returns "1-100" for count of 1', () => {
+            expect(toRangeBucket(1)).toBe('1-100');
+        });
+
+        it('returns "1-100" for count of 100', () => {
+            expect(toRangeBucket(100)).toBe('1-100');
+        });
+
+        it('returns "101-1k" for count of 101', () => {
+            expect(toRangeBucket(101)).toBe('101-1k');
+        });
+
+        it('returns "101-1k" for count of 1000', () => {
+            expect(toRangeBucket(1000)).toBe('101-1k');
+        });
+
+        it('returns "1k-10k" for count of 1001', () => {
+            expect(toRangeBucket(1001)).toBe('1k-10k');
+        });
+
+        it('returns "1k-10k" for count of 10000', () => {
+            expect(toRangeBucket(10000)).toBe('1k-10k');
+        });
+
+        it('returns "10k-100k" for count of 10001', () => {
+            expect(toRangeBucket(10001)).toBe('10k-100k');
+        });
+
+        it('returns "10k-100k" for count of 100000', () => {
+            expect(toRangeBucket(100000)).toBe('10k-100k');
+        });
+
+        it('returns "100k+" for count of 100001', () => {
+            expect(toRangeBucket(100001)).toBe('100k+');
+        });
+    });
+
+    describe('values within each bucket', () => {
+        it('handles counts within "1-100" range', () => {
+            expect(toRangeBucket(50)).toBe('1-100');
+        });
+
+        it('handles counts within "101-1k" range', () => {
+            expect(toRangeBucket(500)).toBe('101-1k');
+        });
+
+        it('handles counts within "1k-10k" range', () => {
+            expect(toRangeBucket(5000)).toBe('1k-10k');
+        });
+
+        it('handles counts within "10k-100k" range', () => {
+            expect(toRangeBucket(50000)).toBe('10k-100k');
+        });
+
+        it('handles counts within "100k+" range', () => {
+            expect(toRangeBucket(1000000)).toBe('100k+');
+        });
+    });
+
+    describe('edge cases', () => {
+        // Note: Negative counts are not expected in normal usage (entity counts
+        // should never be negative). The function treats negative numbers as
+        // falling into the "1-100" bucket since they satisfy `count <= 100`.
+        // This documents the current behavior rather than prescribing it.
+        it('treats negative numbers as falling into "1-100" bucket', () => {
+            expect(toRangeBucket(-1)).toBe('1-100');
+            expect(toRangeBucket(-100)).toBe('1-100');
+        });
+    });
+});

+ 15 - 0
packages/core/src/telemetry/helpers/range-bucket.helper.ts

@@ -0,0 +1,15 @@
+import { RangeBucket } from '../telemetry.types';
+
+/**
+ * Converts an exact count to a range bucket for privacy.
+ * This prevents exposing exact entity counts while still
+ * providing useful aggregate data.
+ */
+export function toRangeBucket(count: number): RangeBucket {
+    if (count === 0) return '0';
+    if (count <= 100) return '1-100';
+    if (count <= 1000) return '101-1k';
+    if (count <= 10000) return '1k-10k';
+    if (count <= 100000) return '10k-100k';
+    return '100k+';
+}

+ 48 - 0
packages/core/src/telemetry/telemetry.module.ts

@@ -0,0 +1,48 @@
+import { Module } from '@nestjs/common';
+
+import { ConfigModule } from '../config/config.module';
+import { ConnectionModule } from '../connection/connection.module';
+import { JobQueueModule } from '../job-queue/job-queue.module';
+import { ProcessContextModule } from '../process-context/process-context.module';
+import { SettingsStoreService } from '../service/helpers/settings-store/settings-store.service';
+
+import { ConfigCollector } from './collectors/config.collector';
+import { DatabaseCollector } from './collectors/database.collector';
+import { DeploymentCollector } from './collectors/deployment.collector';
+import { InstallationIdCollector } from './collectors/installation-id.collector';
+import { PluginCollector } from './collectors/plugin.collector';
+import { SystemInfoCollector } from './collectors/system-info.collector';
+import { TelemetryService } from './telemetry.service';
+
+/**
+ * @description
+ * The TelemetryModule provides anonymous usage data collection for Vendure.
+ * It collects data on application startup and sends it to the Vendure telemetry endpoint.
+ *
+ * **Privacy guarantees:**
+ * - Installation ID is a random UUID
+ * - Custom plugin names are NOT collected
+ * - Entity counts use ranges, not exact numbers
+ * - No PII is collected
+ *
+ * **Opt-out:**
+ * Set `VENDURE_DISABLE_TELEMETRY=true` to disable.
+ *
+ * @docsCategory Telemetry
+ * @since 3.6.0
+ */
+@Module({
+    imports: [ProcessContextModule, ConfigModule, ConnectionModule, JobQueueModule],
+    providers: [
+        TelemetryService,
+        SettingsStoreService,
+        InstallationIdCollector,
+        SystemInfoCollector,
+        DatabaseCollector,
+        PluginCollector,
+        ConfigCollector,
+        DeploymentCollector,
+    ],
+    exports: [TelemetryService],
+})
+export class TelemetryModule {}

+ 352 - 0
packages/core/src/telemetry/telemetry.service.spec.ts

@@ -0,0 +1,352 @@
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+
+import { ProcessContext } from '../process-context/process-context';
+import { VENDURE_VERSION } from '../version';
+
+import { ConfigCollector } from './collectors/config.collector';
+import { DatabaseCollector } from './collectors/database.collector';
+import { DeploymentCollector } from './collectors/deployment.collector';
+import { InstallationIdCollector } from './collectors/installation-id.collector';
+import { PluginCollector } from './collectors/plugin.collector';
+import { SystemInfoCollector } from './collectors/system-info.collector';
+import { TelemetryService } from './telemetry.service';
+
+vi.mock('./helpers/ci-detector.helper', () => ({
+    isCI: vi.fn(),
+}));
+
+describe('TelemetryService', () => {
+    let service: TelemetryService;
+    let mockProcessContext: Record<string, any>;
+    let mockInstallationIdCollector: Partial<InstallationIdCollector>;
+    let mockSystemInfoCollector: Partial<SystemInfoCollector>;
+    let mockDatabaseCollector: Partial<DatabaseCollector>;
+    let mockPluginCollector: Partial<PluginCollector>;
+    let mockConfigCollector: Partial<ConfigCollector>;
+    let mockDeploymentCollector: Partial<DeploymentCollector>;
+    let mockIsCI: ReturnType<typeof vi.fn>;
+    let mockFetch: ReturnType<typeof vi.fn>;
+
+    const originalEnv = { ...process.env };
+
+    function createService() {
+        return new TelemetryService(
+            mockProcessContext as ProcessContext,
+            mockInstallationIdCollector as InstallationIdCollector,
+            mockSystemInfoCollector as SystemInfoCollector,
+            mockDatabaseCollector as DatabaseCollector,
+            mockPluginCollector as PluginCollector,
+            mockConfigCollector as ConfigCollector,
+            mockDeploymentCollector as DeploymentCollector,
+        );
+    }
+
+    beforeEach(async () => {
+        vi.resetAllMocks();
+        vi.useFakeTimers();
+
+        // Setup fetch mock
+        mockFetch = vi.fn().mockResolvedValue({ ok: true });
+        vi.stubGlobal('fetch', mockFetch);
+
+        // Setup CI mock
+        const ciModule = await import('./helpers/ci-detector.helper.js');
+        mockIsCI = vi.mocked(ciModule.isCI);
+        mockIsCI.mockReturnValue(false);
+
+        // Clear telemetry env vars
+        delete process.env.VENDURE_DISABLE_TELEMETRY;
+        delete process.env.NODE_ENV;
+
+        mockProcessContext = {
+            isWorker: false,
+        };
+
+        mockInstallationIdCollector = {
+            collect: vi.fn().mockResolvedValue('test-installation-id'),
+        };
+
+        mockSystemInfoCollector = {
+            collect: vi.fn().mockReturnValue({
+                nodeVersion: 'v20.0.0',
+                platform: 'linux x64',
+            }),
+        };
+
+        mockDatabaseCollector = {
+            collect: vi.fn().mockResolvedValue({
+                databaseType: 'postgres',
+                metrics: {
+                    entities: { Product: '1-100' },
+                    custom: { entityCount: 0 },
+                },
+            }),
+        };
+
+        mockPluginCollector = {
+            collect: vi.fn().mockReturnValue({
+                npm: ['@vendure/core'],
+                customCount: 0,
+            }),
+        };
+
+        mockConfigCollector = {
+            collect: vi.fn().mockReturnValue({
+                assetStorageType: 'LocalAssetStorageStrategy',
+                jobQueueType: 'InMemoryJobQueueStrategy',
+            }),
+        };
+
+        mockDeploymentCollector = {
+            collect: vi.fn().mockReturnValue({
+                containerized: false,
+                workerMode: 'integrated',
+                serverless: false,
+            }),
+        };
+
+        service = createService();
+    });
+
+    afterEach(() => {
+        process.env = { ...originalEnv };
+        vi.unstubAllGlobals();
+        vi.resetAllMocks();
+        vi.useRealTimers();
+    });
+
+    describe('onApplicationBootstrap()', () => {
+        describe('skip conditions', () => {
+            it('skips when worker process', async () => {
+                mockProcessContext.isWorker = true;
+                service = createService();
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).not.toHaveBeenCalled();
+            });
+
+            it('skips when VENDURE_DISABLE_TELEMETRY=true', async () => {
+                process.env.VENDURE_DISABLE_TELEMETRY = 'true';
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).not.toHaveBeenCalled();
+            });
+
+            it('skips when VENDURE_DISABLE_TELEMETRY=1', async () => {
+                process.env.VENDURE_DISABLE_TELEMETRY = '1';
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).not.toHaveBeenCalled();
+            });
+
+            it('does not skip when VENDURE_DISABLE_TELEMETRY=false', async () => {
+                process.env.VENDURE_DISABLE_TELEMETRY = 'false';
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).toHaveBeenCalled();
+            });
+
+            it('skips in CI environments', async () => {
+                mockIsCI.mockReturnValue(true);
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).not.toHaveBeenCalled();
+            });
+        });
+
+        describe('collector invocation', () => {
+            it('calls all collectors when telemetry is enabled', async () => {
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockInstallationIdCollector.collect).toHaveBeenCalled();
+                expect(mockSystemInfoCollector.collect).toHaveBeenCalled();
+                expect(mockDatabaseCollector.collect).toHaveBeenCalled();
+                expect(mockPluginCollector.collect).toHaveBeenCalled();
+                expect(mockConfigCollector.collect).toHaveBeenCalled();
+                expect(mockDeploymentCollector.collect).toHaveBeenCalled();
+            });
+
+            it('still calls remaining collectors after one fails', async () => {
+                mockDatabaseCollector.collect = vi.fn().mockRejectedValue(new Error('DB error'));
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                // The error is caught, so we won't see fetch called, but we can verify
+                // the error doesn't propagate
+                expect(() => service.onApplicationBootstrap()).not.toThrow();
+            });
+        });
+
+        describe('endpoint configuration', () => {
+            it('sends payload to default endpoint', async () => {
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).toHaveBeenCalledWith(
+                    'https://telemetry.vendure.io/api/v1/collect',
+                    expect.objectContaining({
+                        method: 'POST',
+                        headers: { 'Content-Type': 'application/json' },
+                    }),
+                );
+            });
+
+            it('always uses the hardcoded telemetry endpoint', async () => {
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                expect(mockFetch).toHaveBeenCalledWith(
+                    'https://telemetry.vendure.io/api/v1/collect',
+                    expect.any(Object),
+                );
+            });
+        });
+
+        describe('payload structure', () => {
+            it('sends correct payload with all required fields', async () => {
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                const fetchCall = mockFetch.mock.calls[0];
+                const body = JSON.parse(fetchCall[1].body);
+
+                expect(body.installationId).toBe('test-installation-id');
+                expect(body.nodeVersion).toBe('v20.0.0');
+                expect(body.databaseType).toBe('postgres');
+                expect(body.platform).toBe('linux x64');
+                expect(body.vendureVersion).toBe(VENDURE_VERSION);
+            });
+
+            it('includes timestamp as valid ISO string', async () => {
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                const fetchCall = mockFetch.mock.calls[0];
+                const body = JSON.parse(fetchCall[1].body);
+
+                expect(body.timestamp).toBeDefined();
+                // Verify it's a valid ISO date string
+                const date = new Date(body.timestamp);
+                expect(date.toISOString()).toBe(body.timestamp);
+            });
+
+            it('includes NODE_ENV as environment field', async () => {
+                process.env.NODE_ENV = 'production';
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                const fetchCall = mockFetch.mock.calls[0];
+                const body = JSON.parse(fetchCall[1].body);
+
+                expect(body.environment).toBe('production');
+            });
+
+            it('handles undefined NODE_ENV gracefully', async () => {
+                delete process.env.NODE_ENV;
+
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                const fetchCall = mockFetch.mock.calls[0];
+                const body = JSON.parse(fetchCall[1].body);
+
+                expect(body.environment).toBeUndefined();
+            });
+        });
+
+        describe('timeout handling', () => {
+            it('uses AbortController for timeout', async () => {
+                service.onApplicationBootstrap();
+                await flushPromises();
+
+                const fetchCall = mockFetch.mock.calls[0];
+                expect(fetchCall[1].signal).toBeInstanceOf(AbortSignal);
+            });
+
+            it('aborts request after 5 seconds', async () => {
+                let abortSignal: AbortSignal | undefined;
+                mockFetch.mockImplementation((_url: string, options: { signal: AbortSignal }) => {
+                    abortSignal = options.signal;
+                    // Return a promise that never resolves (simulating slow network)
+                    // eslint-disable-next-line @typescript-eslint/no-empty-function
+                    return new Promise(() => {});
+                });
+
+                service.onApplicationBootstrap();
+
+                // First, advance past the initial 5-second delay to trigger sendTelemetry
+                await vi.advanceTimersByTimeAsync(5000);
+
+                // Then advance past the 5-second abort timeout
+                await vi.advanceTimersByTimeAsync(5000);
+
+                expect(abortSignal?.aborted).toBe(true);
+            });
+        });
+
+        describe('graceful shutdown', () => {
+            it('clears the delay timeout on shutdown before telemetry fires', async () => {
+                service.onApplicationBootstrap();
+
+                // Shutdown before the 5s delay elapses
+                service.onApplicationShutdown();
+
+                // Advance past the delay
+                await vi.advanceTimersByTimeAsync(6000);
+
+                expect(mockFetch).not.toHaveBeenCalled();
+            });
+
+            it('onApplicationShutdown is safe to call when no timeout is pending', () => {
+                expect(() => service.onApplicationShutdown()).not.toThrow();
+            });
+        });
+
+        describe('error handling', () => {
+            it('silently ignores fetch network errors', async () => {
+                mockFetch.mockRejectedValue(new Error('Network error'));
+
+                expect(() => service.onApplicationBootstrap()).not.toThrow();
+                await flushPromises();
+            });
+
+            it('silently ignores collector errors', async () => {
+                mockDatabaseCollector.collect = vi.fn().mockRejectedValue(new Error('DB error'));
+
+                expect(() => service.onApplicationBootstrap()).not.toThrow();
+                await flushPromises();
+            });
+
+            it('silently ignores abort errors', async () => {
+                mockFetch.mockImplementation(() => {
+                    const error = new Error('The operation was aborted');
+                    error.name = 'AbortError';
+                    return Promise.reject(error);
+                });
+
+                expect(() => service.onApplicationBootstrap()).not.toThrow();
+                await flushPromises();
+            });
+        });
+    });
+});
+
+async function flushPromises() {
+    // Advance timers to trigger the 5-second telemetry delay
+    await vi.advanceTimersByTimeAsync(5000);
+    // Flush microtasks
+    await vi.runAllTimersAsync();
+}

+ 143 - 0
packages/core/src/telemetry/telemetry.service.ts

@@ -0,0 +1,143 @@
+import { Injectable, OnApplicationBootstrap, OnApplicationShutdown } from '@nestjs/common';
+
+import { ProcessContext } from '../process-context/process-context';
+import { VENDURE_VERSION } from '../version';
+
+import { ConfigCollector } from './collectors/config.collector';
+import { DatabaseCollector } from './collectors/database.collector';
+import { DeploymentCollector } from './collectors/deployment.collector';
+import { InstallationIdCollector } from './collectors/installation-id.collector';
+import { PluginCollector } from './collectors/plugin.collector';
+import { SystemInfoCollector } from './collectors/system-info.collector';
+import { isTelemetryDisabled } from './helpers/is-telemetry-disabled.helper';
+import { TelemetryPayload } from './telemetry.types';
+
+const TELEMETRY_ENDPOINT = 'https://telemetry.vendure.io/api/v1/collect';
+const TELEMETRY_TIMEOUT_MS = 5000;
+
+/**
+ * @description
+ * The TelemetryService collects anonymous usage data on Vendure application startup
+ * and sends it to the Vendure telemetry endpoint. This data helps the Vendure team
+ * understand how the framework is being used and prioritize development efforts.
+ *
+ * **Privacy guarantees:**
+ * - Installation ID is a random UUID, not derived from any system information
+ * - Custom plugin names are NOT collected (only count)
+ * - Entity counts use ranges, not exact numbers
+ * - No PII (no hostnames, IPs, user data) is collected
+ * - All failures are silently ignored
+ *
+ * **Opt-out:**
+ * Set the environment variable `VENDURE_DISABLE_TELEMETRY=true` to disable telemetry.
+ *
+ * **CI environments:**
+ * Telemetry is automatically disabled in CI environments.
+ *
+ * @docsCategory Telemetry
+ * @since 3.6.0
+ */
+@Injectable()
+export class TelemetryService implements OnApplicationBootstrap, OnApplicationShutdown {
+    private delayTimeout: ReturnType<typeof setTimeout> | undefined;
+
+    constructor(
+        private readonly processContext: ProcessContext,
+        private readonly installationIdCollector: InstallationIdCollector,
+        private readonly systemInfoCollector: SystemInfoCollector,
+        private readonly databaseCollector: DatabaseCollector,
+        private readonly pluginCollector: PluginCollector,
+        private readonly configCollector: ConfigCollector,
+        private readonly deploymentCollector: DeploymentCollector,
+    ) {}
+
+    onApplicationBootstrap() {
+        // Skip if worker process - only run from server
+        if (this.processContext.isWorker) {
+            return;
+        }
+
+        // Skip if disabled or CI environment
+        if (isTelemetryDisabled()) {
+            return;
+        }
+
+        // Delay telemetry collection to allow user bootstrap code to complete
+        // This ensures JobQueueService.start() has been called (if it will be)
+        // before we check worker mode
+        this.delayTimeout = setTimeout(() => {
+            this.delayTimeout = undefined;
+            this.sendTelemetry().catch(() => {
+                // Silently ignore all errors
+            });
+        }, 5000);
+    }
+
+    onApplicationShutdown() {
+        if (this.delayTimeout) {
+            clearTimeout(this.delayTimeout);
+            this.delayTimeout = undefined;
+        }
+    }
+
+    /**
+     * Collects and sends telemetry data.
+     */
+    private async sendTelemetry(): Promise<void> {
+        const payload = await this.collectPayload();
+        await this.send(payload);
+    }
+
+    /**
+     * Collects all telemetry data from the various collectors.
+     */
+    private async collectPayload(): Promise<TelemetryPayload> {
+        const installationId = await this.installationIdCollector.collect();
+        const databaseInfo = await this.databaseCollector.collect();
+
+        const systemInfo = this.systemInfoCollector.collect();
+        const plugins = this.pluginCollector.collect();
+        const config = this.configCollector.collect();
+        const deployment = this.deploymentCollector.collect();
+
+        return {
+            // Required fields
+            installationId,
+            timestamp: new Date().toISOString(),
+            vendureVersion: VENDURE_VERSION,
+            nodeVersion: systemInfo.nodeVersion,
+            databaseType: databaseInfo.databaseType,
+
+            // Optional fields
+            environment: process.env.NODE_ENV,
+            platform: systemInfo.platform,
+            plugins,
+            metrics: databaseInfo.metrics,
+            deployment,
+            config,
+        };
+    }
+
+    /**
+     * Sends the telemetry payload to the collection endpoint.
+     * Uses a 5-second timeout to prevent blocking.
+     */
+    private async send(payload: TelemetryPayload): Promise<void> {
+        const endpoint = TELEMETRY_ENDPOINT;
+        const controller = new AbortController();
+        const timeoutId = setTimeout(() => controller.abort(), TELEMETRY_TIMEOUT_MS);
+
+        try {
+            await fetch(endpoint, {
+                method: 'POST',
+                headers: {
+                    'Content-Type': 'application/json',
+                },
+                body: JSON.stringify(payload),
+                signal: controller.signal,
+            });
+        } finally {
+            clearTimeout(timeoutId);
+        }
+    }
+}

+ 78 - 0
packages/core/src/telemetry/telemetry.types.ts

@@ -0,0 +1,78 @@
+import { DataSourceOptions } from 'typeorm';
+
+/**
+ * Range buckets for anonymizing entity counts
+ */
+export type RangeBucket = '0' | '1-100' | '101-1k' | '1k-10k' | '10k-100k' | '100k+';
+
+/**
+ * Supported database types for Vendure telemetry.
+ * Derived from TypeORM's DataSourceOptions for type safety.
+ * Note: 'better-sqlite3' is normalized to 'sqlite' in the collector.
+ */
+export type SupportedDatabaseType =
+    | Extract<DataSourceOptions['type'], 'postgres' | 'mysql' | 'mariadb' | 'sqlite'>
+    | 'other';
+
+/**
+ * Information about plugins used in the Vendure installation
+ */
+export interface TelemetryPluginInfo {
+    /** Names of detected npm packages (official Vendure and third-party plugins) */
+    npm: string[];
+    /** Count of custom plugins (names are NOT collected for privacy) */
+    customCount: number;
+}
+
+/**
+ * Entity count metrics using range buckets for privacy
+ */
+export interface TelemetryEntityMetrics {
+    entities: Partial<Record<string, RangeBucket>>;
+    custom: {
+        entityCount: number;
+        totalRecords?: RangeBucket;
+    };
+}
+
+/**
+ * Deployment environment information
+ */
+export interface TelemetryDeployment {
+    containerized?: boolean;
+    cloudProvider?: string;
+    workerMode?: 'integrated' | 'separate';
+    serverless?: boolean;
+}
+
+/**
+ * Configuration snapshot (strategy class names only, no sensitive data)
+ */
+export interface TelemetryConfig {
+    assetStorageType?: string;
+    jobQueueType?: string;
+    entityIdStrategy?: string;
+    defaultLanguage?: string;
+    customFieldsCount?: number;
+    authenticationMethods?: string[];
+}
+
+/**
+ * Full telemetry payload sent to the collection endpoint
+ */
+export interface TelemetryPayload {
+    // Required fields
+    installationId: string;
+    timestamp: string;
+    vendureVersion: string;
+    nodeVersion: string;
+    databaseType: SupportedDatabaseType;
+
+    // Optional fields
+    environment?: string;
+    platform?: string;
+    plugins?: TelemetryPluginInfo;
+    metrics?: TelemetryEntityMetrics;
+    deployment?: TelemetryDeployment;
+    config?: TelemetryConfig;
+}

+ 1 - 0
packages/create/templates/gitignore.template

@@ -3,3 +3,4 @@ dist
 admin-ui
 static/assets
 static/email/test-emails
+.vendure/