Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
85 changes: 85 additions & 0 deletions TELEMETRY.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
# ProductOS Telemetry

ProductOS can collect anonymous product-usage events so maintainers can understand which capabilities are used, what breaks, and what to improve next — without inspecting your work.

Telemetry is privacy-preserving and allowlisted at the source. If you do not want to send anything, see [Turning it off](#turning-it-off).

## What every event includes

- A random per-install UUID (`installId`) generated locally on first telemetry write and stored under the ProductOS app-data directory in `telemetry/telemetry.json`.
- ProductOS version, runtime (`node`), Node.js version, OS, and CPU architecture.
- The event name, from the fixed allowlist below.
- A small structured payload, restricted to per-event allowlisted keys.

## What ProductOS never sends

ProductOS telemetry never sends file contents, file paths, project names, prompts, AI outputs, API keys, secrets, environment variables, stack traces, or arbitrary error messages.

Payload values are limited to strings, numbers, and booleans. String values are capped at 256 characters, and unknown payload keys are stripped.

## Google Analytics Integration & Offline Queuing

To secure, aggregate, and streamline telemetry processing, ProductOS uses **Google Analytics 4 (GA4)** on the client side.

When usage analytics are enabled, client-side event triggers route anonymously and securely to Google Analytics. Anonymized Tech metadata (e.g. browser/OS details, country-level geolocation) is automatically collected as part of Google Analytics standard telemetry protocol. All data is processed in accordance with the [Google Analytics Privacy & Terms](https://policies.google.com/privacy).

### Client-Side Offline Queuing

If you are offline or if there is a network error when sending events to Google Analytics, ProductOS automatically queues events locally in the browser's `localStorage` (capped at 100 events). These queued events are securely and automatically flushed to Google Analytics once you are back online or when the application restarts.

## Event allowlist
Comment thread
AssafMiron marked this conversation as resolved.

| Event | Payload keys |
| --- | --- |
| `app.launched` | — |
| `app.exited` | `source` |
| `installation.status_checked` | `isFirstInstall` |
| `installation.started` | `source` |
| `installation.completed` | `success`, `durationMs` |
| `onboarding.step` | `step` |
| `onboarding.completed` | `provider`, `starterPack` |
| `settings.telemetry_changed` | `enabled` |
| `settings.saved` | `section` |
| `usage.viewed` | `scope` |
| `provider.detected` | `provider`, `success`, `durationMs` |
| `provider.selected` | `provider`, `source` |
| `agent.run.started` | `provider`, `source` |
| `agent.run.completed` | `provider`, `success`, `durationMs`, `tokensIn`, `tokensOut` |
| `agent.run.failed` | `provider`, `durationMs`, `errorCode` |
| `project.created` | `source` |
| `project.deleted` | `source` |
| `artifact.created` | `artifactType`, `source` |
| `artifact.saved` | `artifactType` |
| `artifact.imported` | `artifactType` |
| `artifact.exported` | `artifactType`, `exportFormat` |
| `skill.created` | `source` |
| `skill.updated` | `source` |
| `skill.imported` | `source` |
| `custom_cli.added` | `provider` |
| `integrations.enabled` | `channel` |
| `workflow.created` | `stepCount` |
| `workflow.started` | `stepCount`, `trigger` |
| `workflow.completed` | `status`, `durationMs`, `stepCount` |
| `error.unhandled` | `where`, `errorCode` |

The source allowlist lives in `node-backend/lib/telemetry/catalog.mjs`.



## Turning it off

You can turn it off in the app:

```text
Settings → System → Privacy & Analytics → Anonymous Usage Analytics off
```

That preference is saved in `settings.json` as:

```json
{
"telemetry": {
"enabled": false
}
}
```
8 changes: 8 additions & 0 deletions node-backend/lib/orchestrator.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import { CostLog } from './cost.mjs';
import { getProjectById } from './projects.mjs';
import * as ArtifactService from './artifacts.mjs';
import { ChannelService } from './channels.mjs';
import { trackTelemetry } from './telemetry/index.mjs';
import path from 'node:path';

function providerSetupGuidance(providerId, settings = {}) {
Expand Down Expand Up @@ -212,6 +213,13 @@ export class AgentOrchestrator {
if (artifactChanges.length > 0) {
this.emit('trace-log', `Creating ${artifactChanges.length} detected artifacts...`);
await OutputParserService.applyArtifactChanges(projectId, artifactChanges, ArtifactService);
for (const change of artifactChanges) {
try {
await trackTelemetry('artifact.created', { artifactType: change.artifactType || change.artifact_type, source: 'agent' }, settings);
} catch (error) {
this.emit('trace-log', `WARN: telemetry artifact.created failed: ${error?.message || error}`);
}
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
this.emit('artifacts-changed', { projectId });
}

Expand Down
41 changes: 41 additions & 0 deletions node-backend/lib/telemetry/catalog.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
export const TELEMETRY_EVENTS = Object.freeze({
'app.launched': [],
'app.exited': ['source'],
'installation.status_checked': ['isFirstInstall'],
'installation.started': ['source'],
'installation.completed': ['success', 'durationMs'],
'onboarding.step': ['step'],
'onboarding.completed': ['provider', 'starterPack'],
'settings.telemetry_changed': ['enabled'],
'settings.saved': ['section'],
'usage.viewed': ['scope'],
'provider.detected': ['provider', 'success', 'durationMs'],
'provider.selected': ['provider', 'source'],
'agent.run.started': ['provider', 'source'],
'agent.run.completed': ['provider', 'success', 'durationMs', 'tokensIn', 'tokensOut'],
'agent.run.failed': ['provider', 'durationMs', 'errorCode'],
'project.created': ['source'],
Comment thread
AssafMiron marked this conversation as resolved.
'project.deleted': ['source'],
'artifact.created': ['artifactType', 'source'],
'artifact.saved': ['artifactType'],
'artifact.imported': ['artifactType'],
'artifact.exported': ['artifactType', 'exportFormat'],
'skill.created': ['source'],
'skill.updated': ['source'],
'skill.imported': ['source'],
'custom_cli.added': ['provider'],
'integrations.enabled': ['channel'],
'workflow.created': ['stepCount'],
'workflow.started': ['stepCount', 'trigger'],
'workflow.completed': ['status', 'durationMs', 'stepCount'],
'error.unhandled': ['where', 'errorCode'],
'view.changed': ['view'],
'file.opened': [],
'file.closed': [],
'file.edited': [],
'chat.message_sent': [],
'chat.opened': [],
'chat.closed': [],
});

export const ALLOWED_EVENT_NAMES = new Set(Object.keys(TELEMETRY_EVENTS));
59 changes: 59 additions & 0 deletions node-backend/lib/telemetry/index.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import { EventEmitter } from 'node:events';
import { ALLOWED_EVENT_NAMES, TELEMETRY_EVENTS } from './catalog.mjs';

export const telemetryEmitter = new EventEmitter();

const MAX_VALUE_LENGTH = 256;

export function isTelemetryEnabled(settings = {}) {
if (settings?.telemetry?.enabled === false) return false;
return true;
}

function normalizeValue(value) {
if (typeof value === 'string') return value.slice(0, MAX_VALUE_LENGTH);
if (typeof value === 'number') return Number.isFinite(value) ? value : undefined;
if (typeof value === 'boolean') return value;
if (value == null) return undefined;
return String(value).slice(0, MAX_VALUE_LENGTH);
}

export function sanitizeEvent(name, payload = {}) {
if (!ALLOWED_EVENT_NAMES.has(name)) return null;
const allowedKeys = TELEMETRY_EVENTS[name] || [];
const sanitizedPayload = {};

for (const key of allowedKeys) {
const value = normalizeValue(payload[key]);
if (value !== undefined) sanitizedPayload[key] = value;
}

return { name, payload: sanitizedPayload };
}

function errorCode(error) {
if (!error) return 'unknown';
if (typeof error.code === 'string') return error.code.slice(0, MAX_VALUE_LENGTH);
if (typeof error.name === 'string') return error.name.slice(0, MAX_VALUE_LENGTH);
return 'error';
}

export async function trackTelemetry(name, payload = {}, settings = {}) {
if (!isTelemetryEnabled(settings)) return false;

const sanitized = sanitizeEvent(name, payload);
if (!sanitized) return false;

try {
// Emit event for SSE broadcasting
telemetryEmitter.emit('event', { name: sanitized.name, payload: sanitized.payload });
return true;
} catch (error) {
console.warn('[telemetry] Failed to track event:', error.message);
return false;
}
}

export function telemetryErrorCode(error) {
return errorCode(error);
}
32 changes: 32 additions & 0 deletions node-backend/lib/workflows.mjs
Original file line number Diff line number Diff line change
@@ -1,8 +1,17 @@
import fs from 'node:fs/promises';
import path from 'node:path';
import { getProjectById } from './projects.mjs';
import { trackTelemetry, telemetryErrorCode } from './telemetry/index.mjs';
import { getWorkflowsMetadataDir, getDotWorkflowsDir } from './paths.mjs';

async function safeTrackTelemetry(eventName, payload, settings) {
try {
await trackTelemetry(eventName, payload, settings);
} catch (error) {
console.warn(`[Workflows] telemetry ${eventName} failed:`, error?.message || error);
}
}

async function fileExists(target) {
try {
await fs.access(target);
Expand Down Expand Up @@ -222,6 +231,9 @@ export async function executeWorkflow(projectId, workflowId, orchestrator, setti
try {
// Simplified execution: if it's an agent/skill step, run it
if (step.step_type === 'Agent' || step.step_type === 'Skill' || step.step_type === 'Prompt') {
const provider = settings.activeProvider || settings.active_provider || 'hostedApi';
const agentStarted = Date.now();
await safeTrackTelemetry('agent.run.started', { provider, source: 'workflow' }, settings);
const result = await orchestrator.runAgentLoop({
messages: [{ role: 'user', content: step.config?.prompt || workflow.description }],
projectId,
Expand All @@ -231,6 +243,14 @@ export async function executeWorkflow(projectId, workflowId, orchestrator, setti
run.step_results[step.id].status = 'Completed';
run.step_results[step.id].completed = new Date().toISOString();
run.step_results[step.id].output = result.content;
const modelUsed = result?.metadata?.model_used;
await safeTrackTelemetry('agent.run.completed', {
provider,
success: !!modelUsed && modelUsed !== 'error' && modelUsed !== 'aborted',
durationMs: Date.now() - agentStarted,
tokensIn: result?.metadata?.tokens_in || 0,
tokensOut: result?.metadata?.tokens_out || 0,
}, settings);
} else {
// Other steps are handled as no-op for now
run.step_results[step.id].status = 'Completed';
Expand All @@ -239,6 +259,12 @@ export async function executeWorkflow(projectId, workflowId, orchestrator, setti
} catch (stepError) {
run.step_results[step.id].status = 'Failed';
run.step_results[step.id].error = stepError.message;
const stepStartedAt = Date.parse(run.step_results[step.id].started || '');
await safeTrackTelemetry('agent.run.failed', {
provider: settings.activeProvider || settings.active_provider || 'hostedApi',
durationMs: Number.isFinite(stepStartedAt) ? Math.max(0, Date.now() - stepStartedAt) : 0,
errorCode: telemetryErrorCode(stepError),
}, settings);
throw stepError;
}
}
Expand All @@ -264,6 +290,12 @@ export async function executeWorkflow(projectId, workflowId, orchestrator, setti
workflow.last_run = run.completed;
await saveWorkflow(workflow);

await safeTrackTelemetry('workflow.completed', {
status: run.status,
durationMs: run.completed ? new Date(run.completed).getTime() - new Date(run.started).getTime() : 0,
stepCount: Array.isArray(workflow.steps) ? workflow.steps.length : 0,
}, settings);

if (broadcast) {
broadcast('workflow-finished', {
project_id: projectId,
Expand Down
Loading
Loading