Skip to content
Open
55 changes: 42 additions & 13 deletions docs/docs/guides/developer-guide/telemetry/index.mdx
Original file line number Diff line number Diff line change
@@ -1,15 +1,19 @@
---
title: "Telemetry"
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.

## When Data is Collected

An initial telemetry event is sent shortly after the server starts up. After that, a lightweight "heartbeat" event is sent roughly every 24 hours for as long as the server keeps running (a small random delay is added to each heartbeat so that many servers restarted together do not all report at the same instant). Each event contains the same categories of data, collected fresh at send time.

## What Data is Collected

The following data is collected once per server startup:
The following data is included in each telemetry event:

### Installation ID

Expand All @@ -25,6 +29,14 @@ A randomly generated UUID that identifies your Vendure installation. This ID is:
- 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`)
- Process uptime in seconds at the time of the event

### Runtime and Hardware Shape

- JavaScript runtime (`node`, `bun`, or `deno`)
- Package manager used to launch the process (`npm`, `pnpm`, `yarn`, `bun`, or `unknown`)
- Whether the process is running under `ts-node`
- Number of logical CPUs and total system memory (rounded to whole gigabytes)

### Database Type

Expand All @@ -45,17 +57,25 @@ The type of database being used:

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 |
| 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 |
| `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.

#### Order Lifecycle Metrics

Range-bucketed counts (never exact numbers) describing the order mix: orders that have been placed, orders that are still active, draft orders, orders placed in the last 30 days, and a breakdown by order type (`Regular`, `Seller`, `Aggregate` only — arbitrary values are never reported).

#### Internationalization Breadth

The number of distinct language codes and distinct currency codes configured across all channels. These are simple counts — the actual language/currency codes are not sent.

### Deployment Information

