Repository navigation
feat: add privacy-safe usage telemetry #173
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
93e4d42
feat: add privacy-safe usage telemetry
aviatam 4bef385
fix: make telemetry best-effort
aviatam 7a891de
feat: integrate react-ga4 client-side telemetry and resolve PR #173 c…
AssafMiron 3728780
refactor: remove local filesystem telemetry persistence and batching …
AssafMiron 4d96a5f
refactor(telemetry): remove legacy backend queuing/flushing and imple…
AssafMiron 8f25920
feat(telemetry): update measurement ID to new dedicated product property
AssafMiron 0b69903
feat: implement persistent install ID tracking for Google Analytics i…
AssafMiron b222bb2
feat(telemetry): implement anonymized tech overview and user engageme…
AssafMiron File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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 | ||
|
|
||
| | 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 | ||
| } | ||
| } | ||
| ``` | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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'], | ||
|
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)); | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| 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); | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.