- Whether the server is running in a container (Docker, Kubernetes)
Expand All @@ -65,7 +85,7 @@ For custom entities, only the count of custom entity types is collected (not the

### Configuration

Only **class names** of configured strategies are collected, not their configuration values:
A non-sensitive summary of the configuration is collected. This includes the **class names** of certain well-known built-in strategies, along with booleans, enums and counts derived from the config — never raw configuration values, secrets or credentials:

- Asset storage strategy class name (e.g., `LocalAssetStorageStrategy`, `S3AssetStorageStrategy`)
- Job queue strategy class name
Expand All @@ -74,16 +94,25 @@ Only **class names** of configured strategies are collected, not their configura
- Default language code
- Total count of custom fields

#### Security Posture

Booleans and short enums summarizing security-relevant configuration — for example whether GraphQL introspection, the API playground or API debug output are enabled, whether a wildcard CORS origin or `trustProxy` is in use, the configured token method(s) and cookie `secure`/`sameSite` flags, whether auth is disabled or email verification required, and whether the superadmin credentials are still the built-in defaults. Only the flags are sent — never the actual origins, secrets, or credential values.

#### Customized Strategies

For single-strategy configuration fields, telemetry records the **config path** of any strategy that differs from the framework default (e.g. `orderOptions.orderCodeStrategy`). Only the dotted path is sent — the class name of a custom strategy is deliberately **not** collected, so project-specific naming cannot leak.

## 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
- API keys, secrets, or credentials (including superadmin credential values)
- File paths or source code
- Configuration values (only strategy class names)
- CORS origin lists, API paths, or database connection details
- Custom strategy class names (only the config path that was customized)
- Any personally identifiable information (PII)

## How We Use This Data
Expand Down Expand Up @@ -117,7 +146,7 @@ Travis CI, CircleCI, GitHub Actions, GitLab CI, Jenkins, Bitbucket Pipelines, Az
## 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)
- **Timing**: An initial send ~5 seconds after startup, then a heartbeat roughly every 24 hours (plus a small random jitter). All sends are 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
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ generated: true
---
## S3AssetStorageStrategy

<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="155" packageName="@vendure/asset-server-plugin" />
<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="156" packageName="@vendure/asset-server-plugin" />

An [AssetStorageStrategy](/reference/typescript-api/assets/asset-storage-strategy#assetstoragestrategy) which uses [Amazon S3](https://aws.amazon.com/s3/) object storage service.
To us this strategy you must first have access to an AWS account.
Expand Down Expand Up @@ -86,7 +86,7 @@ class S3AssetStorageStrategy implements AssetStorageStrategy {
</div>
## S3Config

<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="19" packageName="@vendure/asset-server-plugin" />
<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="20" packageName="@vendure/asset-server-plugin" />

Configuration for connecting to AWS S3.

Expand Down Expand Up @@ -133,7 +133,7 @@ Using type `any` in order to avoid the need to include `aws-sdk` dependency in g
</div>
## configureS3AssetStorage

<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="119" packageName="@vendure/asset-server-plugin" />
<GenerationInfo sourceFile="packages/asset-server-plugin/src/config/s3-asset-storage-strategy.ts" sourceLine="120" packageName="@vendure/asset-server-plugin" />

Returns a configured instance of the [S3AssetStorageStrategy](/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy#s3assetstoragestrategy) which can then be passed to the [AssetServerOptions](/reference/core-plugins/asset-server-plugin/asset-server-options#assetserveroptions)`storageStrategyFactory` property.

Expand Down
21 changes: 17 additions & 4 deletions docs/docs/reference/typescript-api/telemetry/telemetry-service.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,30 @@
title: "TelemetryService"
generated: true
---
<GenerationInfo sourceFile="packages/core/src/telemetry/telemetry.service.ts" sourceLine="41" packageName="@vendure/core" since="3.6.0" />
<GenerationInfo sourceFile="packages/core/src/telemetry/telemetry.service.ts" sourceLine="61" 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
The TelemetryService collects anonymous usage data from Vendure applications 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.

**Timing:**
An initial event is sent shortly after startup, and a further "heartbeat" event
is then sent roughly every 24 hours for as long as the server is running.

**What is collected:**
Version and runtime information (Vendure/Node versions, platform, JS runtime,
package manager, CPU/memory shape), database type, plugin package names (custom
plugin names are never collected), range-bucketed entity counts including order
lifecycle and internationalization breadth, deployment/cloud detection, and a
configuration summary reduced to booleans, enums and strategy/customization
indicators. See the Telemetry guide for the full, authoritative list.

**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
- No PII (no hostnames, IPs, user data), secrets, credentials or file paths are collected
- Custom strategy class names are never sent (only the config paths that were customized)
- All failures are silently ignored

**Opt-out:**
Expand Down
6 changes: 3 additions & 3 deletions docs/manifest.json
Original file line number Diff line number Diff line change
Expand Up @@ -416,7 +416,7 @@
"title": "Telemetry",
"slug": "telemetry",
"file": "docs/guides/developer-guide/telemetry/index.mdx",
"lastModified": "2026-03-30T21:48:02+02:00"
"lastModified": "2026-07-08T11:54:44+02:00"
}
]
},
Expand Down Expand Up @@ -3352,7 +3352,7 @@
"title": "TelemetryService",
"slug": "telemetry-service",
"file": "docs/reference/typescript-api/telemetry/telemetry-service.mdx",
"lastModified": "2026-04-20T16:08:16+02:00"
"lastModified": "2026-07-08T09:56:24Z"
},
{
"title": "WrappedMethodArgs",
Expand Down Expand Up @@ -3548,7 +3548,7 @@
"title": "S3AssetStorageStrategy",
"slug": "s3asset-storage-strategy",
"file": "docs/reference/core-plugins/asset-server-plugin/s3asset-storage-strategy.mdx",
"lastModified": "2026-04-20T16:08:16+02:00"
"lastModified": "2026-07-08T09:33:32Z"
},
{
"title": "SharpAssetPreviewStrategy",
Expand Down
152 changes: 152 additions & 0 deletions packages/core/src/telemetry/collectors/config.collector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -690,5 +690,157 @@ describe('ConfigCollector', () => {
expect(result.hasCustomFulfillmentProcess).toBe(false);
});
});

describe('API / security posture', () => {
beforeEach(() => {
mockConfigService.apiOptions = {
introspection: false,
adminApiPlayground: true,
shopApiPlayground: false,
adminApiDebug: false,
shopApiDebug: false,
trustProxy: true,
cors: { origin: '*', credentials: true },
} as any;
mockConfigService.authOptions = {
...mockConfigService.authOptions,
tokenMethod: ['cookie', 'bearer'],
requireVerification: false,
disableAuth: true,
superadminCredentials: { identifier: 'superadmin', password: 'superadmin' },
cookieOptions: { secure: true, sameSite: 'strict' },
};
mockConfigService.settingsStoreFields = {
dashboard: [{ name: 'a' }, { name: 'b' }],
theme: [{ name: 'c' }],
} as any;
});

it('reduces api/auth posture to booleans and short enums', () => {
const result = collector.collect();

expect(result.apiIntrospectionEnabled).toBe(false);
expect(result.apiPlaygroundEnabled).toBe(true);
expect(result.apiDebugEnabled).toBe(false);
expect(result.trustProxyEnabled).toBe(true);
expect(result.corsWildcardOrigin).toBe(true);
expect(result.tokenMethods).toEqual(['cookie', 'bearer']);
expect(result.requireVerification).toBe(false);
expect(result.authDisabled).toBe(true);
expect(result.cookieSecure).toBe(true);
expect(result.cookieSameSite).toBe('strict');
expect(result.settingsStoreFieldCount).toBe(3);
});

it('introspection defaults to enabled when not explicitly disabled', () => {
mockConfigService.apiOptions = {} as any;
expect(collector.collect().apiIntrospectionEnabled).toBe(true);
});

it('treats cors: true as a wildcard origin', () => {
mockConfigService.apiOptions = { cors: true } as any;
expect(collector.collect().corsWildcardOrigin).toBe(true);
});

it('reports corsWildcardOrigin false when cors is disabled', () => {
mockConfigService.apiOptions = { cors: false } as any;
expect(collector.collect().corsWildcardOrigin).toBe(false);
});

it('reports corsWildcardOrigin false for a specific origin', () => {
mockConfigService.apiOptions = {
cors: { origin: 'https://example.com' },
} as any;
expect(collector.collect().corsWildcardOrigin).toBe(false);
});

it('normalizes a single tokenMethod string to an array', () => {
mockConfigService.authOptions = { tokenMethod: 'bearer' } as any;
expect(collector.collect().tokenMethods).toEqual(['bearer']);
});

it('detects the default superadmin credentials', () => {
expect(collector.collect().defaultSuperadminCredentials).toBe(true);
});

it('returns false and never reads the actual values for changed credentials', () => {
mockConfigService.authOptions = {
...mockConfigService.authOptions,
superadminCredentials: { identifier: 'admin', password: 'hunter2' },
};
expect(collector.collect().defaultSuperadminCredentials).toBe(false);
});
});

describe('customizedStrategies', () => {
it('flags only single-strategy fields that differ from the default config', () => {
mockConfigService.assetOptions = {
// Differs from the default NoAssetStorageStrategy
assetStorageStrategy: { constructor: { name: 'S3AssetStorageStrategy' } },
// Matches the default
assetNamingStrategy: { constructor: { name: 'DefaultAssetNamingStrategy' } },
assetPreviewStrategy: { constructor: { name: 'NoAssetPreviewStrategy' } },
} as any;

const result = collector.collect();

expect(result.customizedStrategies).toContain('assetOptions.assetStorageStrategy');
expect(result.customizedStrategies).not.toContain('assetOptions.assetNamingStrategy');
expect(result.customizedStrategies).not.toContain('assetOptions.assetPreviewStrategy');
});

it('emits dotted paths, never strategy class names', () => {
mockConfigService.assetOptions = {
assetStorageStrategy: { constructor: { name: 'SecretInternalStorageStrategy' } },
} as any;

const paths = collector.collect().customizedStrategies ?? [];

expect(paths).toContain('assetOptions.assetStorageStrategy');
for (const path of paths) {
expect(path).not.toContain('SecretInternalStorageStrategy');
}
});

it('detects a customized strategy set via the deprecated root-level entityIdStrategy', () => {
// entityOptions.entityIdStrategy is unset; the project configured the
// deprecated root-level entityIdStrategy instead. The fallback should
// still surface it as customized against the default.
mockConfigService.entityOptions = { moneyStrategy: undefined } as any;
(mockConfigService as any).entityIdStrategy = {
constructor: { name: 'CustomEntityIdStrategy' },
};

expect(collector.collect().customizedStrategies).toContain('entityOptions.entityIdStrategy');
});

it('does not flag entityIdStrategy when the root-level fallback matches the default', () => {
mockConfigService.entityOptions = {} as any;
(mockConfigService as any).entityIdStrategy = {
constructor: { name: 'AutoIncrementIdStrategy' },
};

expect(collector.collect().customizedStrategies).not.toContain(
'entityOptions.entityIdStrategy',
);
});

it('returns an empty array when no strategies are customized', () => {
// The base mock uses default-equivalent strategies for the fields it defines
mockConfigService.assetOptions = {
assetStorageStrategy: { constructor: { name: 'NoAssetStorageStrategy' } },
} as any;
mockConfigService.entityOptions = {
entityIdStrategy: { constructor: { name: 'AutoIncrementIdStrategy' } },
moneyStrategy: { constructor: { name: 'DefaultMoneyStrategy' } },
} as any;
mockConfigService.orderOptions = { orderSellerStrategy: undefined, process: [] } as any;
mockConfigService.taxOptions = {} as any;
mockConfigService.systemOptions = {} as any;
mockConfigService.jobQueueOptions = {} as any;

expect(collector.collect().customizedStrategies).toEqual([]);
});
});
});
});
Loading
Loading