mirror of
https://github.com/nexu-io/open-design.git
synced 2026-05-31 19:04:39 +07:00
feat(analytics): PostHog product analytics (P0 events, consent-gated, packaged) (#1428)
* feat(analytics): scaffold PostHog product-analytics integration
- Add @open-design/contracts/analytics subpath with the 17 P0 event
payload types, header constants, and code↔CSV enum mapping helpers.
- Add apps/daemon/src/analytics.ts with env-gated posthog-node client,
request-scoped analytics context reader, and artifact-id anonymizer.
- Expose GET /api/analytics/config so the web bundle never embeds the
PostHog key at build time; daemon owns POSTHOG_KEY / POSTHOG_HOST.
- Add apps/web/src/analytics module (identity + lazy posthog-js client
+ React provider) and mount it under <I18nProvider> in app/layout.
No event wiring yet — that lands in the next commit alongside trigger
points (App.tsx, EntryView, NewProjectPanel, SettingsDialog, FileViewer,
runs.ts).
* feat(analytics): wire app_launch, home_view, home_click, project_create_result
- App.tsx: fire app_launch once after first effect tick. handleCreateProject
now emits project_create_result on both success and failure paths.
- EntryView.tsx: home_view (page) gated on agents loading so
has_available_cli isn't transiently false; home_view (asset_panel) fires
per top-tab change with the right result_count.
- NewProjectPanel.tsx: home_click create_button fires before delegating to
the parent; a fresh request_id is generated here and threaded through
onCreate so the matching project_create_result stitches via $insert_id.
- contracts/analytics: tighten createTabToTracking and topTabToTracking
for the worktree branch's renamed tabs (live-artifact, templates).
* feat(analytics): wire settings_view + 3 settings_click events
- settings_view fires on dialog mount and on every section switch,
carrying the active section (mapped via settingsSectionToTracking
for the 16-section worktree layout), execution_mode, and the
selected CLI provider id when present.
- settings_click execution_mode_tab: setMode now emits before/after
values whenever the user toggles between Local CLI and BYOK.
- settings_click cli_provider_card: agent card onClick reports
cli_provider_id via agentIdToTracking (kiro → other).
- settings_click byok_field: onFocus added to api_key, model select,
and base_url inputs; provider_id widened to include google so the
worktree's Gemini protocol slot type-checks.
* feat(analytics): wire studio_view + studio_click chat, studio_view artifact
- packages/contracts/src/analytics/artifact-id.ts: FNV-1a 64-bit helper
produces a 16-hex anonymized id for (projectId, fileName). Stable
cross-platform so the daemon and the web bundle resolve the same id
without a Web Crypto round-trip; daemon now re-exports it.
- ChatComposer: studio_view chat_panel fires once per project mount,
studio_click chat_composer fires on attachment + send buttons with
estimated user_query_tokens (length/4) and has_attachment.
- FileViewer: studio_view artifact fires once per (project, file) at
the dispatcher level, before any sub-viewer renders, with
artifact_kind derived from the renderer registry / file.kind table.
- Widen TrackingExportFormat to include markdown and cloudflare_pages
so the worktree branch's full share menu can emit verbatim.
* feat(analytics): wire studio_click share_option + artifact_export_result
HtmlViewer's share menu now emits both events per click via a
fireShareExport helper:
- studio_click share_option fires immediately on click with the chosen
export_format and a fresh request_id.
- artifact_export_result fires when the export resolves — success for
sync exporters (html, markdown, template) the moment the call
returns, success/failed for async exporters (pdf, zip, deploy)
via .then/.catch. The same request_id threads both events so
PostHog stitches click → result via $insert_id.
DEPLOY_PROVIDER_OPTIONS maps to the CSV's vercel / cloudflare_pages
slots; markdown is now a first-class export_format value.
Also ignore .env.local so local POSTHOG_KEY / .env-style secrets
don't get committed.
* feat(analytics): emit run_created and run_finished from the daemon
POST /api/runs now reads the analytics context off the
x-od-analytics-* headers the web client sets on every fetch, then:
- Captures run_created with project_id, conversation_id, run_id,
model_id, agent_provider_id (mapped via agentIdToTracking),
skill_id, design_system_id, plus the token_count_source marker.
- Schedules a run_finished capture on runs.wait(run) resolution,
mapping succeeded/canceled/failed to success/cancelled/failed and
reporting total_duration_ms.
Both events use a stable insert_id derived from the same uuid so
PostHog dedupes the daemon-side mirror against any future
web-side capture without double-counting.
Token sub-fields (user_query_tokens/system_prompt_tokens/...) stay
omitted in v1 — the claude-stream parser only exposes input/output
totals today. See tracking-doc-issues.md §3.2.
* feat(analytics): emit settings_cli_test_result + settings_byok_test_result
The original BLOCKING-list assumed these CSV P0 events were not
implementable in this branch because main lacked Test buttons. The
worktree HEAD actually wires `handleTestAgent` and `handleTestProvider`
in SettingsDialog, so both events are now in scope.
- handleTestAgent emits settings_cli_test_result on success and
failure paths with cli_provider_id mapped via agentIdToTracking,
result drawn from result.ok / catch branch, error_code from
result.kind or the thrown error name, and duration_ms timed via
performance.now().
- handleTestProvider emits settings_byok_test_result analogously,
using apiProtocol (anthropic|openai|azure|ollama|google) directly
as provider_id — wider than the CSV's 5-value enum, documented in
tracking-doc-issues.md §2.5.
Contracts: add SettingsCliTestResultProps / SettingsByokTestResultProps
plus matching track* helpers. AnalyticsEventName union now covers all
14 P0 events this branch supports.
* feat(analytics): gate PostHog on the existing telemetry.metrics consent
The integration now reuses the same first-launch privacy banner +
Settings → Privacy toggle that gates Langfuse, so a single user
decision controls both telemetry sinks.
- /api/analytics/config now consults the persisted AppConfigPrefs:
it returns enabled=true only when POSTHOG_KEY is set AND the user
has chosen "Share usage data" (telemetry.metrics === true). The
response also echoes installationId so the web client uses the
same anonymous id Langfuse keys off of — one identity per install,
shared across both sinks.
- Web AnalyticsProvider:
- Bootstrap fetch resolves installationId and threads it through
the x-od-analytics-anonymous-id header on every /api/* fetch,
so daemon-side captures (run_created / run_finished /
project_create_result) land on the same person record.
- Exposes a setConsent(granted) method that calls posthog-js's
opt_in_capturing / opt_out_capturing, wired from App.tsx via a
useEffect watching config.telemetry?.metrics. Toggling Privacy
→ metrics now stops/resumes events immediately, no reload.
- app_launch additionally gates on telemetry.metrics so a freshly-
declined user fires nothing, and a freshly-opted-in user fires on
the next reload.
* feat(packaging): bake POSTHOG_KEY into packaged daemon spawn env
Wires PostHog product analytics through the same Langfuse-style build-
secret pipeline so official Open Design builds ship with the key while
fork builds compile without it (the integration short-circuits cleanly
when POSTHOG_KEY is absent).
tools/pack
- resolveToolPackConfig reads POSTHOG_KEY / POSTHOG_HOST from
process.env at packaging time, validates them (no whitespace in the
key, http(s) URL for host, trailing-slash strip), and stamps them on
ToolPackConfig. Fork builds without the env vars simply omit the
fields; the daemon-side gate keeps things off in that case.
- Mac, Windows, and Linux packaged-config writers each append the two
fields to open-design-config.json next to the existing
telemetryRelayUrl entry.
apps/packaged
- RawPackagedConfig / PackagedConfig surface posthogKey / posthogHost
so the Electron entry and headless entry both forward them to the
daemon sidecar.
- buildPackagedDaemonSpawnEnv emits POSTHOG_KEY / POSTHOG_HOST into
the daemon child env when present. The daemon's existing analytics
module reads these via process.env — no daemon-side changes needed.
- The headless packaged path falls back to process.env for fields the
builder hasn't injected, mirroring how OPEN_DESIGN_TELEMETRY_RELAY_URL
is read there.
CI
- release-beta.yml and release-stable.yml expose POSTHOG_KEY (secret)
and POSTHOG_HOST (var) at workflow-env scope so every packaging job
inherits them. PR / fork builds without these set simply skip the
bake step.
Tests
- tools/pack: config.test.ts covers bake-through, fork-build omission,
whitespace rejection, invalid-URL rejection, and trailing-slash
normalization.
- apps/packaged: sidecars.test.ts covers buildPackagedDaemonSpawnEnv
forwarding the keys when present and omitting them when null.
* feat(analytics): enable PostHog autocapture + perf + exceptions
Flip on the PostHog SDK's automatic diagnostic features so we capture
click paths, page transitions, web vitals, dead clicks, and browser
exceptions without scattering instrumentation through the codebase.
Privacy defense lives in one place — apps/web/src/analytics/scrub.ts —
wired in via posthog-js's `before_send` hook so every outgoing event
passes through the same audit point:
- $autocapture / $rageclick / $dead_click / $copy_autocapture:
strips $el_text and value/placeholder/aria-label attrs from any
input, textarea, password input, or contenteditable element. PostHog
autocapture does not capture input.value by default, but $el_text
on a <textarea> reflects the typed content — that's the prompt
body for us, so it has to be scrubbed every time.
- $pageview / $pageleave: drops query string and fragment from
$current_url / $referrer so any future ?q=… can't leak.
- $exception: rewrites file:// and absolute filesystem paths in
stack frames to app://apps/<repo-relative> so we don't ship the
user's home directory.
- Suppresses $opt_in entirely — duplicate of our explicit
setConsent toggle in App.tsx.
Element-level defense in depth is limited to the single most sensitive
surface: the chat composer textarea gets `ph-no-capture` so PostHog
never even generates an event for clicks inside that subtree. Every
other input relies on scrub.ts — sprinkling the class through every
form would be noisy and easy to forget on new surfaces.
The existing Privacy → "Share usage data" toggle continues to gate
every new feature: posthog-js's opt_out_capturing() halts autocapture,
$pageview, $exception, web vitals, and dead clicks alongside the
explicit capture() calls — one global switch.
11 unit tests pin the scrub rules in apps/web/tests/analytics-scrub.test.ts.
* ci(nix): bump pnpmDepsHash for posthog-js + posthog-node additions
Adding posthog-js to apps/web and posthog-node to apps/daemon changed
pnpm-lock.yaml, which Nix's fixed-output pnpmDeps derivation pins by
sha256. The CI nix flake check failed with:
specified: sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc=
got: sha256-PGFgX4lYyeH2TRAXfUq52A3EOa6bb1gO59hPsXhEk3s=
Copy the new hash into both nix/package-web.nix and
nix/package-daemon.nix per the procedure documented in nix/README.md
§"First-build hash pinning".
* feat(analytics): unify PostHog identity with Langfuse installationId
PostHog's distinct_id is the installationId stamped by /api/analytics/
config; Langfuse already reads the same id off app-config.json to
populate trace.userId. With both sinks keying off the same anonymous
identity, dashboards can correlate user actions (PostHog events) with
LLM runs (Langfuse traces) without re-identifying.
Two gaps closed:
1. applyConsent(false) — clear posthog-js's persisted ph_*_posthog
localStorage entry on opt-out via posthog.reset(). Without this, a
user who opts out, then clicks Delete my data, then re-opts in
would see PostHog stitch their new session to the deleted identity
because bootstrap.distinctID only takes effect on first init.
2. applyIdentity(newInstallationId) — Delete my data rotates the
installationId in app-config; App.tsx now watches config.installationId
and calls posthog.reset() then identify(newId) so the next event
batch is fully decoupled from the deleted one. Idempotent on
same-id re-renders so benign config refreshes don't churn PostHog
identities.
The fetch wrapper's x-od-analytics-anonymous-id header also flips to
the new id on rotation so daemon-side captures (run_created /
run_finished) land on the same person record from the very next API
call, not after a reload.
The end-to-end rotation flow is verified against a live PostHog
project; these unit tests pin the safety guards (no-client paths, null
inputs) since stubbing posthog-js's init-loaded callback chain is
brittle.
* fix(langfuse): require both metrics AND content consent for trace reports
Tightens the Langfuse gate so a user who shares anonymous metrics but
NOT conversation content stops emitting Langfuse traces entirely —
Langfuse is used for turn-quality evals which only make sense with
prompt/output bodies. PostHog (product analytics, content-free) stays
gated on `metrics` alone and is unaffected.
i18n: "Conversation content" → "Conversation and tool content" with
hints expanded to mention tool inputs/outputs so the consent surface
matches what the trace actually carries (en + zh-CN).
Bundled here per PR scope — change originated outside this PostHog
PR but lands cleanly on the same files; gating Langfuse strictly
on `content` makes the dual-sink consent model (PostHog = metrics,
Langfuse = metrics + content) symmetric across both i18n locales and
the daemon-side gate.
* feat(analytics): wire byok_provider_option + fix PR review P1s
Adds the BYOK protocol-chip click event (5-value provider_id mirroring
the apiProtocol Settings UI) and resolves four P1 review threads on
PR #1428.
byok_provider_option:
- New SettingsClickByokProviderOptionProps in contracts (provider_id =
anthropic|openai|azure|google|ollama; maps to CSV's 5 values per
tracking-doc-issues.md §2.5).
- trackSettingsClickByokProviderOption helper in apps/web/src/analytics.
- SettingsDialog hooks it on the protocol-chip onClick alongside the
existing setApiProtocol call; is_selected reflects whether the chip
was already active.
Review fixes:
1. client.ts (Siri-Ray): clear `initPromise` when the resolution is
null so a Privacy → metrics opt-in after a previous decline triggers
a fresh /api/analytics/config fetch. Without this, the disabled
response was cached forever — first-session opt-in needed a reload
to start sending PostHog events.
2. provider.tsx (Siri-Ray): replace `url.includes('/api/')` with a
strict same-origin + /api/ pathname check (shared
`isSameOriginApiCall` helper). Outbound third-party URLs containing
`/api/` (e.g. provider.example.com/api/x) no longer receive our
x-od-analytics-* headers.
3. provider.tsx (codex-connector, lefarcen): gate header injection on
`resolvedAnonId` being non-null. When Privacy → metrics is off,
/api/analytics/config returns enabled=false → resolvedAnonId stays
null → wrapper never installs → daemon can't read consent-bearing
headers → no daemon-side PostHog event. setConsent now also clears
resolvedAnonId on opt-out and re-fetches on opt-in.
4. daemon/analytics.ts (defense in depth): createAnalyticsService now
takes dataDir and capture() re-reads app-config to check
telemetry.metrics inside the fire-and-forget wrapper. Even if a
stale header somehow reaches the daemon after opt-out, the capture
is dropped before posthog-node.capture is called.
* fix(web): place "Share usage data" on the right in privacy consent banner
Swap button order in PrivacyConsentModal and the in-settings ConsentCard
so the affirmative "Share usage data" lands on the right and "Not now"
on the left. Matches the OK-on-the-right pattern users expect for
primary actions.
Both buttons keep equal visual prominence (same .privacy-consent-action
styling) so the swap doesn't change the EDPB equal-prominence stance
called out in the original Langfuse telemetry spec.
* feat(analytics): populate run_finished token totals from claude-stream usage
Daemon's claude-stream parser already emits agent usage events with
input_tokens / output_tokens totals; the run service buffers them in
run.events and Langfuse reads them out the same way. The run_finished
PostHog event was leaving these fields empty.
Scan run.events for the most recent agent usage frame on terminal
transition and emit input_tokens / output_tokens / total_tokens when
present. token_count_source flips to 'provider_usage' only when at
least one count landed; runs without provider-side usage data keep
'unknown'.
Provider does not break the input down into the 7 sub-fields the
tracking doc lists (memory / context / attachment / system_prompt /
…); those stay omitted until a parser change exposes them.
* feat(analytics): estimate user_query_tokens from prompt length
The user_query_tokens field for run_created / run_finished was hardcoded
to 0. We can't tokenize without bundling a model-specific tokenizer, but
the character/4 heuristic is the industry-standard estimate when one
isn't available and is enough for funnel analysis (prompt-length cohorts,
short-vs-long-query conversion rates).
Extracted from req.body via the same telemetryPromptFromRunRequest
pattern the daemon already uses for langfuse-bridge (currentPrompt then
message fallback). Only the integer count goes to PostHog — the prompt
text itself never leaves the daemon.
token_count_source flips appropriately:
- run_created with a prompt: 'estimated' (was 'unknown')
- run_created with no prompt: 'unknown'
- run_finished with provider usage: 'provider_usage' (overrides
baseProps' 'estimated' value)
- run_finished without provider usage: inherits 'estimated' or 'unknown'
from baseProps so input/output absent doesn't mask the estimate.
This commit is contained in:
parent
7b191b5f85
commit
e1bc83a476
49 changed files with 3312 additions and 84 deletions
7
.github/workflows/release-beta.yml
vendored
7
.github/workflows/release-beta.yml
vendored
|
|
@ -34,6 +34,13 @@ concurrency:
|
|||
|
||||
env:
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: ${{ vars.OPEN_DESIGN_TELEMETRY_RELAY_URL }}
|
||||
# PostHog product-analytics ingest. Both vars must be defined as
|
||||
# repository/organization secrets/vars for official builds to ship with
|
||||
# analytics enabled. PR builds and forks run without these — the daemon's
|
||||
# /api/analytics/config short-circuits to enabled=false in that case and
|
||||
# no events leave the user's machine.
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
|
|||
6
.github/workflows/release-stable.yml
vendored
6
.github/workflows/release-stable.yml
vendored
|
|
@ -26,6 +26,12 @@ concurrency:
|
|||
|
||||
env:
|
||||
OPEN_DESIGN_TELEMETRY_RELAY_URL: ${{ vars.OPEN_DESIGN_TELEMETRY_RELAY_URL }}
|
||||
# PostHog product-analytics ingest. Defined as repository secret + var
|
||||
# so official builds ship with analytics enabled; PR builds and forks
|
||||
# without these run the daemon in no-op analytics mode (events never
|
||||
# leave the user's machine, /api/analytics/config returns enabled=false).
|
||||
POSTHOG_KEY: ${{ secrets.POSTHOG_KEY }}
|
||||
POSTHOG_HOST: ${{ vars.POSTHOG_HOST }}
|
||||
|
||||
jobs:
|
||||
metadata:
|
||||
|
|
|
|||
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -55,5 +55,8 @@ specs/change/active
|
|||
# Nix and direnv
|
||||
.direnv/
|
||||
.envrc
|
||||
# Local secrets (PostHog keys, BYOK creds for local testing, etc.)
|
||||
.env.local
|
||||
.env.*.local
|
||||
# Local design assistant context
|
||||
.impeccable.md
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@
|
|||
"express": "^4.19.2",
|
||||
"jszip": "^3.10.1",
|
||||
"multer": "^2.1.1",
|
||||
"posthog-node": "^4.18.0",
|
||||
"undici": "^7.16.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
|
|
|||
186
apps/daemon/src/analytics.ts
Normal file
186
apps/daemon/src/analytics.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// Daemon-side PostHog capture. Mirrors apps/daemon/src/langfuse-trace.ts in
|
||||
// its env-gating discipline: without POSTHOG_KEY in the env every entry point
|
||||
// is a no-op, so dev builds and third-party forks impose zero overhead.
|
||||
//
|
||||
// Web-side captures (apps/web/src/analytics) carry the matching identity in
|
||||
// HTTP headers (see x-od-analytics-* constants in @open-design/contracts);
|
||||
// daemon reads those headers off the request and reuses the same
|
||||
// anonymous_id as the PostHog distinct_id so events from both sides land on
|
||||
// the same person.
|
||||
|
||||
import crypto from 'node:crypto';
|
||||
import { PostHog } from 'posthog-node';
|
||||
import type { Request } from 'express';
|
||||
import {
|
||||
ANALYTICS_HEADER_ANONYMOUS_ID,
|
||||
ANALYTICS_HEADER_CLIENT_TYPE,
|
||||
ANALYTICS_HEADER_LOCALE,
|
||||
ANALYTICS_HEADER_REQUEST_ID,
|
||||
ANALYTICS_HEADER_SESSION_ID,
|
||||
anonymizeArtifactId as anonymizeArtifactIdShared,
|
||||
type AnalyticsClientType,
|
||||
type AnalyticsConfigResponse,
|
||||
EVENT_SCHEMA_VERSION,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import { readAppConfig } from './app-config.js';
|
||||
|
||||
const DEFAULT_HOST = 'https://us.i.posthog.com';
|
||||
|
||||
export interface AnalyticsContext {
|
||||
anonymousId: string;
|
||||
sessionId: string;
|
||||
clientType: AnalyticsClientType;
|
||||
locale: string;
|
||||
requestId: string | null;
|
||||
}
|
||||
|
||||
// Read context from an incoming request. Returns null when the web client did
|
||||
// not include analytics headers (likely because analytics is disabled on the
|
||||
// web side too). Daemon-internal capture sites (e.g. background sweeps with
|
||||
// no request) should not invoke this path.
|
||||
export function readAnalyticsContext(req: Request): AnalyticsContext | null {
|
||||
const anonymousId = headerString(req, ANALYTICS_HEADER_ANONYMOUS_ID);
|
||||
if (!anonymousId) return null;
|
||||
const sessionId = headerString(req, ANALYTICS_HEADER_SESSION_ID) ?? anonymousId;
|
||||
const clientHeader = headerString(req, ANALYTICS_HEADER_CLIENT_TYPE);
|
||||
const clientType: AnalyticsClientType =
|
||||
clientHeader === 'desktop' ? 'desktop' : 'web';
|
||||
const locale = headerString(req, ANALYTICS_HEADER_LOCALE) ?? 'en';
|
||||
const requestId = headerString(req, ANALYTICS_HEADER_REQUEST_ID);
|
||||
return { anonymousId, sessionId, clientType, locale, requestId };
|
||||
}
|
||||
|
||||
function headerString(req: Request, name: string): string | null {
|
||||
const raw = req.headers[name];
|
||||
if (Array.isArray(raw)) return raw[0]?.trim() || null;
|
||||
if (typeof raw === 'string') return raw.trim() || null;
|
||||
return null;
|
||||
}
|
||||
|
||||
export interface PosthogConfig {
|
||||
key: string;
|
||||
host: string;
|
||||
}
|
||||
|
||||
export function readPosthogConfig(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): PosthogConfig | null {
|
||||
const key = env.POSTHOG_KEY?.trim();
|
||||
if (!key) return null;
|
||||
const host = (env.POSTHOG_HOST?.trim() || DEFAULT_HOST).replace(/\/+$/, '');
|
||||
return { key, host };
|
||||
}
|
||||
|
||||
// Baseline wire response for GET /api/analytics/config — checks only the
|
||||
// env-var gate. The route handler in server.ts further narrows this with
|
||||
// the user's telemetry.metrics consent before sending it to the client.
|
||||
export function readPublicConfigResponse(
|
||||
env: NodeJS.ProcessEnv = process.env,
|
||||
): AnalyticsConfigResponse {
|
||||
const cfg = readPosthogConfig(env);
|
||||
if (!cfg) return { enabled: false, key: null, host: null };
|
||||
return { enabled: true, key: cfg.key, host: cfg.host };
|
||||
}
|
||||
|
||||
export interface AnalyticsService {
|
||||
capture(args: {
|
||||
eventName: string;
|
||||
context: AnalyticsContext;
|
||||
appVersion: string;
|
||||
properties: Record<string, unknown>;
|
||||
insertId: string;
|
||||
}): void;
|
||||
shutdown(): Promise<void>;
|
||||
}
|
||||
|
||||
const NOOP_SERVICE: AnalyticsService = {
|
||||
capture: () => undefined,
|
||||
shutdown: async () => undefined,
|
||||
};
|
||||
|
||||
// PostHog node client is created lazily so that import-time of this module
|
||||
// stays free in keyless dev/test environments. Returns the no-op service
|
||||
// when POSTHOG_KEY is unset.
|
||||
//
|
||||
// `dataDir` is required so capture can re-read app-config and gate on the
|
||||
// user's telemetry.metrics consent. This is defense in depth against PR
|
||||
// #1428 reviewer (codex-connector, lefarcen): even if a stale fetch wrapper
|
||||
// somehow attaches x-od-analytics-* headers to a request after the user
|
||||
// opted out, the daemon will still drop the capture.
|
||||
export function createAnalyticsService(args: {
|
||||
env?: NodeJS.ProcessEnv;
|
||||
dataDir: string;
|
||||
}): AnalyticsService {
|
||||
const env = args.env ?? process.env;
|
||||
const cfg = readPosthogConfig(env);
|
||||
if (!cfg) return NOOP_SERVICE;
|
||||
|
||||
// flushAt: 1 keeps the daemon-emit-then-respond pattern simple at the cost
|
||||
// of one network round-trip per event; flushInterval: 1000 still batches
|
||||
// bursts so a streaming run doesn't fire one HTTP per event.
|
||||
const client = new PostHog(cfg.key, {
|
||||
host: cfg.host,
|
||||
flushAt: 1,
|
||||
flushInterval: 1000,
|
||||
});
|
||||
|
||||
// Suppress posthog-node's own internal error spam — analytics failures
|
||||
// must never look like product errors. The library exposes `on('error')`.
|
||||
client.on?.('error', () => undefined);
|
||||
|
||||
return {
|
||||
capture: ({ eventName, context, appVersion, properties, insertId }) => {
|
||||
// Defense-in-depth consent re-check. The route handler already gates
|
||||
// on header presence, but a future header leak or a Settings toggle
|
||||
// mid-request would still let events through without this. Reading
|
||||
// app-config.json adds one small file read per event; the daemon is
|
||||
// not on a hot critical path here.
|
||||
void (async () => {
|
||||
try {
|
||||
const appCfg = await readAppConfig(args.dataDir);
|
||||
if (appCfg.telemetry?.metrics !== true) return;
|
||||
client.capture({
|
||||
distinctId: context.anonymousId,
|
||||
event: eventName,
|
||||
properties: {
|
||||
...properties,
|
||||
event_id: insertId,
|
||||
event_schema_version: EVENT_SCHEMA_VERSION,
|
||||
ui_version: appVersion,
|
||||
app_version: appVersion,
|
||||
session_id: context.sessionId,
|
||||
anonymous_id: context.anonymousId,
|
||||
client_type: context.clientType,
|
||||
locale: context.locale,
|
||||
...(context.requestId ? { request_id: context.requestId } : {}),
|
||||
// $insert_id is PostHog's dedup key — passing the same id
|
||||
// from web and daemon prevents the mirrored result event
|
||||
// from being counted twice.
|
||||
$insert_id: insertId,
|
||||
},
|
||||
});
|
||||
} catch {
|
||||
// Swallowed by design; capture failures must never propagate.
|
||||
}
|
||||
})();
|
||||
},
|
||||
shutdown: async () => {
|
||||
try {
|
||||
await client.shutdown();
|
||||
} catch {
|
||||
// best-effort flush on shutdown.
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// Re-export so server.ts and route handlers don't need a second import
|
||||
// path; the canonical hash lives in @open-design/contracts/analytics so
|
||||
// the web bundle produces the same id for the same (projectId, fileName).
|
||||
export const anonymizeArtifactId = anonymizeArtifactIdShared;
|
||||
|
||||
// Generate a fresh insert_id when the request didn't carry one. Used for
|
||||
// daemon-internal events where there is no matching web emission.
|
||||
export function newInsertId(): string {
|
||||
return crypto.randomUUID();
|
||||
}
|
||||
|
|
@ -1,5 +1,10 @@
|
|||
import type { Express } from 'express';
|
||||
import type { RouteDeps } from './server-context.js';
|
||||
import { newInsertId } from './analytics.js';
|
||||
import {
|
||||
agentIdToTracking,
|
||||
projectKindToTracking,
|
||||
} from '@open-design/contracts/analytics';
|
||||
|
||||
export interface RegisterChatRoutesDeps extends RouteDeps<'db' | 'design' | 'http' | 'chat' | 'agents' | 'critique' | 'validation' | 'lifecycle'> {}
|
||||
|
||||
|
|
@ -60,6 +65,129 @@ export function registerChatRoutes(app: Express, ctx: RegisterChatRoutesDeps) {
|
|||
res.status(202).json(body);
|
||||
design.runs.start(run, () => startChatRun(req.body || {}, run));
|
||||
reconcileAssistantMessageOnRunEnd(db, design.runs, run);
|
||||
|
||||
// Analytics: emit run_created (daemon-side, authoritative) and
|
||||
// schedule a run_finished emission on wait() resolution. Both events
|
||||
// use the same insert_id so PostHog dedupes against the web mirror
|
||||
// that fires on SSE start/end. No-op when POSTHOG_KEY is unset.
|
||||
const context = design.readAnalyticsContext?.(req);
|
||||
if (context) {
|
||||
const reqBody = (req.body || {}) as Record<string, unknown>;
|
||||
const runInsertId = newInsertId();
|
||||
const runStartedAt = Date.now();
|
||||
// Estimate user_query_tokens from the request prompt — we never
|
||||
// transmit the prompt text itself, just the integer count. The
|
||||
// canonical extraction (currentPrompt fallback to message) lives
|
||||
// in telemetryPromptFromRunRequest; mirroring it inline keeps the
|
||||
// analytics emit self-contained and out of the startChatRun
|
||||
// critical path.
|
||||
const promptText =
|
||||
typeof reqBody.currentPrompt === 'string'
|
||||
? reqBody.currentPrompt
|
||||
: typeof reqBody.message === 'string'
|
||||
? reqBody.message
|
||||
: '';
|
||||
// ~4 chars per token is the common rough heuristic for English /
|
||||
// Latin text; CJK skews token-per-char higher but this is still the
|
||||
// industry-standard estimate when no tokenizer is available. The
|
||||
// accompanying token_count_source field marks this as 'estimated'
|
||||
// so dashboards can tell estimate from real provider counts.
|
||||
const userQueryTokens = promptText.length > 0
|
||||
? Math.ceil(promptText.length / 4)
|
||||
: 0;
|
||||
const baseProps: Record<string, unknown> = {
|
||||
page: 'studio',
|
||||
area: 'chat_composer',
|
||||
project_id: typeof reqBody.projectId === 'string' ? reqBody.projectId : null,
|
||||
conversation_id:
|
||||
typeof reqBody.conversationId === 'string' ? reqBody.conversationId : null,
|
||||
run_id: run.id,
|
||||
project_kind: null,
|
||||
design_system_id:
|
||||
typeof reqBody.designSystemId === 'string'
|
||||
? reqBody.designSystemId
|
||||
: undefined,
|
||||
design_system_source: 'unknown',
|
||||
has_attachment: Array.isArray(reqBody.attachments)
|
||||
? (reqBody.attachments as unknown[]).length > 0
|
||||
: false,
|
||||
user_query_tokens: userQueryTokens,
|
||||
model_id: typeof reqBody.model === 'string' ? reqBody.model : null,
|
||||
agent_provider_id:
|
||||
typeof reqBody.agentId === 'string'
|
||||
? agentIdToTracking(reqBody.agentId)
|
||||
: null,
|
||||
skill_id: typeof reqBody.skillId === 'string' ? reqBody.skillId : null,
|
||||
mcp_id: null,
|
||||
token_count_source: userQueryTokens > 0 ? 'estimated' : 'unknown',
|
||||
};
|
||||
design.analytics.capture({
|
||||
eventName: 'run_created',
|
||||
context,
|
||||
appVersion: design.getAppVersion?.() ?? '0.0.0',
|
||||
properties: baseProps,
|
||||
insertId: runInsertId,
|
||||
});
|
||||
// Run lifecycle hook: emit run_finished when the run reaches a
|
||||
// terminal state. The same context is reused — captures are
|
||||
// synchronous and never block the run.
|
||||
design.runs.wait(run).then((status: { status: string }) => {
|
||||
const result =
|
||||
status.status === 'succeeded'
|
||||
? 'success'
|
||||
: status.status === 'canceled'
|
||||
? 'cancelled'
|
||||
: 'failed';
|
||||
// Pull input/output token totals from the agent's usage event,
|
||||
// which claude-stream.ts emits as `{ type: 'usage', usage: {...} }`
|
||||
// and the run service stores in run.events. Provider only gives
|
||||
// totals (no 7-subfield breakdown), so token_count_source flips
|
||||
// to 'provider_usage' here only when at least one number landed;
|
||||
// otherwise stays 'unknown'.
|
||||
let inputTokens: number | undefined;
|
||||
let outputTokens: number | undefined;
|
||||
for (let i = run.events.length - 1; i >= 0; i -= 1) {
|
||||
const ev = run.events[i];
|
||||
const data = ev?.data as
|
||||
| { type?: string; usage?: Record<string, unknown> | null }
|
||||
| null
|
||||
| undefined;
|
||||
if (ev?.event === 'agent' && data?.type === 'usage' && data.usage) {
|
||||
const u = data.usage;
|
||||
if (typeof u.input_tokens === 'number') inputTokens = u.input_tokens;
|
||||
if (typeof u.output_tokens === 'number') outputTokens = u.output_tokens;
|
||||
if (inputTokens !== undefined || outputTokens !== undefined) break;
|
||||
}
|
||||
}
|
||||
const haveUsage = inputTokens !== undefined || outputTokens !== undefined;
|
||||
const totalTokens =
|
||||
inputTokens !== undefined && outputTokens !== undefined
|
||||
? inputTokens + outputTokens
|
||||
: undefined;
|
||||
design.analytics.capture({
|
||||
eventName: 'run_finished',
|
||||
context,
|
||||
appVersion: design.getAppVersion?.() ?? '0.0.0',
|
||||
properties: {
|
||||
...baseProps,
|
||||
area: 'chat_panel',
|
||||
result,
|
||||
artifact_count: 0,
|
||||
total_duration_ms: Date.now() - runStartedAt,
|
||||
...(inputTokens !== undefined ? { input_tokens: inputTokens } : {}),
|
||||
...(outputTokens !== undefined ? { output_tokens: outputTokens } : {}),
|
||||
...(totalTokens !== undefined ? { total_tokens: totalTokens } : {}),
|
||||
// Upgrade source to 'provider_usage' when the agent reported
|
||||
// input/output totals; otherwise inherit baseProps' value
|
||||
// ('estimated' when user_query_tokens > 0, else 'unknown').
|
||||
...(haveUsage ? { token_count_source: 'provider_usage' } : {}),
|
||||
},
|
||||
insertId: `${runInsertId}-finish`,
|
||||
});
|
||||
}).catch(() => {
|
||||
// wait() can't reject in current runs.ts impl, but guard anyway.
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
app.get('/api/runs', (req, res) => {
|
||||
|
|
|
|||
|
|
@ -7,11 +7,12 @@
|
|||
// LANGFUSE_SECRET_KEY in the env, every entry point becomes a no-op so that
|
||||
// dev runs and forks of this open-source repo do not accidentally report.
|
||||
//
|
||||
// Privacy gates are layered: `prefs.metrics` is the master switch (off => no
|
||||
// network call at all), `prefs.content` decides whether the prompt /
|
||||
// assistant text is included, and `prefs.artifactManifest` decides whether
|
||||
// the produced-files manifest is included. None of these defaults to true;
|
||||
// the Web onboarding flow flips them after explicit consent.
|
||||
// Privacy gates are layered: `prefs.metrics` is the master switch, and
|
||||
// `prefs.content` is required for Langfuse traces because this sink is used
|
||||
// for turn-quality evals. If either is off, no network call is made.
|
||||
// `prefs.artifactManifest` decides whether the produced-files manifest is
|
||||
// included. None of these defaults to true; the Web onboarding flow flips
|
||||
// metrics + content after explicit consent.
|
||||
//
|
||||
// See: specs/change/20260507-langfuse-telemetry/spec.md
|
||||
|
||||
|
|
@ -615,6 +616,7 @@ export async function reportRunCompleted(
|
|||
opts: ReportRunOpts = {},
|
||||
): Promise<void> {
|
||||
if (ctx.prefs.metrics !== true) return;
|
||||
if (ctx.prefs.content !== true) return;
|
||||
|
||||
const config = resolveReportConfig(opts);
|
||||
if (!config) {
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ import { renderDesignSystemPreview } from './design-system-preview.js';
|
|||
import { renderDesignSystemShowcase } from './design-system-showcase.js';
|
||||
import { createChatRunService } from './runs.js';
|
||||
import { reportRunCompletedFromDaemon } from './langfuse-bridge.js';
|
||||
import {
|
||||
createAnalyticsService,
|
||||
readAnalyticsContext,
|
||||
readPublicConfigResponse,
|
||||
} from './analytics.js';
|
||||
import {
|
||||
redactSecrets,
|
||||
testAgentConnection,
|
||||
|
|
@ -2607,10 +2612,44 @@ export async function startServer({
|
|||
}
|
||||
});
|
||||
|
||||
const analyticsService = createAnalyticsService({ dataDir: RUNTIME_DATA_DIR });
|
||||
const design = {
|
||||
runs: createChatRunService({ createSseResponse, createSseErrorPayload }),
|
||||
analytics: analyticsService,
|
||||
getAppVersion: () => cachedAppVersion?.version ?? '0.0.0',
|
||||
readAnalyticsContext,
|
||||
};
|
||||
|
||||
// PostHog runtime config — gated on BOTH a server-side key (POSTHOG_KEY)
|
||||
// and the user's opt-in metrics consent (Privacy → "Share usage data").
|
||||
// The web bundle short-circuits when enabled=false so opt-out behaviour
|
||||
// is instant after the user toggles metrics off and reloads.
|
||||
app.get('/api/analytics/config', async (_req, res) => {
|
||||
const baseline = readPublicConfigResponse();
|
||||
if (!baseline.enabled) {
|
||||
res.json(baseline);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const appCfg = await readAppConfig(RUNTIME_DATA_DIR);
|
||||
const consentGranted = appCfg.telemetry?.metrics === true;
|
||||
if (!consentGranted) {
|
||||
res.json({ enabled: false, key: null, host: null });
|
||||
return;
|
||||
}
|
||||
// Echo the installationId so the web client uses the same anonymous
|
||||
// id PostHog already saw on prior runs (and that Langfuse uses too).
|
||||
const installationId =
|
||||
typeof appCfg.installationId === 'string' && appCfg.installationId
|
||||
? appCfg.installationId
|
||||
: null;
|
||||
res.json({ ...baseline, installationId });
|
||||
} catch {
|
||||
// If the config file is unreadable, fail closed — no events.
|
||||
res.json({ enabled: false, key: null, host: null });
|
||||
}
|
||||
});
|
||||
|
||||
// Tracks runs whose completion has already been forwarded to Langfuse so
|
||||
// repeated message updates only emit one trace per run.
|
||||
const reportedRuns = new Set();
|
||||
|
|
@ -4557,6 +4596,7 @@ export async function startServer({
|
|||
daemonShutdownStarted = true;
|
||||
daemonShuttingDown = true;
|
||||
await design.runs.shutdownActive({ graceMs: resolveChatRunShutdownGraceMs() });
|
||||
await design.analytics.shutdown();
|
||||
};
|
||||
let server;
|
||||
try {
|
||||
|
|
|
|||
|
|
@ -135,6 +135,37 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
|||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when conversation/tool content reporting is off', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true, content: false, artifactManifest: true },
|
||||
});
|
||||
const fetchSpy = vi.fn();
|
||||
process.env.LANGFUSE_PUBLIC_KEY = 'pk';
|
||||
process.env.LANGFUSE_SECRET_KEY = 'sk';
|
||||
try {
|
||||
await reportRunCompletedFromDaemon({
|
||||
db: makeDbWithListMessages({
|
||||
'conv-1': [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'assistant',
|
||||
content: 'sensitive output',
|
||||
producedFiles: [{ name: 'secret.html', kind: 'html', size: 1 }],
|
||||
},
|
||||
],
|
||||
}),
|
||||
dataDir,
|
||||
run: makeRun() as any,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
} finally {
|
||||
delete process.env.LANGFUSE_PUBLIC_KEY;
|
||||
delete process.env.LANGFUSE_SECRET_KEY;
|
||||
}
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no app-config.json exists (fresh install)', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompletedFromDaemon({
|
||||
|
|
@ -239,7 +270,7 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
|||
it('attaches turn-level config (model / reasoning / skill / DS) to trace + generation', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-uuid-1',
|
||||
telemetry: { metrics: true, content: false, artifactManifest: false },
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
|
|
@ -303,12 +334,12 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
|||
expect(generation.modelParameters).toEqual({ reasoning: 'high' });
|
||||
});
|
||||
|
||||
it('omits content + artifacts when those gates are off', async () => {
|
||||
it('omits artifacts when that gate is off', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: {
|
||||
metrics: true,
|
||||
content: false,
|
||||
content: true,
|
||||
artifactManifest: false,
|
||||
},
|
||||
});
|
||||
|
|
@ -338,8 +369,8 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
|||
}
|
||||
const init = fetchSpy.mock.calls[0]![1] as RequestInit;
|
||||
const trace = JSON.parse(init.body as string).batch[0].body;
|
||||
expect(trace.input).toBeUndefined();
|
||||
expect(trace.output).toBeUndefined();
|
||||
expect(trace.input).toBe('design a coffee landing page');
|
||||
expect(trace.output).toBe('sensitive output');
|
||||
expect(trace.metadata.artifacts).toBeUndefined();
|
||||
// tokens + eventsSummary are still in metadata since they're metrics
|
||||
expect(trace.metadata.tokens).toEqual({
|
||||
|
|
@ -392,7 +423,7 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
|||
it('passes status=failed and a clipped error message through', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-1',
|
||||
telemetry: { metrics: true },
|
||||
telemetry: { metrics: true, content: true },
|
||||
});
|
||||
const fetchSpy = vi
|
||||
.fn()
|
||||
|
|
@ -465,7 +496,7 @@ describe('langfuse-bridge.reportRunCompletedFromDaemon', () => {
|
|||
it('uses the persisted terminal status when the in-memory run has not settled yet', async () => {
|
||||
await writeAppCfg({
|
||||
installationId: 'install-uuid-1',
|
||||
telemetry: { metrics: true, content: false, artifactManifest: false },
|
||||
telemetry: { metrics: true, content: true, artifactManifest: false },
|
||||
});
|
||||
const run = makeRun({
|
||||
status: 'cancelRequested',
|
||||
|
|
|
|||
|
|
@ -498,12 +498,28 @@ describe('reportRunCompleted', () => {
|
|||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when content gate is off', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: false, artifactManifest: true },
|
||||
}),
|
||||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('does nothing when no Langfuse config is available', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: null,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: null,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
|
|
@ -511,10 +527,15 @@ describe('reportRunCompleted', () => {
|
|||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response('{}', { status: 200 }),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const call = fetchSpy.mock.calls[0]!;
|
||||
const url = call[0] as string;
|
||||
|
|
@ -544,10 +565,15 @@ describe('reportRunCompleted', () => {
|
|||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response('{}', { status: 200 }),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: relayConfig,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: relayConfig,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(1);
|
||||
const call = fetchSpy.mock.calls[0]!;
|
||||
const url = call[0] as string;
|
||||
|
|
@ -574,10 +600,15 @@ describe('reportRunCompleted', () => {
|
|||
{ status: 207 },
|
||||
),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: relayConfig,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: relayConfig,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Relay per-event errors (1)'),
|
||||
);
|
||||
|
|
@ -596,7 +627,7 @@ describe('reportRunCompleted', () => {
|
|||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
artifacts: fatArtifacts,
|
||||
prefs: { metrics: true, content: false, artifactManifest: true },
|
||||
prefs: { metrics: true, content: true, artifactManifest: true },
|
||||
}),
|
||||
{ config: TEST_CONFIG, fetchImpl: fetchSpy as any },
|
||||
);
|
||||
|
|
@ -609,10 +640,15 @@ describe('reportRunCompleted', () => {
|
|||
it('only warns (does not throw) when fetch rejects', async () => {
|
||||
const fetchSpy = vi.fn().mockRejectedValue(new Error('network down'));
|
||||
await expect(
|
||||
reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
}),
|
||||
reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
),
|
||||
).resolves.toBeUndefined();
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Fetch error'),
|
||||
|
|
@ -624,10 +660,15 @@ describe('reportRunCompleted', () => {
|
|||
.fn()
|
||||
.mockRejectedValueOnce(new Error('timeout'))
|
||||
.mockResolvedValueOnce(new Response('{}', { status: 207 }));
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: { ...TEST_CONFIG, retries: 1 },
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: { ...TEST_CONFIG, retries: 1 },
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(fetchSpy).toHaveBeenCalledTimes(2);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
|
@ -636,10 +677,15 @@ describe('reportRunCompleted', () => {
|
|||
const fetchSpy = vi.fn().mockResolvedValue(
|
||||
new Response('rate limited', { status: 429 }),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Ingestion failed 429'),
|
||||
);
|
||||
|
|
@ -664,10 +710,15 @@ describe('reportRunCompleted', () => {
|
|||
{ status: 207 },
|
||||
),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(warnSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Per-event errors (1)'),
|
||||
);
|
||||
|
|
@ -686,10 +737,15 @@ describe('reportRunCompleted', () => {
|
|||
{ status: 207 },
|
||||
),
|
||||
);
|
||||
await reportRunCompleted(makeCtx(), {
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
});
|
||||
await reportRunCompleted(
|
||||
makeCtx({
|
||||
prefs: { metrics: true, content: true, artifactManifest: false },
|
||||
}),
|
||||
{
|
||||
config: TEST_CONFIG,
|
||||
fetchImpl: fetchSpy as any,
|
||||
},
|
||||
);
|
||||
expect(warnSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -24,6 +24,14 @@ export type RawPackagedConfig = {
|
|||
// Baked by tools/pack from OPEN_DESIGN_TELEMETRY_RELAY_URL and forwarded to
|
||||
// the daemon at runtime; Langfuse credentials never ship in packaged config.
|
||||
telemetryRelayUrl?: string;
|
||||
// PostHog product-analytics ingest key, baked by tools/pack from
|
||||
// process.env.POSTHOG_KEY at packaging time. Forwarded to the daemon
|
||||
// sidecar's spawn env as POSTHOG_KEY. `phc_` keys are public ingest
|
||||
// tokens (write-only event capture); embedding them in the bundle is
|
||||
// the PostHog-recommended pattern. The integration short-circuits when
|
||||
// either this is absent or the user has declined Privacy → metrics.
|
||||
posthogKey?: string;
|
||||
posthogHost?: string;
|
||||
webSidecarEntryRelative?: string;
|
||||
webStandaloneRoot?: string;
|
||||
webOutputMode?: string;
|
||||
|
|
@ -38,6 +46,8 @@ export type PackagedConfig = {
|
|||
nodeCommand: string | null;
|
||||
resourceRoot: string;
|
||||
telemetryRelayUrl: string | null;
|
||||
posthogKey: string | null;
|
||||
posthogHost: string | null;
|
||||
webSidecarEntry: string | null;
|
||||
webStandaloneRoot: string | null;
|
||||
webOutputMode: PackagedWebOutputMode;
|
||||
|
|
@ -157,6 +167,8 @@ export async function readPackagedConfig(): Promise<PackagedConfig> {
|
|||
nodeCommand,
|
||||
resourceRoot,
|
||||
telemetryRelayUrl: cleanOptionalString(raw.telemetryRelayUrl),
|
||||
posthogKey: cleanOptionalString(raw.posthogKey),
|
||||
posthogHost: cleanOptionalString(raw.posthogHost),
|
||||
webSidecarEntry,
|
||||
webStandaloneRoot,
|
||||
webOutputMode,
|
||||
|
|
|
|||
|
|
@ -61,6 +61,8 @@ function resolveHeadlessConfig(): PackagedConfig {
|
|||
nodeCommand: null,
|
||||
resourceRoot,
|
||||
telemetryRelayUrl: process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL?.trim() || null,
|
||||
posthogKey: process.env.POSTHOG_KEY?.trim() || null,
|
||||
posthogHost: process.env.POSTHOG_HOST?.trim() || null,
|
||||
webSidecarEntry: null,
|
||||
webStandaloneRoot: null,
|
||||
webOutputMode: "server",
|
||||
|
|
@ -109,6 +111,8 @@ async function main(): Promise<void> {
|
|||
daemonSidecarEntry: config.daemonSidecarEntry,
|
||||
nodeCommand: config.nodeCommand,
|
||||
telemetryRelayUrl: config.telemetryRelayUrl,
|
||||
posthogKey: config.posthogKey,
|
||||
posthogHost: config.posthogHost,
|
||||
// PR #974 round-5 (lefarcen P2): headless packaged mode runs daemon
|
||||
// + web only, no Electron, no privileged shell.openPath surface.
|
||||
// Pinning OD_REQUIRE_DESKTOP_AUTH here would arm a gate no client
|
||||
|
|
|
|||
|
|
@ -85,6 +85,8 @@ async function main(): Promise<void> {
|
|||
daemonSidecarEntry: config.daemonSidecarEntry,
|
||||
nodeCommand: config.nodeCommand,
|
||||
telemetryRelayUrl: config.telemetryRelayUrl,
|
||||
posthogKey: config.posthogKey,
|
||||
posthogHost: config.posthogHost,
|
||||
// PR #974 round-5 (lefarcen P2): the Electron entry runs desktop
|
||||
// main alongside the daemon, so the import-folder gate must be
|
||||
// pinned ON from request 0. See `apps/packaged/src/headless.ts` for
|
||||
|
|
|
|||
|
|
@ -219,6 +219,8 @@ export type PackagedDaemonSpawnEnvOptions = {
|
|||
requireDesktopAuth: boolean;
|
||||
legacyDataDir?: string | null;
|
||||
telemetryRelayUrl?: string | null;
|
||||
posthogKey?: string | null;
|
||||
posthogHost?: string | null;
|
||||
};
|
||||
|
||||
/**
|
||||
|
|
@ -260,6 +262,17 @@ export function buildPackagedDaemonSpawnEnv(
|
|||
...(options.legacyDataDir == null || options.legacyDataDir.length === 0
|
||||
? {}
|
||||
: { OD_LEGACY_DATA_DIR: options.legacyDataDir }),
|
||||
// PostHog analytics ingest key, baked into the bundle at packaging time
|
||||
// by tools/pack. Daemon reads this as POSTHOG_KEY at startup. Absent
|
||||
// for fork builds without the CI secret — the daemon's analytics
|
||||
// module no-ops cleanly in that case, and /api/analytics/config
|
||||
// returns enabled=false regardless of user consent.
|
||||
...(options.posthogKey == null || options.posthogKey.length === 0
|
||||
? {}
|
||||
: { POSTHOG_KEY: options.posthogKey }),
|
||||
...(options.posthogHost == null || options.posthogHost.length === 0
|
||||
? {}
|
||||
: { POSTHOG_HOST: options.posthogHost }),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -339,6 +352,8 @@ export async function startPackagedSidecars(
|
|||
daemonSidecarEntry: string | null;
|
||||
nodeCommand: string | null;
|
||||
telemetryRelayUrl: string | null;
|
||||
posthogKey: string | null;
|
||||
posthogHost: string | null;
|
||||
/**
|
||||
* PR #974 round-5 (lefarcen P2): caller asserts whether a desktop
|
||||
* runtime is being started in this packaged process group. The
|
||||
|
|
@ -375,6 +390,8 @@ export async function startPackagedSidecars(
|
|||
legacyDataDir: process.env.OD_LEGACY_DATA_DIR ?? null,
|
||||
requireDesktopAuth: options.requireDesktopAuth,
|
||||
telemetryRelayUrl: options.telemetryRelayUrl,
|
||||
posthogKey: options.posthogKey,
|
||||
posthogHost: options.posthogHost,
|
||||
}),
|
||||
nodeCommand: options.nodeCommand,
|
||||
paths,
|
||||
|
|
|
|||
|
|
@ -221,6 +221,32 @@ describe('buildPackagedDaemonSpawnEnv', () => {
|
|||
'https://telemetry.open-design.ai/api/langfuse',
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards POSTHOG_KEY/POSTHOG_HOST to the daemon spawn env when baked into the bundle', () => {
|
||||
const env = buildPackagedDaemonSpawnEnv(fakePaths(), {
|
||||
appVersion: null,
|
||||
daemonCliEntry: null,
|
||||
legacyDataDir: null,
|
||||
requireDesktopAuth: true,
|
||||
posthogKey: 'phc_packaged_test',
|
||||
posthogHost: 'https://us.i.posthog.com',
|
||||
});
|
||||
expect(env.POSTHOG_KEY).toBe('phc_packaged_test');
|
||||
expect(env.POSTHOG_HOST).toBe('https://us.i.posthog.com');
|
||||
});
|
||||
|
||||
it('omits POSTHOG_KEY/POSTHOG_HOST for fork builds that lack the secret', () => {
|
||||
const env = buildPackagedDaemonSpawnEnv(fakePaths(), {
|
||||
appVersion: null,
|
||||
daemonCliEntry: null,
|
||||
legacyDataDir: null,
|
||||
requireDesktopAuth: true,
|
||||
posthogKey: null,
|
||||
posthogHost: null,
|
||||
});
|
||||
expect(env.POSTHOG_KEY).toBeUndefined();
|
||||
expect(env.POSTHOG_HOST).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('waitForStatus child-exit fast-fail', () => {
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import type { Metadata, Viewport } from 'next';
|
||||
import type { ReactNode } from 'react';
|
||||
import { I18nProvider } from '../src/i18n';
|
||||
import { AnalyticsProvider } from '../src/analytics/provider';
|
||||
import '../src/index.css';
|
||||
|
||||
export const metadata: Metadata = {
|
||||
|
|
@ -37,7 +38,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
|
|||
<script dangerouslySetInnerHTML={{ __html: themeInitScript }} />
|
||||
</head>
|
||||
<body suppressHydrationWarning>
|
||||
<I18nProvider>{children}</I18nProvider>
|
||||
<I18nProvider>
|
||||
<AnalyticsProvider>{children}</AnalyticsProvider>
|
||||
</I18nProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
"@open-design/sidecar-proto": "workspace:*",
|
||||
"next": "^16.2.5",
|
||||
"openai": "^6.36.0",
|
||||
"posthog-js": "^1.205.0",
|
||||
"react": "^18.3.1",
|
||||
"react-dom": "^18.3.1"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,4 +1,11 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useAnalytics } from './analytics/provider';
|
||||
import { trackAppLaunch, trackProjectCreateResult } from './analytics/events';
|
||||
import { detectClientType, detectLaunchSource } from './analytics/identity';
|
||||
import {
|
||||
projectKindToTracking,
|
||||
fidelityToTracking,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import { EntryView } from './components/EntryView';
|
||||
import type { CreateInput } from './components/NewProjectPanel';
|
||||
import { MemoryToast } from './components/MemoryToast';
|
||||
|
|
@ -194,6 +201,43 @@ export function App() {
|
|||
// can't overwrite the saved state with `''` before hydration lands.
|
||||
const [composioConfigLoading, setComposioConfigLoading] = useState(true);
|
||||
const route = useRoute();
|
||||
const analytics = useAnalytics();
|
||||
|
||||
// app_launch — fired exactly once per page load. Mounting in App, not the
|
||||
// RootLayout, so we capture after the first React tick and the analytics
|
||||
// provider has had a chance to wire its identity. Gated on
|
||||
// `config.telemetry?.metrics` so a freshly-opted-in user gets the event
|
||||
// on their next reload, and a declined user fires nothing.
|
||||
const appLaunchFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (appLaunchFiredRef.current) return;
|
||||
if (config.telemetry?.metrics !== true) return;
|
||||
appLaunchFiredRef.current = true;
|
||||
trackAppLaunch(analytics.track, {
|
||||
page: 'app',
|
||||
launch_source: detectLaunchSource(),
|
||||
platform: detectClientType(),
|
||||
});
|
||||
}, [analytics.track, config.telemetry?.metrics]);
|
||||
|
||||
// Propagate the Privacy toggle through to PostHog without a reload —
|
||||
// posthog-js's opt_out_capturing flips a localStorage flag that makes
|
||||
// every subsequent capture() a no-op. When the user opts back in we
|
||||
// call opt_in_capturing to resume.
|
||||
useEffect(() => {
|
||||
analytics.setConsent(config.telemetry?.metrics === true);
|
||||
}, [analytics.setConsent, config.telemetry?.metrics]);
|
||||
|
||||
// Sync PostHog's distinct_id with the anonymous installationId, both on
|
||||
// first opt-in (when the daemon stamps a fresh id) and on Delete-my-data
|
||||
// rotation (when PrivacySection.tsx generates a new one). posthog-js
|
||||
// caches the previous id in localStorage; identify() alone would stitch
|
||||
// the two ids together, so applyIdentity() does reset() first to
|
||||
// guarantee the new session is fully decoupled from the deleted one.
|
||||
useEffect(() => {
|
||||
if (config.telemetry?.metrics !== true) return;
|
||||
analytics.setIdentity(config.installationId ?? null);
|
||||
}, [analytics.setIdentity, config.installationId, config.telemetry?.metrics]);
|
||||
|
||||
// Sync theme preference to the <html> element so CSS variables pick it up.
|
||||
// useLayoutEffect (vs useEffect) fires before the browser paints, so a
|
||||
|
|
@ -603,7 +647,9 @@ export function App() {
|
|||
);
|
||||
|
||||
const handleCreateProject = useCallback(
|
||||
async (input: CreateInput & { pendingPrompt?: string }) => {
|
||||
async (
|
||||
input: CreateInput & { pendingPrompt?: string; requestId?: string },
|
||||
) => {
|
||||
// Honor an explicit `null` design system — the create panel defaults
|
||||
// to "None" for every kind now, and the user expects that to land
|
||||
// as a no-design-system project rather than silently inheriting the
|
||||
|
|
@ -612,6 +658,10 @@ export function App() {
|
|||
input.pendingPrompt ??
|
||||
(input.metadata?.promptTemplate?.prompt?.trim() || undefined);
|
||||
|
||||
const kind = input.metadata?.kind ?? null;
|
||||
const fidelity = fidelityToTracking(input.metadata?.fidelity ?? null);
|
||||
const creationSource: 'blank' | 'template' | 'zip' | 'folder' =
|
||||
kind === 'template' ? 'template' : 'blank';
|
||||
const result = await createProject({
|
||||
name: input.name,
|
||||
skillId: input.skillId,
|
||||
|
|
@ -619,7 +669,38 @@ export function App() {
|
|||
pendingPrompt: derivedPendingPrompt,
|
||||
metadata: input.metadata,
|
||||
});
|
||||
if (!result) return;
|
||||
if (!result) {
|
||||
trackProjectCreateResult(
|
||||
analytics.track,
|
||||
{
|
||||
page: 'home',
|
||||
area: 'create_panel',
|
||||
action_source: 'create_button',
|
||||
project_id: null,
|
||||
project_kind: projectKindToTracking(kind),
|
||||
creation_source: creationSource,
|
||||
fidelity,
|
||||
result: 'failed',
|
||||
error_code: 'CREATE_REQUEST_FAILED',
|
||||
},
|
||||
{ requestId: input.requestId },
|
||||
);
|
||||
return;
|
||||
}
|
||||
trackProjectCreateResult(
|
||||
analytics.track,
|
||||
{
|
||||
page: 'home',
|
||||
area: 'create_panel',
|
||||
action_source: 'create_button',
|
||||
project_id: result.project.id,
|
||||
project_kind: projectKindToTracking(kind),
|
||||
creation_source: creationSource,
|
||||
fidelity,
|
||||
result: 'success',
|
||||
},
|
||||
{ requestId: input.requestId },
|
||||
);
|
||||
setProjects((curr) => [
|
||||
result.project,
|
||||
...curr.filter((p) => p.id !== result.project.id),
|
||||
|
|
@ -630,7 +711,7 @@ export function App() {
|
|||
fileName: null,
|
||||
});
|
||||
},
|
||||
[],
|
||||
[analytics.track],
|
||||
);
|
||||
|
||||
const handleImportClaudeDesign = useCallback(async (file: File) => {
|
||||
|
|
|
|||
207
apps/web/src/analytics/client.ts
Normal file
207
apps/web/src/analytics/client.ts
Normal file
|
|
@ -0,0 +1,207 @@
|
|||
// PostHog browser client wrapper. Lazy-loads posthog-js only after the
|
||||
// daemon /api/analytics/config response confirms a key is present, so dev
|
||||
// builds and forks impose zero runtime cost. All entry points are
|
||||
// fire-and-forget: capture failures must never propagate to product code.
|
||||
|
||||
import type { PostHog } from 'posthog-js';
|
||||
import {
|
||||
EVENT_SCHEMA_VERSION,
|
||||
type AnalyticsClientType,
|
||||
type AnalyticsConfigResponse,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import { scrubBeforeSend } from './scrub';
|
||||
|
||||
interface AnalyticsContext {
|
||||
anonymousId: string;
|
||||
sessionId: string;
|
||||
clientType: AnalyticsClientType;
|
||||
locale: string;
|
||||
appVersion: string;
|
||||
}
|
||||
|
||||
let client: PostHog | null = null;
|
||||
let initPromise: Promise<PostHog | null> | null = null;
|
||||
let resolvedAnonymousId: string | null = null;
|
||||
|
||||
// Returns the installationId the daemon stamped on /api/analytics/config
|
||||
// after the user opted in via Privacy → "Share usage data". The provider
|
||||
// uses this in preference to its locally-generated UUID so PostHog,
|
||||
// Langfuse, and any future sink share a single anonymous identity.
|
||||
export function getResolvedAnonymousId(): string | null {
|
||||
return resolvedAnonymousId;
|
||||
}
|
||||
|
||||
export async function getAnalyticsClient(
|
||||
context: AnalyticsContext,
|
||||
): Promise<PostHog | null> {
|
||||
if (client) return client;
|
||||
if (initPromise) return initPromise;
|
||||
// PR #1428 reviewer (Siri-Ray): the first /api/analytics/config response
|
||||
// is cached forever if it resolves to null. On first launch before the
|
||||
// user accepts the privacy banner the daemon returns enabled=false, this
|
||||
// promise resolves null, and every later track() call returns the cached
|
||||
// null without re-fetching the now-enabled config. Clear initPromise
|
||||
// whenever the resolution is null so a subsequent setConsent(true) can
|
||||
// trigger a fresh init.
|
||||
const pending = (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/analytics/config');
|
||||
if (!res.ok) return null;
|
||||
const cfg = (await res.json()) as AnalyticsConfigResponse;
|
||||
if (!cfg.enabled || !cfg.key || !cfg.host) return null;
|
||||
const distinctId =
|
||||
(typeof cfg.installationId === 'string' && cfg.installationId) ||
|
||||
context.anonymousId;
|
||||
resolvedAnonymousId = distinctId;
|
||||
const mod = await import('posthog-js');
|
||||
const posthog = mod.default;
|
||||
posthog.init(cfg.key, {
|
||||
api_host: cfg.host,
|
||||
// Identify by installationId when present so daemon-side captures
|
||||
// (which also key off installationId via the analytics context
|
||||
// header) land on the same person record. Falls back to the
|
||||
// locally-generated UUID for the legacy / pre-consent path.
|
||||
bootstrap: { distinctID: distinctId },
|
||||
persistence: 'localStorage',
|
||||
|
||||
// --- Auto-capture layers --------------------------------------
|
||||
// Anonymous diagnostic features (click paths, page transitions,
|
||||
// web vitals, browser errors). The single Privacy → "Share
|
||||
// usage data" toggle gates ALL of these via posthog-js's global
|
||||
// opt_out_capturing() — see applyConsent() below and
|
||||
// AnalyticsProvider's setConsent wiring in App.tsx.
|
||||
autocapture: true,
|
||||
capture_pageview: 'history_change',
|
||||
capture_pageleave: 'if_capture_pageview',
|
||||
capture_dead_clicks: true,
|
||||
capture_performance: {
|
||||
web_vitals: true,
|
||||
network_timing: true,
|
||||
},
|
||||
capture_exceptions: true,
|
||||
|
||||
// --- Privacy defenses -----------------------------------------
|
||||
// 1. scrub.ts runs on every outgoing event and strips $el_text
|
||||
// from input/textarea/contenteditable elements, removes
|
||||
// query strings from URLs, and rewrites absolute filesystem
|
||||
// paths in exception stack traces. Single audit point — new
|
||||
// sensitive surfaces extend the rules there, not by
|
||||
// sprinkling class names through the codebase.
|
||||
// 2. The chat composer textarea keeps a `ph-no-capture` class
|
||||
// as defense in depth: PostHog won't even generate an event
|
||||
// for clicks inside that subtree, so a future scrub regression
|
||||
// can't leak prompt content. Only the most sensitive surface
|
||||
// (prompt body) gets this treatment; everything else relies
|
||||
// on scrub.ts.
|
||||
before_send: scrubBeforeSend,
|
||||
|
||||
// --- Explicitly disabled --------------------------------------
|
||||
// Session replay captures the user's entire screen. For a tool
|
||||
// where prompts, generated artifacts, and provider API keys are
|
||||
// all visible in DOM, this needs an extensive mask catalogue
|
||||
// before we can satisfy the CSV's no-prompt-content rule. Off
|
||||
// until a dedicated consent surface ships.
|
||||
disable_session_recording: true,
|
||||
|
||||
loaded: (instance) => {
|
||||
instance.register({
|
||||
event_schema_version: EVENT_SCHEMA_VERSION,
|
||||
ui_version: context.appVersion,
|
||||
app_version: context.appVersion,
|
||||
client_type: context.clientType,
|
||||
locale: context.locale,
|
||||
session_id: context.sessionId,
|
||||
anonymous_id: distinctId,
|
||||
});
|
||||
},
|
||||
});
|
||||
client = posthog;
|
||||
return posthog;
|
||||
} catch {
|
||||
// Network failure, missing endpoint, third-party fork without keys —
|
||||
// all collapse to the same no-op.
|
||||
return null;
|
||||
}
|
||||
})();
|
||||
initPromise = pending;
|
||||
// Clear the cache as soon as the result is null so a later opt-in retries.
|
||||
void pending.then((result) => {
|
||||
if (!result) initPromise = null;
|
||||
});
|
||||
return pending;
|
||||
}
|
||||
|
||||
// Called from the AnalyticsProvider when the user toggles Privacy →
|
||||
// metrics off so events stop flowing immediately, before the next
|
||||
// reload re-reads /api/analytics/config. The posthog-js client persists
|
||||
// its opt-out flag in localStorage; subsequent capture() calls become
|
||||
// no-ops until the user opts back in.
|
||||
//
|
||||
// `opt_out_capturing()` is a global gate — it halts not only explicit
|
||||
// capture() calls but also autocapture, $pageview, $pageleave,
|
||||
// $exception, web vitals, and dead clicks. One toggle covers every
|
||||
// PostHog code path.
|
||||
//
|
||||
// On opt-out we ALSO call `posthog.reset()` to clear the persisted
|
||||
// `ph_*_posthog` localStorage entry. Without this, the SDK keeps the
|
||||
// old distinct_id; if the user later clicks Delete my data (which
|
||||
// rotates installationId via the daemon) and toggles metrics back on,
|
||||
// posthog-js would still think the user is the old id and stitch the
|
||||
// new session to the deleted identity. reset() prevents that.
|
||||
export function applyConsent(consentGranted: boolean): void {
|
||||
if (!client) return;
|
||||
try {
|
||||
if (consentGranted) {
|
||||
client.opt_in_capturing();
|
||||
} else {
|
||||
client.opt_out_capturing();
|
||||
client.reset();
|
||||
resolvedAnonymousId = null;
|
||||
}
|
||||
} catch {
|
||||
// best-effort — capture should never throw out of this path.
|
||||
}
|
||||
}
|
||||
|
||||
// Called from the AnalyticsProvider when `config.installationId` rotates
|
||||
// (Delete my data). posthog-js's `bootstrap.distinctID` only takes
|
||||
// effect on first init; once the client is alive, identify() is the
|
||||
// only way to switch identities. We pair it with reset() first so any
|
||||
// $device_id stored under the OLD installation is wiped — the new
|
||||
// session is fully decoupled from the deleted one.
|
||||
export function applyIdentity(installationId: string | null): void {
|
||||
if (!client || !installationId) return;
|
||||
if (resolvedAnonymousId === installationId) return;
|
||||
try {
|
||||
client.reset();
|
||||
client.identify(installationId);
|
||||
resolvedAnonymousId = installationId;
|
||||
} catch {
|
||||
// best-effort — never propagate.
|
||||
}
|
||||
}
|
||||
|
||||
export function capture(
|
||||
client: PostHog | null,
|
||||
args: {
|
||||
event: string;
|
||||
properties: Record<string, unknown>;
|
||||
insertId: string;
|
||||
requestId?: string | null;
|
||||
},
|
||||
): void {
|
||||
if (!client) return;
|
||||
try {
|
||||
client.capture(args.event, {
|
||||
...args.properties,
|
||||
event_id: args.insertId,
|
||||
// PostHog's official dedup key. The daemon mirrors result events with
|
||||
// the same $insert_id so duplicates from the dual-side capture pattern
|
||||
// get coalesced server-side.
|
||||
$insert_id: args.insertId,
|
||||
...(args.requestId ? { request_id: args.requestId } : {}),
|
||||
});
|
||||
} catch {
|
||||
// Swallow — analytics failures must not propagate.
|
||||
}
|
||||
}
|
||||
177
apps/web/src/analytics/events.ts
Normal file
177
apps/web/src/analytics/events.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
// Typed track* helpers — one per P0 event. The helpers themselves don't
|
||||
// hit PostHog directly; they marshal the typed props into the loosely-typed
|
||||
// `track()` from the AnalyticsProvider so the event names + property shapes
|
||||
// live in @open-design/contracts/analytics and stay in lockstep with the
|
||||
// daemon side.
|
||||
|
||||
import type {
|
||||
AppLaunchProps,
|
||||
ArtifactExportResultProps,
|
||||
HomeClickCreateButtonProps,
|
||||
HomeViewAssetPanelProps,
|
||||
HomeViewPageProps,
|
||||
ProjectCreateResultProps,
|
||||
RunCreatedProps,
|
||||
RunFinishedProps,
|
||||
SettingsByokTestResultProps,
|
||||
SettingsClickByokFieldProps,
|
||||
SettingsClickByokProviderOptionProps,
|
||||
SettingsClickCliProviderCardProps,
|
||||
SettingsClickExecutionModeTabProps,
|
||||
SettingsCliTestResultProps,
|
||||
SettingsViewProps,
|
||||
StudioClickChatComposerProps,
|
||||
StudioClickShareOptionProps,
|
||||
StudioViewArtifactProps,
|
||||
StudioViewChatPanelProps,
|
||||
} from '@open-design/contracts/analytics';
|
||||
|
||||
type Track = (
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
options?: { requestId?: string; insertId?: string },
|
||||
) => void;
|
||||
|
||||
export function trackAppLaunch(track: Track, props: AppLaunchProps) {
|
||||
track('app_launch', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackHomeViewPage(track: Track, props: HomeViewPageProps) {
|
||||
track('home_view', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackHomeViewAssetPanel(
|
||||
track: Track,
|
||||
props: HomeViewAssetPanelProps,
|
||||
) {
|
||||
track('home_view', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackHomeClickCreateButton(
|
||||
track: Track,
|
||||
props: HomeClickCreateButtonProps,
|
||||
options?: { requestId: string },
|
||||
) {
|
||||
track('home_click', props as unknown as Record<string, unknown>, options);
|
||||
}
|
||||
|
||||
export function trackProjectCreateResult(
|
||||
track: Track,
|
||||
props: ProjectCreateResultProps,
|
||||
options?: { requestId?: string },
|
||||
) {
|
||||
track(
|
||||
'project_create_result',
|
||||
props as unknown as Record<string, unknown>,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function trackSettingsView(track: Track, props: SettingsViewProps) {
|
||||
track('settings_view', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackSettingsClickExecutionModeTab(
|
||||
track: Track,
|
||||
props: SettingsClickExecutionModeTabProps,
|
||||
) {
|
||||
track('settings_click', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackSettingsClickCliProviderCard(
|
||||
track: Track,
|
||||
props: SettingsClickCliProviderCardProps,
|
||||
) {
|
||||
track('settings_click', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackSettingsClickByokField(
|
||||
track: Track,
|
||||
props: SettingsClickByokFieldProps,
|
||||
) {
|
||||
track('settings_click', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackSettingsClickByokProviderOption(
|
||||
track: Track,
|
||||
props: SettingsClickByokProviderOptionProps,
|
||||
) {
|
||||
track('settings_click', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackSettingsCliTestResult(
|
||||
track: Track,
|
||||
props: SettingsCliTestResultProps,
|
||||
) {
|
||||
track(
|
||||
'settings_cli_test_result',
|
||||
props as unknown as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
export function trackSettingsByokTestResult(
|
||||
track: Track,
|
||||
props: SettingsByokTestResultProps,
|
||||
) {
|
||||
track(
|
||||
'settings_byok_test_result',
|
||||
props as unknown as Record<string, unknown>,
|
||||
);
|
||||
}
|
||||
|
||||
export function trackStudioViewChatPanel(
|
||||
track: Track,
|
||||
props: StudioViewChatPanelProps,
|
||||
) {
|
||||
track('studio_view', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackStudioClickChatComposer(
|
||||
track: Track,
|
||||
props: StudioClickChatComposerProps,
|
||||
) {
|
||||
track('studio_click', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackStudioViewArtifact(
|
||||
track: Track,
|
||||
props: StudioViewArtifactProps,
|
||||
) {
|
||||
track('studio_view', props as unknown as Record<string, unknown>);
|
||||
}
|
||||
|
||||
export function trackStudioClickShareOption(
|
||||
track: Track,
|
||||
props: StudioClickShareOptionProps,
|
||||
options?: { requestId: string },
|
||||
) {
|
||||
track('studio_click', props as unknown as Record<string, unknown>, options);
|
||||
}
|
||||
|
||||
export function trackArtifactExportResult(
|
||||
track: Track,
|
||||
props: ArtifactExportResultProps,
|
||||
options?: { requestId?: string },
|
||||
) {
|
||||
track(
|
||||
'artifact_export_result',
|
||||
props as unknown as Record<string, unknown>,
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
export function trackRunCreated(
|
||||
track: Track,
|
||||
props: RunCreatedProps,
|
||||
options?: { requestId?: string },
|
||||
) {
|
||||
track('run_created', props as unknown as Record<string, unknown>, options);
|
||||
}
|
||||
|
||||
export function trackRunFinished(
|
||||
track: Track,
|
||||
props: RunFinishedProps,
|
||||
options?: { requestId?: string },
|
||||
) {
|
||||
track('run_finished', props as unknown as Record<string, unknown>, options);
|
||||
}
|
||||
91
apps/web/src/analytics/identity.ts
Normal file
91
apps/web/src/analytics/identity.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
// Browser-side identity bookkeeping for PostHog product analytics. Designed
|
||||
// so it stays SSR-safe: every entry point guards window/localStorage access
|
||||
// and falls back to a deterministic-enough fake id under jsdom and Next.js
|
||||
// pre-render. The daemon mirrors these values via the x-od-analytics-*
|
||||
// headers (see @open-design/contracts/analytics).
|
||||
|
||||
import type { AnalyticsClientType } from '@open-design/contracts/analytics';
|
||||
|
||||
const ANONYMOUS_ID_KEY = 'open-design:analytics.anonymous_id';
|
||||
const SESSION_ID_KEY = 'open-design:analytics.session_id';
|
||||
|
||||
function randomUuid(): string {
|
||||
// Prefer the standard crypto.randomUUID — present in every modern browser
|
||||
// and Node 19+. The Math.random fallback is for jsdom builds that ship
|
||||
// without crypto.randomUUID and for very old browsers; it does not need
|
||||
// to be cryptographically strong, only unique-enough for a session id.
|
||||
const c: Crypto | undefined =
|
||||
typeof globalThis !== 'undefined' ? globalThis.crypto : undefined;
|
||||
if (c?.randomUUID) return c.randomUUID();
|
||||
return `xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx`.replace(/[xy]/g, (ch) => {
|
||||
const r = (Math.random() * 16) | 0;
|
||||
const v = ch === 'x' ? r : (r & 0x3) | 0x8;
|
||||
return v.toString(16);
|
||||
});
|
||||
}
|
||||
|
||||
export function getAnonymousId(): string {
|
||||
if (typeof window === 'undefined') return 'ssr';
|
||||
try {
|
||||
const existing = window.localStorage.getItem(ANONYMOUS_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const fresh = randomUuid();
|
||||
window.localStorage.setItem(ANONYMOUS_ID_KEY, fresh);
|
||||
return fresh;
|
||||
} catch {
|
||||
// Privacy mode or quota — fall back to a per-load id; we'd rather lose
|
||||
// cross-session continuity than throw out of an analytics path.
|
||||
return randomUuid();
|
||||
}
|
||||
}
|
||||
|
||||
export function getSessionId(): string {
|
||||
if (typeof window === 'undefined') return 'ssr';
|
||||
try {
|
||||
const existing = window.sessionStorage.getItem(SESSION_ID_KEY);
|
||||
if (existing) return existing;
|
||||
const fresh = randomUuid();
|
||||
window.sessionStorage.setItem(SESSION_ID_KEY, fresh);
|
||||
return fresh;
|
||||
} catch {
|
||||
return randomUuid();
|
||||
}
|
||||
}
|
||||
|
||||
// Desktop packaged builds set this marker on window in a preload script so
|
||||
// the same web bundle can distinguish desktop runs from browser visits.
|
||||
// Falls back to 'web' when the marker isn't present.
|
||||
export function detectClientType(): AnalyticsClientType {
|
||||
if (typeof window === 'undefined') return 'web';
|
||||
const w = window as Window & {
|
||||
__OD_CLIENT_TYPE__?: AnalyticsClientType;
|
||||
electronAPI?: unknown;
|
||||
};
|
||||
if (w.__OD_CLIENT_TYPE__ === 'desktop') return 'desktop';
|
||||
if (w.electronAPI) return 'desktop';
|
||||
return 'web';
|
||||
}
|
||||
|
||||
// Read the launch_source for app_launch. Best-effort: PerformanceNavigation
|
||||
// type 'reload' / 'back_forward' are mapped to 'reload'; deep links (paths
|
||||
// other than '/') are 'deeplink'; otherwise 'direct'. SSR returns 'unknown'.
|
||||
export function detectLaunchSource():
|
||||
| 'direct'
|
||||
| 'deeplink'
|
||||
| 'reload'
|
||||
| 'unknown' {
|
||||
if (typeof window === 'undefined') return 'unknown';
|
||||
try {
|
||||
const entries = performance.getEntriesByType?.(
|
||||
'navigation',
|
||||
) as PerformanceNavigationTiming[] | undefined;
|
||||
const nav = entries?.[0];
|
||||
if (nav?.type === 'reload' || nav?.type === 'back_forward') return 'reload';
|
||||
if (window.location.pathname && window.location.pathname !== '/') {
|
||||
return 'deeplink';
|
||||
}
|
||||
return 'direct';
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
310
apps/web/src/analytics/provider.tsx
Normal file
310
apps/web/src/analytics/provider.tsx
Normal file
|
|
@ -0,0 +1,310 @@
|
|||
'use client';
|
||||
|
||||
import {
|
||||
createContext,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
type ReactNode,
|
||||
} from 'react';
|
||||
import { useI18n } from '../i18n';
|
||||
import {
|
||||
ANALYTICS_HEADER_ANONYMOUS_ID,
|
||||
ANALYTICS_HEADER_CLIENT_TYPE,
|
||||
ANALYTICS_HEADER_LOCALE,
|
||||
ANALYTICS_HEADER_REQUEST_ID,
|
||||
ANALYTICS_HEADER_SESSION_ID,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import {
|
||||
applyConsent,
|
||||
applyIdentity,
|
||||
capture,
|
||||
getAnalyticsClient,
|
||||
getResolvedAnonymousId,
|
||||
} from './client';
|
||||
import {
|
||||
detectClientType,
|
||||
getAnonymousId,
|
||||
getSessionId,
|
||||
} from './identity';
|
||||
|
||||
interface AnalyticsContextValue {
|
||||
// The track helper accepts any event/props pair; per-event safety is
|
||||
// enforced by the typed wrappers in events.ts that consumers use.
|
||||
track: (
|
||||
event: string,
|
||||
properties: Record<string, unknown>,
|
||||
options?: { requestId?: string; insertId?: string },
|
||||
) => void;
|
||||
// Toggle PostHog capture without unmounting the provider. App.tsx calls
|
||||
// this from a useEffect that watches `config.telemetry?.metrics` so a
|
||||
// Privacy toggle takes effect immediately, not on next reload.
|
||||
setConsent: (granted: boolean) => void;
|
||||
// Switch PostHog's distinct_id to the new installationId after a
|
||||
// Delete-my-data rotation. App.tsx watches `config.installationId` and
|
||||
// calls this whenever the daemon rotates it; PostHog's localStorage
|
||||
// state is reset() then identify()'d to the new id so the next event
|
||||
// batch is fully decoupled from the deleted identity.
|
||||
setIdentity: (installationId: string | null) => void;
|
||||
anonymousId: string;
|
||||
sessionId: string;
|
||||
newRequestId: () => string;
|
||||
}
|
||||
|
||||
const Ctx = createContext<AnalyticsContextValue | null>(null);
|
||||
|
||||
// PR #1428 reviewer (Siri-Ray): the previous `url.includes('/api/')` check
|
||||
// matched absolute third-party URLs (https://provider.example/api/x), which
|
||||
// would leak our analytics headers outside the daemon boundary. This helper
|
||||
// is strictly same-origin + /api/ prefix and is shared by both the global
|
||||
// fetch wrapper and the per-track request_id wrapper.
|
||||
function isSameOriginApiCall(url: unknown): boolean {
|
||||
if (typeof url !== 'string') return false;
|
||||
if (url.startsWith('/api/')) return true;
|
||||
if (typeof window === 'undefined') return false;
|
||||
try {
|
||||
const parsed = new URL(url, window.location.origin);
|
||||
return (
|
||||
parsed.origin === window.location.origin &&
|
||||
parsed.pathname.startsWith('/api/')
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// App version is read from a runtime endpoint rather than at build time so
|
||||
// the same web bundle reports the daemon-pinned version even when running
|
||||
// against a newer/older daemon during dev. Falls back to '0.0.0' until the
|
||||
// fetch resolves; analytics events fired before resolution simply have a
|
||||
// stale version string and are not re-emitted.
|
||||
function useAppVersion(): string {
|
||||
const versionRef = useRef('0.0.0');
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
try {
|
||||
const res = await fetch('/api/version');
|
||||
if (!res.ok) return;
|
||||
const body = (await res.json()) as { version?: { version?: string } };
|
||||
if (cancelled) return;
|
||||
if (body?.version?.version) versionRef.current = body.version.version;
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, []);
|
||||
return versionRef.current;
|
||||
}
|
||||
|
||||
export function AnalyticsProvider({ children }: { children: ReactNode }) {
|
||||
const { locale } = useI18n();
|
||||
const appVersion = useAppVersion();
|
||||
// Identity is computed once on mount; locale flows in as a register update
|
||||
// when the user switches locales so subsequent events carry the fresh
|
||||
// value without re-initializing the PostHog client.
|
||||
const identity = useMemo(
|
||||
() => ({
|
||||
anonymousId: getAnonymousId(),
|
||||
sessionId: getSessionId(),
|
||||
clientType: detectClientType(),
|
||||
}),
|
||||
[],
|
||||
);
|
||||
|
||||
// Once the PostHog client has talked to /api/analytics/config, the
|
||||
// installationId the daemon stamped becomes the canonical anonymous id —
|
||||
// shared with Langfuse. The fetch wrapper below picks this up so daemon
|
||||
// server-side captures end up on the same person record.
|
||||
const [resolvedAnonId, setResolvedAnonId] = useState<string | null>(null);
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void getAnalyticsClient({
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
locale,
|
||||
appVersion,
|
||||
}).then(() => {
|
||||
if (cancelled) return;
|
||||
const resolved = getResolvedAnonymousId();
|
||||
if (resolved) setResolvedAnonId(resolved);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [identity, locale, appVersion]);
|
||||
|
||||
// Wrap window.fetch so every same-origin /api/* request carries the
|
||||
// analytics context for the daemon to mirror result events back with the
|
||||
// matching distinct id.
|
||||
//
|
||||
// Gated on `resolvedAnonId`: PR #1428 reviewer (codex-connector,
|
||||
// lefarcen) — when Privacy → metrics is off, /api/analytics/config
|
||||
// returns enabled=false → resolvedAnonId stays null → header injection
|
||||
// never installs. That way an opted-out user can't produce daemon-side
|
||||
// PostHog events even though POSTHOG_KEY exists in the daemon env
|
||||
// (daemon's readAnalyticsContext treats the header as consent).
|
||||
useEffect(() => {
|
||||
if (typeof window === 'undefined') return;
|
||||
if (!resolvedAnonId) return;
|
||||
const original = window.fetch;
|
||||
const baseHeaders: Record<string, string> = {
|
||||
[ANALYTICS_HEADER_ANONYMOUS_ID]: resolvedAnonId,
|
||||
[ANALYTICS_HEADER_SESSION_ID]: identity.sessionId,
|
||||
[ANALYTICS_HEADER_CLIENT_TYPE]: identity.clientType,
|
||||
};
|
||||
window.fetch = async (input, init) => {
|
||||
const url = typeof input === 'string' ? input : input instanceof URL ? input.href : input.url;
|
||||
if (!isSameOriginApiCall(url)) return original(input, init);
|
||||
const merged: HeadersInit = {
|
||||
...baseHeaders,
|
||||
[ANALYTICS_HEADER_LOCALE]: locale,
|
||||
...(init?.headers ?? {}),
|
||||
};
|
||||
return original(input, { ...(init ?? {}), headers: merged });
|
||||
};
|
||||
return () => {
|
||||
window.fetch = original;
|
||||
};
|
||||
}, [identity, locale, resolvedAnonId]);
|
||||
|
||||
// Update PostHog's super-properties whenever locale changes so subsequent
|
||||
// captures carry the right `locale` field without us threading it through
|
||||
// every track call site.
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const client = await getAnalyticsClient({
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
locale: locale,
|
||||
appVersion,
|
||||
});
|
||||
if (cancelled || !client) return;
|
||||
try {
|
||||
client.register({ locale: locale, app_version: appVersion, ui_version: appVersion });
|
||||
} catch {
|
||||
// Best-effort.
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [identity, locale, appVersion]);
|
||||
|
||||
const track = useCallback<AnalyticsContextValue['track']>(
|
||||
(event, properties, options) => {
|
||||
const insertId = options?.insertId ?? crypto.randomUUID();
|
||||
const requestId = options?.requestId ?? null;
|
||||
// Attach request_id to the in-flight fetch wrapper too, so the daemon
|
||||
// can stitch click→result pairs without the caller threading it.
|
||||
if (typeof window !== 'undefined' && requestId) {
|
||||
try {
|
||||
const baseFetch = window.fetch;
|
||||
const wrapped: typeof fetch = async (input, init) => {
|
||||
const url =
|
||||
typeof input === 'string'
|
||||
? input
|
||||
: input instanceof URL
|
||||
? input.href
|
||||
: input.url;
|
||||
if (!isSameOriginApiCall(url)) return baseFetch(input, init);
|
||||
const merged: HeadersInit = {
|
||||
[ANALYTICS_HEADER_REQUEST_ID]: requestId,
|
||||
...(init?.headers ?? {}),
|
||||
};
|
||||
return baseFetch(input, { ...(init ?? {}), headers: merged });
|
||||
};
|
||||
// Single-shot: restore after next microtask so only the originating
|
||||
// fetch picks up the request_id header.
|
||||
window.fetch = wrapped;
|
||||
queueMicrotask(() => {
|
||||
window.fetch = baseFetch;
|
||||
});
|
||||
} catch {
|
||||
// Best-effort header injection.
|
||||
}
|
||||
}
|
||||
void (async () => {
|
||||
const client = await getAnalyticsClient({
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
locale: locale,
|
||||
appVersion,
|
||||
});
|
||||
capture(client, { event, properties, insertId, requestId });
|
||||
})();
|
||||
},
|
||||
[identity, locale, appVersion],
|
||||
);
|
||||
|
||||
const value = useMemo<AnalyticsContextValue>(
|
||||
() => ({
|
||||
track,
|
||||
setConsent: (granted: boolean) => {
|
||||
applyConsent(granted);
|
||||
if (!granted) {
|
||||
// Clear the header-injection state so the fetch wrapper effect
|
||||
// tears down its hook on the next render. Daemon-side captures
|
||||
// will see no x-od-analytics-* headers → readAnalyticsContext
|
||||
// returns null → no events emitted, even if POSTHOG_KEY is set.
|
||||
setResolvedAnonId(null);
|
||||
} else {
|
||||
// Re-trigger client init: getAnalyticsClient's null-cache fix
|
||||
// (client.ts) allows a fresh /api/analytics/config fetch when
|
||||
// the previous response was enabled=false. Resolved id propagates
|
||||
// into the wrapper via setResolvedAnonId below.
|
||||
void getAnalyticsClient({
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
clientType: identity.clientType,
|
||||
locale,
|
||||
appVersion,
|
||||
}).then(() => {
|
||||
const resolved = getResolvedAnonymousId();
|
||||
if (resolved) setResolvedAnonId(resolved);
|
||||
});
|
||||
}
|
||||
},
|
||||
setIdentity: (installationId: string | null) => {
|
||||
applyIdentity(installationId);
|
||||
// Keep the fetch wrapper's header in sync so daemon-side captures
|
||||
// start using the new id immediately, not after the next reload.
|
||||
if (installationId) setResolvedAnonId(installationId);
|
||||
},
|
||||
anonymousId: identity.anonymousId,
|
||||
sessionId: identity.sessionId,
|
||||
newRequestId: () => crypto.randomUUID(),
|
||||
}),
|
||||
[track, identity, locale, appVersion],
|
||||
);
|
||||
|
||||
return <Ctx.Provider value={value}>{children}</Ctx.Provider>;
|
||||
}
|
||||
|
||||
export function useAnalytics(): AnalyticsContextValue {
|
||||
const value = useContext(Ctx);
|
||||
if (!value) {
|
||||
// No-op stub for unit tests / SSR / consumers rendered outside the
|
||||
// provider tree. Returning a working stub keeps every call site free of
|
||||
// null checks.
|
||||
return {
|
||||
track: () => undefined,
|
||||
setConsent: () => undefined,
|
||||
setIdentity: () => undefined,
|
||||
anonymousId: 'unmounted',
|
||||
sessionId: 'unmounted',
|
||||
newRequestId: () => crypto.randomUUID(),
|
||||
};
|
||||
}
|
||||
return value;
|
||||
}
|
||||
155
apps/web/src/analytics/scrub.ts
Normal file
155
apps/web/src/analytics/scrub.ts
Normal file
|
|
@ -0,0 +1,155 @@
|
|||
// Single point of privacy scrubbing for outgoing PostHog events.
|
||||
// Wired in via posthog-js's `before_send` hook so every autocapture,
|
||||
// pageview, exception, web-vital, etc. passes through this function
|
||||
// before reaching the network. Returning null drops the event.
|
||||
//
|
||||
// The masking rules here intentionally over-redact rather than rely on
|
||||
// per-element `ph-no-capture` marks scattered across the codebase. A
|
||||
// single function is easier to audit and harder to forget when a new
|
||||
// sensitive surface ships.
|
||||
|
||||
import type { CaptureResult } from 'posthog-js';
|
||||
|
||||
// Tags whose text content can carry user-typed values. PostHog autocapture
|
||||
// does not capture input/textarea `value` properties by default, but it
|
||||
// does capture `$el_text` (element.textContent) — for a <textarea> with
|
||||
// typed content that becomes the prompt body. Strip eagerly.
|
||||
const TEXT_BEARING_TAGS = new Set(['input', 'textarea']);
|
||||
|
||||
function scrubElementsChain(
|
||||
elements: Array<Record<string, unknown>>,
|
||||
): Array<Record<string, unknown>> {
|
||||
return elements.map((el) => {
|
||||
const tag = typeof el.tag_name === 'string' ? el.tag_name.toLowerCase() : '';
|
||||
const contentEditable =
|
||||
typeof el.attr__contenteditable === 'string' &&
|
||||
el.attr__contenteditable !== 'false';
|
||||
const isPasswordInput =
|
||||
tag === 'input' &&
|
||||
typeof el.attr__type === 'string' &&
|
||||
el.attr__type.toLowerCase() === 'password';
|
||||
const shouldScrub =
|
||||
TEXT_BEARING_TAGS.has(tag) || contentEditable || isPasswordInput;
|
||||
if (!shouldScrub) return el;
|
||||
const cleaned: Record<string, unknown> = { ...el };
|
||||
delete cleaned.$el_text;
|
||||
delete cleaned.attr__value;
|
||||
delete cleaned.attr__placeholder;
|
||||
delete cleaned.attr__aria_label;
|
||||
delete cleaned.text;
|
||||
return cleaned;
|
||||
});
|
||||
}
|
||||
|
||||
// Drop query-string and fragment from URLs in pageview / pageleave / nav
|
||||
// events. Pathnames are kept (they're typically `/projects/<uuid>`,
|
||||
// non-sensitive) but any `?q=…` we accidentally introduce in the future
|
||||
// won't leak.
|
||||
function scrubUrl(url: unknown): unknown {
|
||||
if (typeof url !== 'string') return url;
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return `${parsed.origin}${parsed.pathname}`;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
// Rewrite absolute filesystem paths in exception stack traces. Packaged
|
||||
// builds expose `file:///Applications/Open Design.app/Contents/Resources/…`
|
||||
// which leaks both the install root and the user's home dir in homebrew /
|
||||
// custom installs. Reduce to the repo-relative tail.
|
||||
function scrubFilePath(value: unknown): unknown {
|
||||
if (typeof value !== 'string') return value;
|
||||
// file:///abs/path/.../apps/web/src/foo.tsx → app://apps/web/src/foo.tsx
|
||||
// /Users/<user>/.../apps/web/src/foo.tsx → app://apps/web/src/foo.tsx
|
||||
return value.replace(
|
||||
/(?:file:\/\/)?[^\s]*\/((?:apps|packages|tools)\/[^\s]+)/g,
|
||||
'app://$1',
|
||||
);
|
||||
}
|
||||
|
||||
function scrubExceptionList(
|
||||
list: Array<Record<string, unknown>>,
|
||||
): Array<Record<string, unknown>> {
|
||||
return list.map((entry) => {
|
||||
const next = { ...entry };
|
||||
const stack = next.stacktrace as
|
||||
| { frames?: Array<Record<string, unknown>> }
|
||||
| undefined;
|
||||
if (stack?.frames && Array.isArray(stack.frames)) {
|
||||
next.stacktrace = {
|
||||
...stack,
|
||||
frames: stack.frames.map((frame) => ({
|
||||
...frame,
|
||||
filename: scrubFilePath(frame.filename),
|
||||
abs_path: scrubFilePath(frame.abs_path),
|
||||
})),
|
||||
};
|
||||
}
|
||||
if (typeof next.mechanism === 'object' && next.mechanism !== null) {
|
||||
// Mechanism source URL can also be a file:// — same scrub.
|
||||
const mech = next.mechanism as Record<string, unknown>;
|
||||
if (typeof mech.source === 'string') {
|
||||
mech.source = scrubFilePath(mech.source);
|
||||
}
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
|
||||
// Some events we don't need at all; suppressing them keeps the volume
|
||||
// below PostHog's free-tier cap and avoids capturing surfaces that don't
|
||||
// add product insight.
|
||||
const SUPPRESSED_EVENTS = new Set<string>([
|
||||
// PostHog's $opt_in event records the act of opting in. We already
|
||||
// emit explicit consent via the toggle handler in App.tsx; the
|
||||
// duplicate is noise.
|
||||
'$opt_in',
|
||||
]);
|
||||
|
||||
export function scrubBeforeSend(cr: CaptureResult | null): CaptureResult | null {
|
||||
if (!cr) return null;
|
||||
if (SUPPRESSED_EVENTS.has(cr.event)) return null;
|
||||
|
||||
const props = (cr.properties ?? {}) as Record<string, unknown>;
|
||||
|
||||
// Autocapture / rageclick / dead-click carry $elements (legacy) or
|
||||
// $elements_chain (newer). Both shapes get the same scrub.
|
||||
const elementBearing =
|
||||
cr.event === '$autocapture' ||
|
||||
cr.event === '$rageclick' ||
|
||||
cr.event === '$dead_click' ||
|
||||
cr.event === '$copy_autocapture';
|
||||
if (elementBearing) {
|
||||
const elements = props.$elements;
|
||||
if (Array.isArray(elements)) {
|
||||
props.$elements = scrubElementsChain(elements as Array<Record<string, unknown>>);
|
||||
}
|
||||
}
|
||||
|
||||
// URL-bearing events.
|
||||
if (typeof props.$current_url === 'string') {
|
||||
props.$current_url = scrubUrl(props.$current_url);
|
||||
}
|
||||
if (typeof props.$pathname === 'string') {
|
||||
// Pathnames in this app are routing slugs (/projects/<uuid>) — keep
|
||||
// as-is. Query strings live on $current_url, not $pathname.
|
||||
}
|
||||
if (typeof props.$referrer === 'string') {
|
||||
props.$referrer = scrubUrl(props.$referrer);
|
||||
}
|
||||
|
||||
// Exceptions: scrub file paths in stack frames.
|
||||
if (cr.event === '$exception') {
|
||||
const list = props.$exception_list;
|
||||
if (Array.isArray(list)) {
|
||||
props.$exception_list = scrubExceptionList(list as Array<Record<string, unknown>>);
|
||||
}
|
||||
if (typeof props.$exception_source === 'string') {
|
||||
props.$exception_source = scrubFilePath(props.$exception_source);
|
||||
}
|
||||
}
|
||||
|
||||
return { ...cr, properties: props };
|
||||
}
|
||||
|
|
@ -8,6 +8,11 @@ import {
|
|||
} from "react";
|
||||
import { useT } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import {
|
||||
trackStudioClickChatComposer,
|
||||
trackStudioViewChatPanel,
|
||||
} from '../analytics/events';
|
||||
import { projectRawUrl, uploadProjectFiles, openFolderDialog } from "../providers/registry";
|
||||
import { patchProject } from "../state/projects";
|
||||
import { fetchMcpServers } from "../state/mcp";
|
||||
|
|
@ -133,7 +138,26 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
ref
|
||||
) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const [draft, setDraft] = useState(initialDraft ?? "");
|
||||
|
||||
// studio_view chat_panel — fire once per ChatComposer mount per project.
|
||||
// The composer is the dominant chat surface; firing here keeps the
|
||||
// event close to where the user actually sees the panel rather than at
|
||||
// the higher-level ProjectView layer which mounts before the composer.
|
||||
const studioViewFiredRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
if (studioViewFiredRef.current === projectId) return;
|
||||
studioViewFiredRef.current = projectId;
|
||||
trackStudioViewChatPanel(analytics.track, {
|
||||
page: 'studio',
|
||||
area: 'chat_panel',
|
||||
element: 'chat_tab',
|
||||
view_type: 'panel',
|
||||
source: 'open_project',
|
||||
conversation_id: null,
|
||||
});
|
||||
}, [projectId, analytics.track]);
|
||||
const [staged, setStaged] = useState<ChatAttachment[]>([]);
|
||||
// Skills the user has @-mentioned for this turn. We dedupe on id and
|
||||
// strip the chip when the user removes the corresponding `@<skill>`
|
||||
|
|
@ -799,6 +823,10 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
<textarea
|
||||
ref={textareaRef}
|
||||
data-testid="chat-composer-input"
|
||||
// ph-no-capture: prompt content is the most sensitive
|
||||
// surface in the product. PostHog autocapture skips this
|
||||
// element + subtree entirely.
|
||||
className="ph-no-capture"
|
||||
value={draft}
|
||||
placeholder={t('chat.composerPlaceholder')}
|
||||
onChange={handleChange}
|
||||
|
|
@ -1007,7 +1035,17 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
<button
|
||||
className="icon-btn"
|
||||
data-testid="chat-attach"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
onClick={() => {
|
||||
trackStudioClickChatComposer(analytics.track, {
|
||||
page: 'studio',
|
||||
area: 'chat_composer',
|
||||
element: 'attachment_button',
|
||||
action: 'click_composer_control',
|
||||
user_query_tokens: Math.ceil(draft.length / 4),
|
||||
has_attachment: staged.length > 0 || commentAttachments.length > 0,
|
||||
});
|
||||
fileInputRef.current?.click();
|
||||
}}
|
||||
title={t('chat.attachTitle')}
|
||||
disabled={uploading}
|
||||
aria-label={t('chat.attachAria')}
|
||||
|
|
@ -1033,7 +1071,18 @@ export const ChatComposer = forwardRef<ChatComposerHandle, Props>(
|
|||
type="button"
|
||||
className="composer-send"
|
||||
data-testid="chat-send"
|
||||
onClick={() => void submit()}
|
||||
onClick={() => {
|
||||
trackStudioClickChatComposer(analytics.track, {
|
||||
page: 'studio',
|
||||
area: 'chat_composer',
|
||||
element: 'send_button',
|
||||
action: 'click_composer_control',
|
||||
user_query_tokens: Math.ceil(draft.length / 4),
|
||||
has_attachment:
|
||||
staged.length > 0 || commentAttachments.length > 0,
|
||||
});
|
||||
void submit();
|
||||
}}
|
||||
disabled={sendDisabled || (!draft.trim() && commentAttachments.length === 0)}
|
||||
>
|
||||
<Icon name="send" size={13} />
|
||||
|
|
|
|||
|
|
@ -1,5 +1,11 @@
|
|||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { ConnectorDetail, ConnectorStatusResponse, ImportFolderResponse } from '@open-design/contracts';
|
||||
import { topTabToTracking } from '@open-design/contracts/analytics';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import {
|
||||
trackHomeViewAssetPanel,
|
||||
trackHomeViewPage,
|
||||
} from '../analytics/events';
|
||||
import { useT } from '../i18n';
|
||||
import {
|
||||
DEFAULT_AUDIO_MODEL,
|
||||
|
|
@ -249,7 +255,80 @@ export function EntryView({
|
|||
onTogglePet,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const [topTab, setTopTab] = useState<TopTab>('designs');
|
||||
|
||||
// home_view (page) — fire once per EntryView mount with a snapshot of the
|
||||
// CLI / BYOK availability so the new-user activation funnel can read
|
||||
// execution_availability without needing a server-side join. Gated on
|
||||
// agents loading so has_available_cli isn't transiently false.
|
||||
const homeViewFiredRef = useRef(false);
|
||||
useEffect(() => {
|
||||
if (homeViewFiredRef.current) return;
|
||||
if (skillsLoading) return;
|
||||
homeViewFiredRef.current = true;
|
||||
const hasCli = agents.some((a) => a.available);
|
||||
const hasByok = Boolean(config.apiKey?.trim());
|
||||
const configuredProviderType: 'local_cli' | 'byok' | 'both' | 'none' | 'unknown' = hasCli && hasByok
|
||||
? 'both'
|
||||
: hasCli
|
||||
? 'local_cli'
|
||||
: hasByok
|
||||
? 'byok'
|
||||
: 'none';
|
||||
trackHomeViewPage(analytics.track, {
|
||||
page: 'home',
|
||||
has_available_cli: hasCli,
|
||||
has_available_byok: hasByok,
|
||||
configured_provider_type: configuredProviderType,
|
||||
execution_availability: hasCli || hasByok ? 'available' : 'unavailable',
|
||||
});
|
||||
}, [skillsLoading, agents, config.apiKey, analytics.track]);
|
||||
|
||||
// home_view (asset_panel) — fires on tab change (and on initial render
|
||||
// once the tab's underlying resource has loaded) so the funnel can
|
||||
// attribute downstream clicks to the correct surface.
|
||||
const assetTabFiredRef = useRef<TopTab | null>(null);
|
||||
useEffect(() => {
|
||||
if (assetTabFiredRef.current === topTab) return;
|
||||
// Gate per-tab on its source resource so result_count isn't reported as
|
||||
// 0 just because the fetch hasn't landed yet.
|
||||
const tabLoading: Record<TopTab, boolean | undefined> = {
|
||||
designs: projectsLoading,
|
||||
templates: promptTemplatesLoading,
|
||||
'design-systems': designSystemsLoading,
|
||||
'image-templates': promptTemplatesLoading,
|
||||
'video-templates': promptTemplatesLoading,
|
||||
};
|
||||
if (tabLoading[topTab]) return;
|
||||
assetTabFiredRef.current = topTab;
|
||||
const counts: Record<TopTab, number> = {
|
||||
designs: projects.length,
|
||||
templates: promptTemplates.length,
|
||||
'design-systems': designSystems.length,
|
||||
'image-templates': promptTemplates.filter((p) => p.surface === 'image').length,
|
||||
'video-templates': promptTemplates.filter((p) => p.surface === 'video').length,
|
||||
};
|
||||
const count = counts[topTab] ?? 0;
|
||||
trackHomeViewAssetPanel(analytics.track, {
|
||||
page: 'home',
|
||||
area: 'asset_panel',
|
||||
element: 'tab_content',
|
||||
view_type: 'tab_content',
|
||||
target_id: topTabToTracking(topTab),
|
||||
result_count: count,
|
||||
is_empty: count === 0,
|
||||
});
|
||||
}, [
|
||||
topTab,
|
||||
analytics.track,
|
||||
projects.length,
|
||||
promptTemplates,
|
||||
designSystems.length,
|
||||
projectsLoading,
|
||||
designSystemsLoading,
|
||||
promptTemplatesLoading,
|
||||
]);
|
||||
const [previewSystemId, setPreviewSystemId] = useState<string | null>(null);
|
||||
const [previewPromptTemplate, setPreviewPromptTemplate] =
|
||||
useState<PromptTemplateSummary | null>(null);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,16 @@
|
|||
import { useEffect, useId, useMemo, useRef, useState, type CSSProperties, type MouseEvent as ReactMouseEvent, type ReactNode } from 'react';
|
||||
import { createPortal } from 'react-dom';
|
||||
import { APP_CHROME_FILE_ACTIONS_ID } from './AppChromeHeader';
|
||||
import {
|
||||
anonymizeArtifactId,
|
||||
artifactKindToTracking,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import {
|
||||
trackArtifactExportResult,
|
||||
trackStudioClickShareOption,
|
||||
trackStudioViewArtifact,
|
||||
} from '../analytics/events';
|
||||
import { MarkdownRenderer, artifactRendererRegistry } from '../artifacts/renderer-registry';
|
||||
import { renderMarkdownToSafeHtml } from '../artifacts/markdown';
|
||||
import { useT, useI18n } from '../i18n';
|
||||
|
|
@ -458,6 +468,29 @@ export function FileViewer({
|
|||
isDeckHint: Boolean(isDeck),
|
||||
});
|
||||
|
||||
// studio_view artifact — fire once per (project, file) pair so the
|
||||
// activation funnel can attribute "user opened the produced artifact"
|
||||
// even when the sub-viewer below is HtmlViewer / MarkdownViewer / etc.
|
||||
// artifact_id is anonymized to satisfy the CSV's no-filename rule.
|
||||
const analytics = useAnalytics();
|
||||
const studioViewKeyRef = useRef<string | null>(null);
|
||||
useEffect(() => {
|
||||
const key = `${projectId}::${file.name}`;
|
||||
if (studioViewKeyRef.current === key) return;
|
||||
studioViewKeyRef.current = key;
|
||||
trackStudioViewArtifact(analytics.track, {
|
||||
page: 'studio',
|
||||
area: 'artifact',
|
||||
element: 'artifact_view',
|
||||
view_type: 'artifact',
|
||||
artifact_id: anonymizeArtifactId({ projectId, fileName: file.name }),
|
||||
artifact_kind: artifactKindToTracking({
|
||||
rendererId: rendererMatch?.renderer.id ?? null,
|
||||
fileKind: file.kind ?? null,
|
||||
}),
|
||||
});
|
||||
}, [projectId, file.name, file.kind, rendererMatch?.renderer.id, analytics.track]);
|
||||
|
||||
if (rendererMatch?.renderer.id === 'html' || rendererMatch?.renderer.id === 'deck-html') {
|
||||
return (
|
||||
<HtmlViewer
|
||||
|
|
@ -3309,6 +3342,70 @@ function HtmlViewer({
|
|||
onFileSaved?: () => Promise<void> | void;
|
||||
}) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
// Shared helper for the share menu: emit studio_click share_option on
|
||||
// entry and artifact_export_result on resolution. Sync exports report
|
||||
// success immediately after the call returns; async exports get .then
|
||||
// / .catch. The same request_id threads both events so PostHog can
|
||||
// stitch click → result via $insert_id correlation.
|
||||
const fireShareExport = (
|
||||
format:
|
||||
| 'pdf'
|
||||
| 'pptx'
|
||||
| 'zip'
|
||||
| 'html'
|
||||
| 'markdown'
|
||||
| 'template'
|
||||
| 'vercel'
|
||||
| 'cloudflare_pages',
|
||||
fn: () => Promise<unknown> | unknown,
|
||||
) => {
|
||||
const requestId = analytics.newRequestId();
|
||||
const artifactId = anonymizeArtifactId({ projectId, fileName: file.name });
|
||||
trackStudioClickShareOption(
|
||||
analytics.track,
|
||||
{
|
||||
page: 'studio',
|
||||
area: 'app_header',
|
||||
artifact_id: artifactId,
|
||||
element: 'share_option',
|
||||
action: 'select_share_option',
|
||||
share_context: 'artifact',
|
||||
export_format: format,
|
||||
},
|
||||
{ requestId },
|
||||
);
|
||||
const started = performance.now();
|
||||
const finish = (result: 'success' | 'failed' | 'cancelled', errorCode?: string) => {
|
||||
trackArtifactExportResult(
|
||||
analytics.track,
|
||||
{
|
||||
page: 'studio',
|
||||
area: 'app_header',
|
||||
artifact_id: artifactId,
|
||||
project_id: projectId,
|
||||
export_format: format,
|
||||
result,
|
||||
...(errorCode ? { error_code: errorCode } : {}),
|
||||
export_duration_ms: Math.round(performance.now() - started),
|
||||
},
|
||||
{ requestId },
|
||||
);
|
||||
};
|
||||
try {
|
||||
const out = fn();
|
||||
if (out && typeof (out as Promise<unknown>).then === 'function') {
|
||||
(out as Promise<unknown>).then(
|
||||
() => finish('success'),
|
||||
(err) => finish('failed', err instanceof Error ? err.name : 'UNKNOWN'),
|
||||
);
|
||||
} else {
|
||||
finish('success');
|
||||
}
|
||||
} catch (err) {
|
||||
finish('failed', err instanceof Error ? err.name : 'UNKNOWN');
|
||||
}
|
||||
};
|
||||
const [mode, setMode] = useState<'preview' | 'source'>('preview');
|
||||
const [source, setSource] = useState<string | null>(liveHtml ?? null);
|
||||
const [inlinedSource, setInlinedSource] = useState<string | null>(null);
|
||||
|
|
@ -4974,13 +5071,13 @@ function HtmlViewer({
|
|||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
void exportProjectAsPdf({
|
||||
fireShareExport('pdf', () => exportProjectAsPdf({
|
||||
deck: effectiveDeck,
|
||||
fallbackPdf: () => exportAsPdf(source ?? '', exportTitle, { deck: effectiveDeck }),
|
||||
filePath: file.name,
|
||||
projectId,
|
||||
title: exportTitle,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||||
|
|
@ -5004,7 +5101,9 @@ function HtmlViewer({
|
|||
}
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
if (onExportAsPptx) onExportAsPptx(file.name);
|
||||
fireShareExport('pptx', () => {
|
||||
if (onExportAsPptx) onExportAsPptx(file.name);
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="present" size={14} /></span>
|
||||
|
|
@ -5017,12 +5116,12 @@ function HtmlViewer({
|
|||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
void exportProjectAsZip({
|
||||
fireShareExport('zip', () => exportProjectAsZip({
|
||||
projectId,
|
||||
filePath: file.name,
|
||||
fallbackHtml: source ?? '',
|
||||
fallbackTitle: exportTitle,
|
||||
});
|
||||
}));
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="download" size={14} /></span>
|
||||
|
|
@ -5034,7 +5133,7 @@ function HtmlViewer({
|
|||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
exportAsHtml(source ?? '', exportTitle);
|
||||
fireShareExport('html', () => exportAsHtml(source ?? '', exportTitle));
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file-code" size={14} /></span>
|
||||
|
|
@ -5052,7 +5151,7 @@ function HtmlViewer({
|
|||
role="menuitem"
|
||||
onClick={() => {
|
||||
setShareMenuOpen(false);
|
||||
exportAsMd(source ?? '', exportTitle);
|
||||
fireShareExport('markdown', () => exportAsMd(source ?? '', exportTitle));
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="file" size={14} /></span>
|
||||
|
|
@ -5065,7 +5164,9 @@ function HtmlViewer({
|
|||
role="menuitem"
|
||||
disabled={savingTemplate}
|
||||
onClick={() => {
|
||||
openSaveAsTemplateModal();
|
||||
fireShareExport('template', () => {
|
||||
openSaveAsTemplateModal();
|
||||
});
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="copy" size={14} /></span>
|
||||
|
|
@ -5085,7 +5186,13 @@ function HtmlViewer({
|
|||
className="share-menu-item"
|
||||
role="menuitem"
|
||||
onClick={() => {
|
||||
void openDeployModal(option.id);
|
||||
const format =
|
||||
option.id === 'cloudflare-pages'
|
||||
? 'cloudflare_pages'
|
||||
: option.id === 'vercel-self'
|
||||
? 'vercel'
|
||||
: 'vercel';
|
||||
fireShareExport(format, () => openDeployModal(option.id));
|
||||
}}
|
||||
>
|
||||
<span className="share-menu-icon"><Icon name="upload" size={14} /></span>
|
||||
|
|
|
|||
|
|
@ -1,4 +1,10 @@
|
|||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
createTabToTracking,
|
||||
projectKindToTracking,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import { trackHomeClickCreateButton } from '../analytics/events';
|
||||
import type { ConnectorDetail, ImportFolderResponse } from '@open-design/contracts';
|
||||
|
||||
// Window.electronAPI is declared globally in apps/web/src/types/electron.d.ts
|
||||
|
|
@ -135,7 +141,7 @@ interface Props {
|
|||
defaultDesignSystemId: string | null;
|
||||
templates: ProjectTemplate[];
|
||||
promptTemplates: PromptTemplateSummary[];
|
||||
onCreate: (input: CreateInput) => void;
|
||||
onCreate: (input: CreateInput & { requestId?: string }) => void;
|
||||
onImportClaudeDesign?: (file: File) => Promise<void> | void;
|
||||
// Web fallback: the user types an absolute baseDir into the manual
|
||||
// input and the renderer POSTs `/api/import/folder` itself. Browser
|
||||
|
|
@ -209,6 +215,7 @@ export function NewProjectPanel({
|
|||
loading = false,
|
||||
}: Props) {
|
||||
const t = useT();
|
||||
const analytics = useAnalytics();
|
||||
const importInputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [baseDir, setBaseDir] = useState('');
|
||||
|
|
@ -522,11 +529,29 @@ export function NewProjectPanel({
|
|||
inspirationIds: inspirations,
|
||||
promptTemplate: promptTemplatePick,
|
||||
});
|
||||
// Generate the click→result correlation id here so the home_click and
|
||||
// the eventual project_create_result share request_id.
|
||||
const requestId = analytics.newRequestId();
|
||||
const trackedKind = projectKindToTracking(metadata?.kind ?? null) ?? 'prototype';
|
||||
trackHomeClickCreateButton(
|
||||
analytics.track,
|
||||
{
|
||||
page: 'home',
|
||||
area: 'create_panel',
|
||||
element: 'create_button',
|
||||
action: 'create_project',
|
||||
source_tab: createTabToTracking(tab),
|
||||
project_kind: trackedKind,
|
||||
has_project_name: name.trim().length > 0,
|
||||
},
|
||||
{ requestId },
|
||||
);
|
||||
onCreate({
|
||||
name: name.trim() || autoName(tab, mediaSurface, t),
|
||||
skillId: skillIdForTab,
|
||||
designSystemId: primaryDs,
|
||||
metadata,
|
||||
requestId,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -52,12 +52,12 @@ export function PrivacyConsentModal({ onShare, onDecline }: Props): JSX.Element
|
|||
role="group"
|
||||
aria-label={t('settings.privacyConsentKicker')}
|
||||
>
|
||||
<button type="button" className="privacy-consent-action" onClick={onShare}>
|
||||
{t('settings.privacyConsentShare')}
|
||||
</button>
|
||||
<button type="button" className="privacy-consent-action" onClick={onDecline}>
|
||||
{t('settings.privacyConsentDecline')}
|
||||
</button>
|
||||
<button type="button" className="privacy-consent-action" onClick={onShare}>
|
||||
{t('settings.privacyConsentShare')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -194,12 +194,12 @@ function ConsentCard({ onShare, onDecline }: ConsentProps): JSX.Element {
|
|||
role="group"
|
||||
aria-label={t('settings.privacyConsentKicker')}
|
||||
>
|
||||
<button type="button" className="privacy-consent-action" onClick={onShare}>
|
||||
{t('settings.privacyConsentShare')}
|
||||
</button>
|
||||
<button type="button" className="privacy-consent-action" onClick={onDecline}>
|
||||
{t('settings.privacyConsentDecline')}
|
||||
</button>
|
||||
<button type="button" className="privacy-consent-action" onClick={onShare}>
|
||||
{t('settings.privacyConsentShare')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,6 +1,21 @@
|
|||
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react';
|
||||
import type { CSSProperties, Dispatch, SetStateAction } from 'react';
|
||||
import { validateBaseUrl } from '@open-design/contracts/api/connectionTest';
|
||||
import {
|
||||
agentIdToTracking,
|
||||
executionModeToTracking,
|
||||
settingsSectionToTracking,
|
||||
} from '@open-design/contracts/analytics';
|
||||
import { useAnalytics } from '../analytics/provider';
|
||||
import {
|
||||
trackSettingsByokTestResult,
|
||||
trackSettingsCliTestResult,
|
||||
trackSettingsClickByokField,
|
||||
trackSettingsClickByokProviderOption,
|
||||
trackSettingsClickCliProviderCard,
|
||||
trackSettingsClickExecutionModeTab,
|
||||
trackSettingsView,
|
||||
} from '../analytics/events';
|
||||
import { LOCALE_LABEL, LOCALES, useI18n } from '../i18n';
|
||||
import type { Locale } from '../i18n';
|
||||
import type { Dict } from '../i18n/types';
|
||||
|
|
@ -637,12 +652,19 @@ export function SettingsDialog({
|
|||
onReloadMediaProviders,
|
||||
}: Props) {
|
||||
const { t, locale, setLocale } = useI18n();
|
||||
const analytics = useAnalytics();
|
||||
const [cfg, setCfg] = useState<AppConfig>(initial);
|
||||
const lastSavedAppearanceRef = useRef({
|
||||
theme: initial.theme ?? 'system',
|
||||
accentColor: resolveAccentColor(initial.accentColor),
|
||||
});
|
||||
|
||||
// settings_view — fire on dialog open and on every section switch so the
|
||||
// configuration funnel can see which section the user spent time in.
|
||||
// The fire is keyed on section so a section bounce (open → switch →
|
||||
// close) emits one event per surface.
|
||||
const lastViewSectionRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
lastSavedAppearanceRef.current = {
|
||||
theme: initial.theme ?? 'system',
|
||||
|
|
@ -699,6 +721,26 @@ export function SettingsDialog({
|
|||
useEffect(() => {
|
||||
setActiveSection(initialSection);
|
||||
}, [initialSection]);
|
||||
|
||||
// settings_view — fires whenever the active section changes (and once on
|
||||
// mount). Keying the fire on a section+section-string lets us dedupe
|
||||
// accidental double-renders while still capturing genuine tab switches.
|
||||
useEffect(() => {
|
||||
if (lastViewSectionRef.current === activeSection) return;
|
||||
lastViewSectionRef.current = activeSection;
|
||||
const hasCli = agents.some((a) => a.available);
|
||||
const selected = agents.find((a) => a.id === cfg.agentId && a.available);
|
||||
trackSettingsView(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'settings_panel',
|
||||
element: 'page',
|
||||
view_type: 'page',
|
||||
active_section: settingsSectionToTracking(activeSection),
|
||||
execution_mode: executionModeToTracking(cfg.mode),
|
||||
has_available_cli: hasCli,
|
||||
...(selected ? { selected_cli_id: agentIdToTracking(selected.id) } : {}),
|
||||
});
|
||||
}, [activeSection, agents, cfg.mode, cfg.agentId, analytics.track]);
|
||||
useEffect(() => {
|
||||
const el = settingsContentRef.current;
|
||||
if (el) el.scrollTop = 0;
|
||||
|
|
@ -795,7 +837,23 @@ export function SettingsDialog({
|
|||
[agents],
|
||||
);
|
||||
|
||||
const setMode = (mode: ExecMode) => setCfg((c) => ({ ...c, mode }));
|
||||
const setMode = (mode: ExecMode) => {
|
||||
setCfg((c) => {
|
||||
const modeBefore = executionModeToTracking(c.mode);
|
||||
const modeAfter = executionModeToTracking(mode);
|
||||
if (modeBefore !== modeAfter) {
|
||||
trackSettingsClickExecutionModeTab(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
element: 'execution_mode_tab',
|
||||
action: 'switch_execution_mode',
|
||||
mode_before: modeBefore,
|
||||
mode_after: modeAfter,
|
||||
});
|
||||
}
|
||||
return { ...c, mode };
|
||||
});
|
||||
};
|
||||
const setApiProtocol = (protocol: ApiProtocol) => {
|
||||
setApiModelCustomEditing(false);
|
||||
setCfg((c) => switchApiProtocolConfig(c, protocol));
|
||||
|
|
@ -831,6 +889,8 @@ export function SettingsDialog({
|
|||
const revision = agentTestRevisionRef.current;
|
||||
agentTestAbortRef.current = controller;
|
||||
setAgentTestState({ status: 'running' });
|
||||
const startedAt = performance.now();
|
||||
const cliProviderId = agentIdToTracking(selected.id);
|
||||
const clearIfStale = () => {
|
||||
if (agentTestAbortRef.current === controller) {
|
||||
setAgentTestState({ status: 'idle' });
|
||||
|
|
@ -852,6 +912,14 @@ export function SettingsDialog({
|
|||
return;
|
||||
}
|
||||
setAgentTestState({ status: 'done', result });
|
||||
trackSettingsCliTestResult(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
cli_provider_id: cliProviderId,
|
||||
result: result.ok ? 'success' : 'failed',
|
||||
...(result.ok ? {} : { error_code: result.kind || 'UNKNOWN' }),
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
if (agentTestRevisionRef.current !== revision) {
|
||||
|
|
@ -868,6 +936,14 @@ export function SettingsDialog({
|
|||
detail: err instanceof Error ? err.message : 'Test request failed',
|
||||
},
|
||||
});
|
||||
trackSettingsCliTestResult(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
cli_provider_id: cliProviderId,
|
||||
result: 'failed',
|
||||
error_code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
} finally {
|
||||
if (agentTestAbortRef.current === controller) {
|
||||
agentTestAbortRef.current = null;
|
||||
|
|
@ -883,6 +959,7 @@ export function SettingsDialog({
|
|||
const revision = providerTestRevisionRef.current;
|
||||
providerTestAbortRef.current = controller;
|
||||
setProviderTestState({ status: 'running' });
|
||||
const startedAt = performance.now();
|
||||
const clearIfStale = () => {
|
||||
if (providerTestAbortRef.current === controller) {
|
||||
setProviderTestState({ status: 'idle' });
|
||||
|
|
@ -908,6 +985,14 @@ export function SettingsDialog({
|
|||
return;
|
||||
}
|
||||
setProviderTestState({ status: 'done', result });
|
||||
trackSettingsByokTestResult(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
provider_id: apiProtocol,
|
||||
result: result.ok ? 'success' : 'failed',
|
||||
...(result.ok ? {} : { error_code: result.kind || 'UNKNOWN' }),
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
if (providerTestRevisionRef.current !== revision) {
|
||||
|
|
@ -924,6 +1009,14 @@ export function SettingsDialog({
|
|||
detail: err instanceof Error ? err.message : 'Test request failed',
|
||||
},
|
||||
});
|
||||
trackSettingsByokTestResult(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
provider_id: apiProtocol,
|
||||
result: 'failed',
|
||||
error_code: err instanceof Error ? err.name : 'UNKNOWN',
|
||||
duration_ms: Math.round(performance.now() - startedAt),
|
||||
});
|
||||
} finally {
|
||||
if (providerTestAbortRef.current === controller) {
|
||||
providerTestAbortRef.current = null;
|
||||
|
|
@ -1688,7 +1781,17 @@ export function SettingsDialog({
|
|||
role="tab"
|
||||
aria-selected={apiProtocol === tab.id}
|
||||
className={'protocol-chip' + (apiProtocol === tab.id ? ' active' : '')}
|
||||
onClick={() => setApiProtocol(tab.id)}
|
||||
onClick={() => {
|
||||
trackSettingsClickByokProviderOption(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
element: 'byok_provider_option',
|
||||
action: 'select_byok_provider',
|
||||
provider_id: tab.id,
|
||||
is_selected: apiProtocol === tab.id,
|
||||
});
|
||||
setApiProtocol(tab.id);
|
||||
}}
|
||||
>
|
||||
{tab.title}
|
||||
</button>
|
||||
|
|
@ -1840,9 +1943,18 @@ export function SettingsDialog({
|
|||
className={
|
||||
'agent-card' + (active ? ' active' : '')
|
||||
}
|
||||
onClick={() =>
|
||||
setCfg((c) => ({ ...c, agentId: a.id }))
|
||||
}
|
||||
onClick={() => {
|
||||
trackSettingsClickCliProviderCard(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
element: 'cli_provider_card',
|
||||
action: 'select_cli_provider',
|
||||
cli_provider_id: agentIdToTracking(a.id),
|
||||
install_status: a.available ? 'installed' : 'not_installed',
|
||||
is_selected: !active,
|
||||
});
|
||||
setCfg((c) => ({ ...c, agentId: a.id }));
|
||||
}}
|
||||
aria-pressed={active}
|
||||
>
|
||||
<AgentIcon id={a.id} size={40} />
|
||||
|
|
@ -2247,6 +2359,17 @@ export function SettingsDialog({
|
|||
placeholder={API_KEY_PLACEHOLDERS[apiProtocol]}
|
||||
value={cfg.apiKey}
|
||||
onChange={(e) => updateApiConfig({ apiKey: e.target.value })}
|
||||
onFocus={() => {
|
||||
trackSettingsClickByokField(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
element: 'byok_field',
|
||||
action: 'focus_byok_field',
|
||||
field_id: 'api_key',
|
||||
provider_id: apiProtocol,
|
||||
has_value: Boolean(cfg.apiKey?.trim()),
|
||||
});
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
<button
|
||||
|
|
@ -2269,6 +2392,17 @@ export function SettingsDialog({
|
|||
</span>
|
||||
<select
|
||||
value={apiModelSelectValue}
|
||||
onFocus={() => {
|
||||
trackSettingsClickByokField(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
element: 'byok_field',
|
||||
action: 'focus_byok_field',
|
||||
field_id: 'model',
|
||||
provider_id: apiProtocol,
|
||||
has_value: Boolean(cfg.model?.trim()),
|
||||
});
|
||||
}}
|
||||
onChange={(e) => {
|
||||
if (e.target.value === CUSTOM_MODEL_SENTINEL) {
|
||||
setApiModelCustomEditing(true);
|
||||
|
|
@ -2323,6 +2457,17 @@ export function SettingsDialog({
|
|||
aria-describedby={
|
||||
baseUrlInvalid ? 'settings-base-url-error' : undefined
|
||||
}
|
||||
onFocus={() => {
|
||||
trackSettingsClickByokField(analytics.track, {
|
||||
page: 'settings',
|
||||
area: 'execution_model',
|
||||
element: 'byok_field',
|
||||
action: 'focus_byok_field',
|
||||
field_id: 'base_url',
|
||||
provider_id: apiProtocol,
|
||||
has_value: Boolean(cfg.baseUrl?.trim()),
|
||||
});
|
||||
}}
|
||||
onChange={(e) => updateApiConfig({ baseUrl: e.target.value, apiProviderBaseUrl: null })}
|
||||
/>
|
||||
{baseUrlInvalid ? (
|
||||
|
|
|
|||
|
|
@ -182,8 +182,8 @@ export const en: Dict = {
|
|||
'settings.privacyConsentDecline': 'Not now',
|
||||
'settings.privacyMetrics': 'Anonymous metrics',
|
||||
'settings.privacyMetricsHint': 'Run counts, token usage, error rate, duration. No prompts, no project data.',
|
||||
'settings.privacyContent': 'Conversation content',
|
||||
'settings.privacyContentHint': "Your prompts and the assistant's responses (truncated 8 KB / 16 KB). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyContent': 'Conversation and tool content',
|
||||
'settings.privacyContentHint': "Your prompts, assistant responses, tool inputs, and tool outputs (truncated before send). API keys, tokens, JWTs, emails, IPs, and credit-card numbers are stripped automatically before send.",
|
||||
'settings.privacyArtifacts': 'Project artifacts manifest',
|
||||
'settings.privacyArtifactsHint': 'Filenames, types, sizes of generated files. File contents are never sent.',
|
||||
'settings.privacyInstallationId': 'Anonymous ID',
|
||||
|
|
|
|||
|
|
@ -180,8 +180,8 @@ export const zhCN: Dict = {
|
|||
'settings.privacyConsentDecline': '暂不',
|
||||
'settings.privacyMetrics': '匿名指标',
|
||||
'settings.privacyMetricsHint': '运行次数、token 用量、错误率、时长。不包含 prompt,不包含项目数据。',
|
||||
'settings.privacyContent': '对话内容',
|
||||
'settings.privacyContentHint': '你发送的 prompt 与助手的回复(分别截断到 8 KB / 16 KB)。API key、token、JWT、邮箱、IP 与信用卡号在发送前会自动剥离。',
|
||||
'settings.privacyContent': '对话和工具内容',
|
||||
'settings.privacyContentHint': '你发送的 prompt、助手回复、工具输入与工具输出(发送前截断)。API key、token、JWT、邮箱、IP 与信用卡号在发送前会自动剥离。',
|
||||
'settings.privacyArtifacts': '项目产物清单',
|
||||
'settings.privacyArtifactsHint': '生成文件的名称、类型、大小。文件内容绝不发送。',
|
||||
'settings.privacyInstallationId': '匿名 ID',
|
||||
|
|
|
|||
25
apps/web/tests/analytics-identity.test.ts
Normal file
25
apps/web/tests/analytics-identity.test.ts
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
// Smoke tests for the identity-rotation contract that lets PostHog stay
|
||||
// in lockstep with the existing Langfuse installationId. The full
|
||||
// rotation flow (Delete-my-data → daemon rotates id → app-config updates
|
||||
// → posthog.reset() + identify(newId)) is verified end-to-end against
|
||||
// a live PostHog project; these unit tests pin only the safety guards
|
||||
// (no-client paths, null inputs) so future refactors can't regress the
|
||||
// "never throw out of an analytics path" invariant.
|
||||
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { applyConsent, applyIdentity } from '../src/analytics/client';
|
||||
|
||||
describe('analytics identity safety', () => {
|
||||
it('applyConsent never throws when no PostHog client is initialized', () => {
|
||||
expect(() => applyConsent(true)).not.toThrow();
|
||||
expect(() => applyConsent(false)).not.toThrow();
|
||||
});
|
||||
|
||||
it('applyIdentity is a no-op for null installationId', () => {
|
||||
expect(() => applyIdentity(null)).not.toThrow();
|
||||
});
|
||||
|
||||
it('applyIdentity never throws when no PostHog client is initialized', () => {
|
||||
expect(() => applyIdentity('install-X')).not.toThrow();
|
||||
});
|
||||
});
|
||||
142
apps/web/tests/analytics-scrub.test.ts
Normal file
142
apps/web/tests/analytics-scrub.test.ts
Normal file
|
|
@ -0,0 +1,142 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
import type { CaptureResult } from 'posthog-js';
|
||||
import { scrubBeforeSend } from '../src/analytics/scrub';
|
||||
|
||||
function makeEvent(event: string, properties: Record<string, unknown>): CaptureResult {
|
||||
return {
|
||||
event,
|
||||
properties,
|
||||
distinct_id: 'test',
|
||||
uuid: 'uuid',
|
||||
timestamp: new Date(),
|
||||
} as unknown as CaptureResult;
|
||||
}
|
||||
|
||||
describe('scrubBeforeSend', () => {
|
||||
it('returns null for null input', () => {
|
||||
expect(scrubBeforeSend(null)).toBeNull();
|
||||
});
|
||||
|
||||
it('drops $opt_in events (already captured via explicit consent toggle)', () => {
|
||||
const cleaned = scrubBeforeSend(makeEvent('$opt_in', {}));
|
||||
expect(cleaned).toBeNull();
|
||||
});
|
||||
|
||||
it('strips $el_text from textarea elements on $autocapture', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$autocapture', {
|
||||
$elements: [
|
||||
{
|
||||
tag_name: 'textarea',
|
||||
$el_text: 'A secret prompt the user typed',
|
||||
attr__placeholder: 'Type your prompt…',
|
||||
attr__value: 'A secret prompt the user typed',
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const els = cleaned!.properties.$elements as Array<Record<string, unknown>>;
|
||||
expect(els[0]!.$el_text).toBeUndefined();
|
||||
expect(els[0]!.attr__value).toBeUndefined();
|
||||
expect(els[0]!.attr__placeholder).toBeUndefined();
|
||||
expect(els[0]!.tag_name).toBe('textarea');
|
||||
});
|
||||
|
||||
it('strips $el_text from password inputs', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$rageclick', {
|
||||
$elements: [
|
||||
{ tag_name: 'input', attr__type: 'password', $el_text: 'sk-ant-xxx' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const els = cleaned!.properties.$elements as Array<Record<string, unknown>>;
|
||||
expect(els[0]!.$el_text).toBeUndefined();
|
||||
});
|
||||
|
||||
it('strips $el_text from contenteditable elements', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$dead_click', {
|
||||
$elements: [
|
||||
{ tag_name: 'div', attr__contenteditable: 'true', $el_text: 'user-typed' },
|
||||
],
|
||||
}),
|
||||
);
|
||||
const els = cleaned!.properties.$elements as Array<Record<string, unknown>>;
|
||||
expect(els[0]!.$el_text).toBeUndefined();
|
||||
});
|
||||
|
||||
it('leaves button text on safe tags untouched', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$autocapture', {
|
||||
$elements: [{ tag_name: 'button', $el_text: 'Create project' }],
|
||||
}),
|
||||
);
|
||||
const els = cleaned!.properties.$elements as Array<Record<string, unknown>>;
|
||||
expect(els[0]!.$el_text).toBe('Create project');
|
||||
});
|
||||
|
||||
it('strips query string from $current_url', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$pageview', {
|
||||
$current_url: 'http://localhost:7457/projects/abc-123?prompt=secret&model=foo',
|
||||
}),
|
||||
);
|
||||
expect(cleaned!.properties.$current_url).toBe(
|
||||
'http://localhost:7457/projects/abc-123',
|
||||
);
|
||||
});
|
||||
|
||||
it('strips fragment from URL', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$pageview', {
|
||||
$current_url: 'http://localhost:7457/projects/abc#anchor-with-data',
|
||||
}),
|
||||
);
|
||||
expect(cleaned!.properties.$current_url).toBe(
|
||||
'http://localhost:7457/projects/abc',
|
||||
);
|
||||
});
|
||||
|
||||
it('keeps malformed URLs as-is rather than dropping the event', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$pageview', { $current_url: 'not a url' }),
|
||||
);
|
||||
expect(cleaned!.properties.$current_url).toBe('not a url');
|
||||
});
|
||||
|
||||
it('rewrites absolute file:// paths in exception stack traces', () => {
|
||||
const cleaned = scrubBeforeSend(
|
||||
makeEvent('$exception', {
|
||||
$exception_list: [
|
||||
{
|
||||
type: 'TypeError',
|
||||
value: 'x is null',
|
||||
stacktrace: {
|
||||
frames: [
|
||||
{
|
||||
filename: 'file:///Users/alice/work/apps/web/src/App.tsx',
|
||||
abs_path: '/Users/alice/work/apps/web/src/App.tsx',
|
||||
lineno: 42,
|
||||
},
|
||||
],
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
);
|
||||
const list = cleaned!.properties.$exception_list as Array<{
|
||||
stacktrace: { frames: Array<{ filename: string; abs_path: string }> };
|
||||
}>;
|
||||
expect(list[0]!.stacktrace.frames[0]!.filename).toBe('app://apps/web/src/App.tsx');
|
||||
expect(list[0]!.stacktrace.frames[0]!.abs_path).toBe(
|
||||
'app://apps/web/src/App.tsx',
|
||||
);
|
||||
});
|
||||
|
||||
it('passes events through with no $elements, $current_url, or stack untouched', () => {
|
||||
const event = makeEvent('$pageleave', { duration: 1234 });
|
||||
const cleaned = scrubBeforeSend(event);
|
||||
expect(cleaned).toEqual({ ...event, properties: { duration: 1234 } });
|
||||
});
|
||||
});
|
||||
|
|
@ -44,7 +44,7 @@ let
|
|||
# `nix build .#daemon` will fail with the expected hash printed; copy
|
||||
# that into `pnpmDepsHash` below. Bump it whenever pnpm-lock.yaml
|
||||
# changes.
|
||||
pnpmDepsHash = "sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc=";
|
||||
pnpmDepsHash = "sha256-PGFgX4lYyeH2TRAXfUq52A3EOa6bb1gO59hPsXhEk3s=";
|
||||
# pnpmDepsHash = lib.fakeHash;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
|
|
|
|||
|
|
@ -30,7 +30,7 @@ let
|
|||
# `nix build .#web` will fail with the expected hash printed; copy
|
||||
# that into `pnpmDepsHash` below. Bump it whenever pnpm-lock.yaml
|
||||
# changes.
|
||||
pnpmDepsHash = "sha256-KF3Mld72/iau+pJmA7HvnanRx8VLtDP0N624SKrtrrc=";
|
||||
pnpmDepsHash = "sha256-PGFgX4lYyeH2TRAXfUq52A3EOa6bb1gO59hPsXhEk3s=";
|
||||
# pnpmDepsHash = lib.fakeHash;
|
||||
in
|
||||
stdenv.mkDerivation (finalAttrs: {
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ await build({
|
|||
"./src/api/finalize.ts",
|
||||
"./src/api/providerModels.ts",
|
||||
"./src/api/research.ts",
|
||||
"./src/analytics/index.ts",
|
||||
],
|
||||
format: "esm",
|
||||
outbase: "./src",
|
||||
|
|
|
|||
|
|
@ -37,6 +37,10 @@
|
|||
"./critique": {
|
||||
"types": "./dist/critique.d.ts",
|
||||
"default": "./dist/critique.mjs"
|
||||
},
|
||||
"./analytics": {
|
||||
"types": "./dist/analytics/index.d.ts",
|
||||
"default": "./dist/analytics/index.mjs"
|
||||
}
|
||||
},
|
||||
"scripts": {
|
||||
|
|
|
|||
31
packages/contracts/src/analytics/artifact-id.ts
Normal file
31
packages/contracts/src/analytics/artifact-id.ts
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
// Stable, anonymized artifact identifier shared by the daemon and the web
|
||||
// bundle. The CSV tracking doc forbids raw file names; this helper hashes
|
||||
// the (projectId, fileName) pair into a 16-hex string so dashboards can
|
||||
// group repeat opens / exports of the same artifact without learning the
|
||||
// real name.
|
||||
//
|
||||
// FNV-1a 64-bit was chosen over SHA-256 so the same function can run
|
||||
// synchronously in browsers (Web Crypto's digest is async) and inside the
|
||||
// daemon without pulling in either Node's crypto or a hashing dependency
|
||||
// into @open-design/contracts (which must stay dependency-light). Two
|
||||
// different (projectId, fileName) pairs producing the same id are a
|
||||
// dashboard collision, not a security failure — the threat model here is
|
||||
// privacy of the filename, which FNV-1a addresses just as well as a
|
||||
// cryptographic hash.
|
||||
|
||||
const FNV_OFFSET_BASIS = 0xcbf29ce484222325n;
|
||||
const FNV_PRIME = 0x100000001b3n;
|
||||
const MASK_64 = 0xffffffffffffffffn;
|
||||
|
||||
export function anonymizeArtifactId(args: {
|
||||
projectId: string;
|
||||
fileName: string;
|
||||
}): string {
|
||||
const input = `${args.projectId}:${args.fileName}`;
|
||||
let hash = FNV_OFFSET_BASIS;
|
||||
for (let i = 0; i < input.length; i++) {
|
||||
hash ^= BigInt(input.charCodeAt(i));
|
||||
hash = (hash * FNV_PRIME) & MASK_64;
|
||||
}
|
||||
return hash.toString(16).padStart(16, '0');
|
||||
}
|
||||
584
packages/contracts/src/analytics/events.ts
Normal file
584
packages/contracts/src/analytics/events.ts
Normal file
|
|
@ -0,0 +1,584 @@
|
|||
// Typed catalog for the 17 P0 analytics events. Per-event prop shapes mirror
|
||||
// the CSV tracking doc (Open Design 埋点文档 1.0). Enums map code-side values
|
||||
// to the CSV's wire format via the `…ToTracking…` helpers below — when the
|
||||
// product team finalizes BLOCKING decisions (see
|
||||
// specs/change/20260511-posthog-analytics/tracking-doc-issues.md), revise
|
||||
// those tables in one place.
|
||||
|
||||
// P0 events implemented in this branch. Two of the original CSV P0 events
|
||||
// (project_open_result, settings_click byok_provider_option) are out of
|
||||
// scope because the matching UI surfaces don't exist in this codebase —
|
||||
// see specs/change/20260511-posthog-analytics/tracking-doc-issues.md.
|
||||
export type AnalyticsEventName =
|
||||
| 'app_launch'
|
||||
| 'home_view'
|
||||
| 'home_click'
|
||||
| 'project_create_result'
|
||||
| 'settings_view'
|
||||
| 'settings_click'
|
||||
| 'settings_cli_test_result'
|
||||
| 'settings_byok_test_result'
|
||||
| 'studio_view'
|
||||
| 'studio_click'
|
||||
| 'run_created'
|
||||
| 'run_finished'
|
||||
| 'artifact_export_result';
|
||||
|
||||
// ---- Enums shared across events (CSV wire format) ------------------------
|
||||
|
||||
export type TrackingProjectKind =
|
||||
| 'prototype'
|
||||
| 'slide_deck'
|
||||
| 'template'
|
||||
| 'live_artifact'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio';
|
||||
|
||||
export type TrackingSourceTab =
|
||||
| 'prototype'
|
||||
| 'slide_deck'
|
||||
| 'from_template'
|
||||
| 'live_artifact'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio';
|
||||
|
||||
export type TrackingFidelity = 'wireframe' | 'high_fidelity' | 'not_applicable';
|
||||
|
||||
export type TrackingExecutionMode = 'local_cli' | 'byok';
|
||||
|
||||
export type TrackingConfiguredProviderType =
|
||||
| 'local_cli'
|
||||
| 'byok'
|
||||
| 'both'
|
||||
| 'none'
|
||||
| 'unknown';
|
||||
|
||||
export type TrackingExecutionAvailability =
|
||||
| 'available'
|
||||
| 'unavailable'
|
||||
| 'unknown';
|
||||
|
||||
export type TrackingPlatform = 'web' | 'desktop';
|
||||
|
||||
export type TrackingLaunchSource =
|
||||
| 'direct'
|
||||
| 'deeplink'
|
||||
| 'reload'
|
||||
| 'unknown';
|
||||
|
||||
export type TrackingTopTabId =
|
||||
| 'designs'
|
||||
| 'examples'
|
||||
| 'design_systems'
|
||||
| 'image_templates'
|
||||
| 'video_templates';
|
||||
|
||||
export type TrackingActiveSection =
|
||||
| 'execution_model'
|
||||
| 'media_providers'
|
||||
| 'language'
|
||||
| 'appearance'
|
||||
| 'pets'
|
||||
| 'about'
|
||||
// Worktree-branch sections that have no CSV counterpart yet. Emit them
|
||||
// verbatim so PostHog dashboards can group them once the CSV catches up
|
||||
// (tracking-doc-issues.md §2.6).
|
||||
| 'connectors'
|
||||
| 'mcp_client'
|
||||
| 'orbit'
|
||||
| 'routines'
|
||||
| 'integrations'
|
||||
| 'skills'
|
||||
| 'design_systems'
|
||||
| 'memory'
|
||||
| 'privacy'
|
||||
| 'notifications';
|
||||
|
||||
export type TrackingCliProviderId =
|
||||
| 'claude_code'
|
||||
| 'codex_cli'
|
||||
| 'devin_terminal'
|
||||
| 'gemini_cli'
|
||||
| 'opencode'
|
||||
| 'hermes'
|
||||
| 'kimi_cli'
|
||||
| 'cursor_agent'
|
||||
| 'qwen_code'
|
||||
| 'github_copilot_cli'
|
||||
| 'pi'
|
||||
| 'other';
|
||||
|
||||
export type TrackingArtifactKind =
|
||||
| 'html'
|
||||
| 'markdown'
|
||||
| 'image'
|
||||
| 'video'
|
||||
| 'audio'
|
||||
| 'doc'
|
||||
| 'unknown';
|
||||
|
||||
export type TrackingExportFormat =
|
||||
| 'pdf'
|
||||
| 'pptx'
|
||||
| 'zip'
|
||||
| 'html'
|
||||
| 'markdown'
|
||||
| 'template'
|
||||
| 'vercel'
|
||||
| 'cloudflare_pages';
|
||||
|
||||
export type TrackingRunResult = 'success' | 'failed' | 'cancelled';
|
||||
export type TrackingCreateResult = 'success' | 'failed';
|
||||
export type TrackingExportResult = 'success' | 'failed' | 'cancelled';
|
||||
|
||||
export type TrackingTokenCountSource =
|
||||
| 'provider_usage'
|
||||
| 'estimated'
|
||||
| 'unknown';
|
||||
|
||||
// ---- Per-event property shapes -------------------------------------------
|
||||
|
||||
export interface AppLaunchProps {
|
||||
page: 'app';
|
||||
launch_source: TrackingLaunchSource;
|
||||
platform: TrackingPlatform;
|
||||
}
|
||||
|
||||
export interface HomeViewPageProps {
|
||||
page: 'home';
|
||||
has_available_cli: boolean;
|
||||
has_available_byok: boolean;
|
||||
configured_provider_type: TrackingConfiguredProviderType;
|
||||
execution_availability: TrackingExecutionAvailability;
|
||||
}
|
||||
|
||||
export interface HomeViewAssetPanelProps {
|
||||
page: 'home';
|
||||
area: 'asset_panel';
|
||||
element: 'tab_content';
|
||||
view_type: 'tab_content';
|
||||
target_id: TrackingTopTabId;
|
||||
result_count: number;
|
||||
is_empty: boolean;
|
||||
}
|
||||
|
||||
export interface HomeClickCreateButtonProps {
|
||||
page: 'home';
|
||||
area: 'create_panel';
|
||||
element: 'create_button';
|
||||
action: 'create_project';
|
||||
source_tab: TrackingSourceTab;
|
||||
project_kind: TrackingProjectKind;
|
||||
has_project_name: boolean;
|
||||
}
|
||||
|
||||
export interface ProjectCreateResultProps {
|
||||
page: 'home';
|
||||
area: 'create_panel';
|
||||
action_source: 'create_button' | 'import_claude_design_zip' | 'open_folder';
|
||||
project_id: string | null;
|
||||
project_kind: TrackingProjectKind | null;
|
||||
creation_source: 'blank' | 'template' | 'zip' | 'folder';
|
||||
fidelity: TrackingFidelity;
|
||||
result: TrackingCreateResult;
|
||||
error_code?: string;
|
||||
}
|
||||
|
||||
export interface SettingsViewProps {
|
||||
page: 'settings';
|
||||
area: 'settings_panel';
|
||||
element: 'page';
|
||||
view_type: 'page';
|
||||
active_section: TrackingActiveSection;
|
||||
execution_mode: TrackingExecutionMode;
|
||||
has_available_cli: boolean;
|
||||
selected_cli_id?: TrackingCliProviderId;
|
||||
}
|
||||
|
||||
export interface SettingsClickExecutionModeTabProps {
|
||||
page: 'settings';
|
||||
area: 'execution_model';
|
||||
element: 'execution_mode_tab';
|
||||
action: 'switch_execution_mode';
|
||||
mode_before: TrackingExecutionMode;
|
||||
mode_after: TrackingExecutionMode;
|
||||
// BYOK sub-protocol is captured separately so the 2-value CSV enum stays
|
||||
// intact; a CSV revision could fold this back in later.
|
||||
byok_protocol_after?: 'anthropic' | 'openai';
|
||||
}
|
||||
|
||||
export interface SettingsClickCliProviderCardProps {
|
||||
page: 'settings';
|
||||
area: 'execution_model';
|
||||
element: 'cli_provider_card';
|
||||
action: 'select_cli_provider';
|
||||
cli_provider_id: TrackingCliProviderId;
|
||||
install_status: 'installed' | 'not_installed' | 'unknown';
|
||||
is_selected: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsClickByokProviderOptionProps {
|
||||
page: 'settings';
|
||||
area: 'execution_model';
|
||||
element: 'byok_provider_option';
|
||||
action: 'select_byok_provider';
|
||||
// Code's `apiProtocol` matches the BYOK protocol chip Settings UI 1:1.
|
||||
// Tracking doc names azure/google/ollama as azure_openai/google_gemini/
|
||||
// ollama_cloud — we forward the code value verbatim and let dashboards
|
||||
// map; see tracking-doc-issues.md §2.5.
|
||||
provider_id: 'anthropic' | 'openai' | 'azure' | 'ollama' | 'google';
|
||||
// True when the clicked chip was already the active protocol (no-op
|
||||
// toggle); false when the click switches protocol.
|
||||
is_selected: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsClickByokFieldProps {
|
||||
page: 'settings';
|
||||
area: 'execution_model';
|
||||
element: 'byok_field';
|
||||
action: 'focus_byok_field';
|
||||
field_id: 'api_key' | 'base_url' | 'model';
|
||||
// Code's `apiProtocol` is wider than the CSV's BYOK provider enum
|
||||
// (anthropic|openai|azure|ollama|google). We forward the code value
|
||||
// verbatim so dashboards can group by the actual protocol; the CSV enum
|
||||
// is a strict subset the product team can revise.
|
||||
provider_id: 'anthropic' | 'openai' | 'azure' | 'ollama' | 'google';
|
||||
has_value: boolean;
|
||||
}
|
||||
|
||||
export interface SettingsCliTestResultProps {
|
||||
page: 'settings';
|
||||
area: 'execution_model';
|
||||
cli_provider_id: TrackingCliProviderId;
|
||||
result: 'success' | 'failed' | 'timeout';
|
||||
error_code?: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface SettingsByokTestResultProps {
|
||||
page: 'settings';
|
||||
area: 'execution_model';
|
||||
provider_id: 'anthropic' | 'openai' | 'azure' | 'ollama' | 'google';
|
||||
result: 'success' | 'failed' | 'timeout';
|
||||
error_code?: string;
|
||||
duration_ms: number;
|
||||
}
|
||||
|
||||
export interface StudioViewChatPanelProps {
|
||||
page: 'studio';
|
||||
area: 'chat_panel';
|
||||
element: 'chat_tab';
|
||||
view_type: 'panel';
|
||||
source: 'create_project' | 'template' | 'open_project';
|
||||
conversation_id: string | null;
|
||||
}
|
||||
|
||||
export interface StudioClickChatComposerProps {
|
||||
page: 'studio';
|
||||
area: 'chat_composer';
|
||||
element:
|
||||
| 'prompt_template_card'
|
||||
| 'chat_composer_input'
|
||||
| 'composer_settings_button'
|
||||
| 'attachment_button'
|
||||
| 'send_button';
|
||||
action: 'click_composer_control';
|
||||
user_query_tokens: number;
|
||||
has_attachment: boolean;
|
||||
}
|
||||
|
||||
export interface RunCreatedProps {
|
||||
page: 'studio';
|
||||
area: 'chat_composer';
|
||||
project_id: string;
|
||||
conversation_id: string | null;
|
||||
run_id: string;
|
||||
project_kind: TrackingProjectKind | null;
|
||||
design_system_id?: string;
|
||||
design_system_source:
|
||||
| 'default'
|
||||
| 'user_selected'
|
||||
| 'template_inherited'
|
||||
| 'project_saved'
|
||||
| 'not_applicable'
|
||||
| 'unknown';
|
||||
design_system_version?: string;
|
||||
has_attachment: boolean;
|
||||
user_query_tokens: number;
|
||||
model_id: string | null;
|
||||
agent_provider_id: string | null;
|
||||
skill_id: string | null;
|
||||
mcp_id: string | null;
|
||||
token_count_source: TrackingTokenCountSource;
|
||||
}
|
||||
|
||||
export interface RunFinishedProps extends Omit<RunCreatedProps, 'area'> {
|
||||
// CSV specifies `area=chat_panel` for run_finished — note the divergence
|
||||
// from run_created's chat_composer (see tracking-doc-issues.md §4.3).
|
||||
area: 'chat_panel';
|
||||
result: TrackingRunResult;
|
||||
error_code?: string;
|
||||
artifact_count: number;
|
||||
// Token sub-fields (user_query/system_prompt/memory/context/attachment_context/
|
||||
// other_input) are omitted in v1; daemon parser does not expose them yet.
|
||||
input_tokens?: number;
|
||||
output_tokens?: number;
|
||||
total_tokens?: number;
|
||||
time_to_first_token_ms?: number;
|
||||
generation_duration_ms?: number;
|
||||
total_duration_ms: number;
|
||||
}
|
||||
|
||||
export interface StudioViewArtifactProps {
|
||||
page: 'studio';
|
||||
area: 'artifact';
|
||||
element: 'artifact_view';
|
||||
view_type: 'artifact';
|
||||
// Anonymized stable id: sha256(projectId + ':' + fileName).slice(0,16) —
|
||||
// never the raw filename.
|
||||
artifact_id: string;
|
||||
artifact_kind: TrackingArtifactKind;
|
||||
}
|
||||
|
||||
export interface StudioClickShareOptionProps {
|
||||
page: 'studio';
|
||||
area: 'app_header';
|
||||
artifact_id: string;
|
||||
element: 'share_option';
|
||||
action: 'select_share_option';
|
||||
share_context: 'artifact';
|
||||
export_format: TrackingExportFormat;
|
||||
}
|
||||
|
||||
export interface ArtifactExportResultProps {
|
||||
page: 'studio';
|
||||
area: 'app_header';
|
||||
artifact_id: string;
|
||||
project_id: string;
|
||||
export_format: TrackingExportFormat;
|
||||
result: TrackingExportResult;
|
||||
error_code?: string;
|
||||
export_duration_ms: number;
|
||||
}
|
||||
|
||||
// ---- Discriminated union of all P0 event payloads ------------------------
|
||||
|
||||
export type AnalyticsEventPayload =
|
||||
| { event: 'app_launch'; props: AppLaunchProps }
|
||||
| { event: 'home_view'; props: HomeViewPageProps | HomeViewAssetPanelProps }
|
||||
| { event: 'home_click'; props: HomeClickCreateButtonProps }
|
||||
| { event: 'project_create_result'; props: ProjectCreateResultProps }
|
||||
| { event: 'settings_view'; props: SettingsViewProps }
|
||||
| {
|
||||
event: 'settings_click';
|
||||
props:
|
||||
| SettingsClickExecutionModeTabProps
|
||||
| SettingsClickCliProviderCardProps
|
||||
| SettingsClickByokProviderOptionProps
|
||||
| SettingsClickByokFieldProps;
|
||||
}
|
||||
| { event: 'settings_cli_test_result'; props: SettingsCliTestResultProps }
|
||||
| { event: 'settings_byok_test_result'; props: SettingsByokTestResultProps }
|
||||
| { event: 'studio_view'; props: StudioViewChatPanelProps | StudioViewArtifactProps }
|
||||
| { event: 'studio_click'; props: StudioClickChatComposerProps | StudioClickShareOptionProps }
|
||||
| { event: 'run_created'; props: RunCreatedProps }
|
||||
| { event: 'run_finished'; props: RunFinishedProps }
|
||||
| { event: 'artifact_export_result'; props: ArtifactExportResultProps };
|
||||
|
||||
// ---- Enum mapping helpers (code ↔ CSV wire format) -----------------------
|
||||
//
|
||||
// These translate the code-side values (which use hyphens, different ids,
|
||||
// and richer enums) to the CSV's underscored wire format. When the product
|
||||
// team revises the CSV per tracking-doc-issues.md, revise these in one
|
||||
// place.
|
||||
|
||||
// Code `ProjectKind` from packages/contracts/src/api/projects.ts:
|
||||
// 'prototype' | 'deck' | 'template' | 'other' | 'image' | 'video' | 'audio'
|
||||
export function projectKindToTracking(
|
||||
kind: string | null | undefined,
|
||||
): TrackingProjectKind | null {
|
||||
switch (kind) {
|
||||
case 'prototype':
|
||||
return 'prototype';
|
||||
case 'deck':
|
||||
return 'slide_deck';
|
||||
case 'template':
|
||||
return 'template';
|
||||
case 'other':
|
||||
return 'live_artifact';
|
||||
case 'image':
|
||||
return 'image';
|
||||
case 'video':
|
||||
return 'video';
|
||||
case 'audio':
|
||||
return 'audio';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Code `CreateTab` from apps/web/src/components/NewProjectPanel.tsx:
|
||||
// 'prototype' | 'live-artifact' | 'deck' | 'template' | 'image' | 'video' | 'audio' | 'other'
|
||||
export function createTabToTracking(tab: string): TrackingSourceTab {
|
||||
switch (tab) {
|
||||
case 'prototype':
|
||||
return 'prototype';
|
||||
case 'deck':
|
||||
return 'slide_deck';
|
||||
case 'template':
|
||||
return 'from_template';
|
||||
case 'live-artifact':
|
||||
case 'other':
|
||||
return 'live_artifact';
|
||||
case 'image':
|
||||
return 'image';
|
||||
case 'video':
|
||||
return 'video';
|
||||
case 'audio':
|
||||
return 'audio';
|
||||
default:
|
||||
return 'prototype';
|
||||
}
|
||||
}
|
||||
|
||||
// Code `fidelity` is 'wireframe' | 'high-fidelity'; the CSV uses underscore.
|
||||
export function fidelityToTracking(
|
||||
fidelity: string | null | undefined,
|
||||
): TrackingFidelity {
|
||||
if (fidelity === 'wireframe') return 'wireframe';
|
||||
if (fidelity === 'high-fidelity') return 'high_fidelity';
|
||||
return 'not_applicable';
|
||||
}
|
||||
|
||||
// Code top-tab from apps/web/src/components/EntryView.tsx:
|
||||
// 'designs' | 'templates' | 'design-systems' | 'image-templates' | 'video-templates'
|
||||
// Note: the entry tab labelled 'Templates' in this branch corresponds to
|
||||
// what the CSV calls 'examples' — the surface that was historically the
|
||||
// curated examples gallery.
|
||||
export function topTabToTracking(tab: string): TrackingTopTabId {
|
||||
switch (tab) {
|
||||
case 'designs':
|
||||
return 'designs';
|
||||
case 'templates':
|
||||
case 'examples':
|
||||
return 'examples';
|
||||
case 'design-systems':
|
||||
return 'design_systems';
|
||||
case 'image-templates':
|
||||
return 'image_templates';
|
||||
case 'video-templates':
|
||||
return 'video_templates';
|
||||
default:
|
||||
return 'designs';
|
||||
}
|
||||
}
|
||||
|
||||
// Code `SettingsSection` from apps/web/src/components/SettingsDialog.tsx
|
||||
// (16 sections in this worktree branch). Sections that have no CSV
|
||||
// counterpart still get emitted under the same event so dashboards can
|
||||
// group them once the CSV catches up.
|
||||
export function settingsSectionToTracking(
|
||||
section: string,
|
||||
): TrackingActiveSection {
|
||||
switch (section) {
|
||||
case 'execution':
|
||||
return 'execution_model';
|
||||
case 'media':
|
||||
return 'media_providers';
|
||||
case 'language':
|
||||
return 'language';
|
||||
case 'appearance':
|
||||
return 'appearance';
|
||||
case 'pet':
|
||||
return 'pets';
|
||||
case 'about':
|
||||
return 'about';
|
||||
case 'composio':
|
||||
case 'integrations':
|
||||
return 'integrations';
|
||||
case 'mcpClient':
|
||||
return 'mcp_client';
|
||||
case 'orbit':
|
||||
return 'orbit';
|
||||
case 'routines':
|
||||
return 'routines';
|
||||
case 'skills':
|
||||
return 'skills';
|
||||
case 'designSystems':
|
||||
return 'design_systems';
|
||||
case 'memory':
|
||||
return 'memory';
|
||||
case 'privacy':
|
||||
return 'privacy';
|
||||
case 'notifications':
|
||||
return 'notifications';
|
||||
default:
|
||||
return 'execution_model';
|
||||
}
|
||||
}
|
||||
|
||||
// Code `mode` ('daemon' | 'api') → CSV execution_mode.
|
||||
export function executionModeToTracking(
|
||||
mode: string | null | undefined,
|
||||
): TrackingExecutionMode {
|
||||
return mode === 'daemon' ? 'local_cli' : 'byok';
|
||||
}
|
||||
|
||||
// Daemon agent id (apps/daemon/src/agents.ts) → CSV cli_provider_id. `kiro`
|
||||
// is in code but not CSV → 'other'; CSV `qoder_cli` is reserved for future.
|
||||
export function agentIdToTracking(agentId: string | null | undefined): TrackingCliProviderId {
|
||||
switch (agentId) {
|
||||
case 'claude':
|
||||
return 'claude_code';
|
||||
case 'codex':
|
||||
return 'codex_cli';
|
||||
case 'devin':
|
||||
return 'devin_terminal';
|
||||
case 'gemini':
|
||||
return 'gemini_cli';
|
||||
case 'opencode':
|
||||
return 'opencode';
|
||||
case 'hermes':
|
||||
return 'hermes';
|
||||
case 'kimi':
|
||||
return 'kimi_cli';
|
||||
case 'cursor-agent':
|
||||
return 'cursor_agent';
|
||||
case 'qwen':
|
||||
return 'qwen_code';
|
||||
case 'copilot':
|
||||
return 'github_copilot_cli';
|
||||
case 'pi':
|
||||
return 'pi';
|
||||
default:
|
||||
return 'other';
|
||||
}
|
||||
}
|
||||
|
||||
// FileViewer renderer.id / file.kind → CSV artifact_kind (see
|
||||
// apps/web/src/components/FileViewer.tsx:67-119 for the dispatch table).
|
||||
export function artifactKindToTracking(args: {
|
||||
rendererId?: string | null;
|
||||
fileKind?: string | null;
|
||||
}): TrackingArtifactKind {
|
||||
const { rendererId, fileKind } = args;
|
||||
if (rendererId === 'html' || rendererId === 'deck-html' || rendererId === 'react-component') {
|
||||
return 'html';
|
||||
}
|
||||
if (rendererId === 'markdown') return 'markdown';
|
||||
if (rendererId === 'svg') return 'image';
|
||||
if (fileKind === 'image' || fileKind === 'sketch') return 'image';
|
||||
if (fileKind === 'video') return 'video';
|
||||
if (fileKind === 'audio') return 'audio';
|
||||
if (
|
||||
fileKind === 'pdf' ||
|
||||
fileKind === 'document' ||
|
||||
fileKind === 'presentation' ||
|
||||
fileKind === 'spreadsheet'
|
||||
) {
|
||||
return 'doc';
|
||||
}
|
||||
return 'unknown';
|
||||
}
|
||||
3
packages/contracts/src/analytics/index.ts
Normal file
3
packages/contracts/src/analytics/index.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
export * from './events.js';
|
||||
export * from './public-params.js';
|
||||
export * from './artifact-id.js';
|
||||
46
packages/contracts/src/analytics/public-params.ts
Normal file
46
packages/contracts/src/analytics/public-params.ts
Normal file
|
|
@ -0,0 +1,46 @@
|
|||
// Public params shared by every analytics event. Set automatically by the
|
||||
// capture helper; per-event properties merge on top.
|
||||
//
|
||||
// Bumped only on breaking changes to the public-param shape or P0 event
|
||||
// semantics. Adding a new optional prop, or a new event name, does NOT bump.
|
||||
export const EVENT_SCHEMA_VERSION = 1;
|
||||
|
||||
export type AnalyticsClientType = 'web' | 'desktop';
|
||||
|
||||
export interface AnalyticsPublicParams {
|
||||
event_id: string;
|
||||
request_id?: string;
|
||||
event_schema_version: number;
|
||||
ui_version: string;
|
||||
session_id: string;
|
||||
anonymous_id: string;
|
||||
user_id?: string;
|
||||
client_type: AnalyticsClientType;
|
||||
app_version: string;
|
||||
locale: string;
|
||||
}
|
||||
|
||||
// Wire format used between web and daemon to bridge identity. Web sets these
|
||||
// on every fetch/SSE request; daemon reads them off req.headers when emitting
|
||||
// server-side events so the distinct_id matches.
|
||||
export const ANALYTICS_HEADER_ANONYMOUS_ID = 'x-od-analytics-anonymous-id';
|
||||
export const ANALYTICS_HEADER_SESSION_ID = 'x-od-analytics-session-id';
|
||||
export const ANALYTICS_HEADER_CLIENT_TYPE = 'x-od-analytics-client-type';
|
||||
export const ANALYTICS_HEADER_LOCALE = 'x-od-analytics-locale';
|
||||
export const ANALYTICS_HEADER_REQUEST_ID = 'x-od-analytics-request-id';
|
||||
|
||||
// Daemon serves the PostHog public config so the web bundle never embeds the
|
||||
// key at build time; loading via /api/analytics/config keeps POSTHOG_KEY /
|
||||
// POSTHOG_HOST as the single source of truth. The endpoint reports
|
||||
// enabled=true only when BOTH a key is present AND the user has consented
|
||||
// via Privacy → "Share usage data" (telemetry.metrics).
|
||||
//
|
||||
// installationId is echoed back so the web client uses the same anonymous
|
||||
// id Langfuse already keys off of — one anonymous identity per install,
|
||||
// shared between both telemetry sinks. Null when consent is declined.
|
||||
export interface AnalyticsConfigResponse {
|
||||
enabled: boolean;
|
||||
key: string | null;
|
||||
host: string | null;
|
||||
installationId?: string | null;
|
||||
}
|
||||
|
|
@ -26,3 +26,5 @@ export * from './sse/chat';
|
|||
export * from './sse/proxy';
|
||||
export * from './prompts/system';
|
||||
export * from './critique';
|
||||
export * from './analytics/events';
|
||||
export * from './analytics/public-params';
|
||||
|
|
|
|||
343
pnpm-lock.yaml
343
pnpm-lock.yaml
|
|
@ -62,6 +62,9 @@ importers:
|
|||
multer:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
posthog-node:
|
||||
specifier: ^4.18.0
|
||||
version: 4.18.0
|
||||
undici:
|
||||
specifier: ^7.16.0
|
||||
version: 7.25.0
|
||||
|
|
@ -206,10 +209,13 @@ importers:
|
|||
version: link:../../packages/sidecar-proto
|
||||
next:
|
||||
specifier: ^16.2.5
|
||||
version: 16.2.5(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
version: 16.2.5(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)
|
||||
openai:
|
||||
specifier: ^6.36.0
|
||||
version: 6.36.0(zod@4.4.2)
|
||||
posthog-js:
|
||||
specifier: ^1.205.0
|
||||
version: 1.373.2
|
||||
react:
|
||||
specifier: ^18.3.1
|
||||
version: 18.3.1
|
||||
|
|
@ -1311,6 +1317,78 @@ packages:
|
|||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
resolution: {integrity: sha512-CjruKY9V6NMssL/T1kAFgzosF1v9o6oeN+aX5JB/C/xPNtmgIJqcXHG7fA82Ou1zCpWGl4lROQUKwUNE1pMCyg==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/api@1.9.1':
|
||||
resolution: {integrity: sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
|
||||
'@opentelemetry/core@2.2.0':
|
||||
resolution: {integrity: sha512-FuabnnUm8LflnieVxs6eP7Z383hgQU4W1e3KJS6aOG3RxWxcHyBxH8fDMHNgu/gFx/M2jvTOW/4/PHhLz6bjWw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/core@2.7.1':
|
||||
resolution: {integrity: sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.0.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.208.0':
|
||||
resolution: {integrity: sha512-jOv40Bs9jy9bZVLo/i8FwUiuCvbjWDI+ZW13wimJm4LjnlwJxGgB+N/VWOZUTpM+ah/awXeQqKdNlpLf2EjvYg==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.208.0':
|
||||
resolution: {integrity: sha512-gMd39gIfVb2OgxldxUtOwGJYSH8P1kVFFlJLuut32L6KgUC4gl1dMhn+YC2mGn0bDOiQYSk/uHOdSjuKp58vvA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.208.0':
|
||||
resolution: {integrity: sha512-DCFPY8C6lAQHUNkzcNT9R+qYExvsk6C5Bto2pbNxgicpcSWbe2WHShLxkOxIdNcBiYPdVHv/e7vH7K6TI+C+fQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': ^1.3.0
|
||||
|
||||
'@opentelemetry/resources@2.2.0':
|
||||
resolution: {integrity: sha512-1pNQf/JazQTMA0BiO5NINUzH0cbLbbl7mntLa4aJNmCCXSj0q03T5ZXXL0zw4G55TjdL9Tz32cznGClf+8zr5A==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/resources@2.7.1':
|
||||
resolution: {integrity: sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-logs@0.208.0':
|
||||
resolution: {integrity: sha512-QlAyL1jRpOeaqx7/leG1vJMp84g0xKP6gJmfELBpnI4O/9xPX+Hu5m1POk9Kl+veNkyth5t19hRlN6tNY1sjbA==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.4.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.2.0':
|
||||
resolution: {integrity: sha512-G5KYP6+VJMZzpGipQw7Giif48h6SGQ2PFKEYCybeXJsOCB4fp8azqMAAzE5lnnHK3ZVwYQrgmFbsUJO/zOnwGw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.9.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.2.0':
|
||||
resolution: {integrity: sha512-xWQgL0Bmctsalg6PaXExmzdedSp3gyKV8mQBwK/j9VGdCDu2fmXIb2gAehBKbkXCpJ4HPkgv3QfoJWRT4dHWbw==}
|
||||
engines: {node: ^18.19.0 || >=20.6.0}
|
||||
peerDependencies:
|
||||
'@opentelemetry/api': '>=1.3.0 <1.10.0'
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0':
|
||||
resolution: {integrity: sha512-cifvXDhcqMwwTlTK04GBNeIe7yyo28Mfby85QXFe1Yk8nmi36Ab/5UQwptOx84SsoGNRg+EVSjwzfSZMy6pmlw==}
|
||||
engines: {node: '>=14'}
|
||||
|
||||
'@oslojs/encoding@1.1.0':
|
||||
resolution: {integrity: sha512-70wQhgYmndg4GCPxPPxPGevRKqTIJ2Nh4OkiMWmDAVYsTQ+Ta7Sq+rPevXyXGdzr30/qZBnyOalCszoMxlyldQ==}
|
||||
|
||||
|
|
@ -1319,6 +1397,42 @@ packages:
|
|||
engines: {node: '>=18'}
|
||||
hasBin: true
|
||||
|
||||
'@posthog/core@1.28.7':
|
||||
resolution: {integrity: sha512-JmV2wN5sE7u2JWxwNNw6CBrPu5xDzIAMWR9zKBar8Pk/8TRrvbFPlXehap8xOtDslfnilY+/urpHeVHpbXMo4w==}
|
||||
|
||||
'@posthog/types@1.373.2':
|
||||
resolution: {integrity: sha512-6o0AARB7OakxsrQiVeMow/m1QPnsI0Cdm7g0o5mNjVSLH/sU1MuTqckNQDLzImv++MzW0+Gyvq44cgwt3wP/Pw==}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2':
|
||||
resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==}
|
||||
|
||||
'@protobufjs/base64@1.1.2':
|
||||
resolution: {integrity: sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==}
|
||||
|
||||
'@protobufjs/codegen@2.0.5':
|
||||
resolution: {integrity: sha512-zgXFLzW3Ap33e6d0Wlj4MGIm6Ce8O89n/apUaGNB/jx+hw+ruWEp7EwGUshdLKVRCxZW12fp9r40E1mQrf/34g==}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0':
|
||||
resolution: {integrity: sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
resolution: {integrity: sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==}
|
||||
|
||||
'@protobufjs/float@1.0.2':
|
||||
resolution: {integrity: sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==}
|
||||
|
||||
'@protobufjs/inquire@1.1.1':
|
||||
resolution: {integrity: sha512-mnzgDV26ueAvk7rsbt9L7bE0SuAoqyuys/sMMrmVcN5x9VsxpcG3rqAUSgDyLp0UZlmNfIbQ4fHfCtreVBk8Ew==}
|
||||
|
||||
'@protobufjs/path@1.1.2':
|
||||
resolution: {integrity: sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==}
|
||||
|
||||
'@protobufjs/pool@1.1.0':
|
||||
resolution: {integrity: sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==}
|
||||
|
||||
'@protobufjs/utf8@1.1.1':
|
||||
resolution: {integrity: sha512-oOAWABowe8EAbMyWKM0tYDKi8Yaox52D+HWZhAIJqQXbqe0xI/GV7FhLWqlEKreMkfDjshR5FKgi3mnle0h6Eg==}
|
||||
|
||||
'@rollup/pluginutils@5.3.0':
|
||||
resolution: {integrity: sha512-5EdhGZtnu3V88ces7s53hhfK5KSASnJZv8Lulpc04cWO3REESroJXg73DFsOmgbU2BhwV0E20bu2IDZb3VKW4Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
|
|
@ -1627,6 +1741,9 @@ packages:
|
|||
'@types/tough-cookie@4.0.5':
|
||||
resolution: {integrity: sha512-/Ad8+nIOV7Rl++6f1BdKxFSMgmoqEoYbHRpPcx3JEfv8VRsQe9Z4mCXeJBzxs7mbHY/XOZZuXlRNfhpVPbs6ZA==}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
resolution: {integrity: sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==}
|
||||
|
||||
'@types/unist@3.0.3':
|
||||
resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==}
|
||||
|
||||
|
|
@ -1848,6 +1965,9 @@ packages:
|
|||
resolution: {integrity: sha512-+q/t7Ekv1EDY2l6Gda6LLiX14rU9TV20Wa3ofeQmwPFZbOMo9DXrLbOjFaaclkXKWidIaopwAObQDqwWtGUjqg==}
|
||||
engines: {node: '>= 4.0.0'}
|
||||
|
||||
axios@1.16.0:
|
||||
resolution: {integrity: sha512-6hp5CwvTPlN2A31g5dxnwAX0orzM7pmCRDLnZSX772mv8WDqICwFjowHuPs04Mc8deIld1+ejhtaMn5vp6b+1w==}
|
||||
|
||||
axobject-query@4.1.0:
|
||||
resolution: {integrity: sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
|
@ -2115,6 +2235,9 @@ packages:
|
|||
resolution: {integrity: sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
core-js@3.49.0:
|
||||
resolution: {integrity: sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
resolution: {integrity: sha512-3lqz5YjWTYnW6dlDa5TLaTCcShfar1e40rmcJVwCBJC6mWlFuj0eCHIElmG1g5kyuJ/GD+8Wn4FFCcz4gJPfaQ==}
|
||||
|
||||
|
|
@ -2287,6 +2410,9 @@ packages:
|
|||
resolution: {integrity: sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==}
|
||||
engines: {node: '>= 4'}
|
||||
|
||||
dompurify@3.4.2:
|
||||
resolution: {integrity: sha512-lHeS9SA/IKeIFFyYciHBr2n0v1VMPlSj843HdLOwjb2OxNwdq9Xykxqhk+FE42MzAdHvInbAolSE4mhahPpjXA==}
|
||||
|
||||
domutils@3.2.2:
|
||||
resolution: {integrity: sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==}
|
||||
|
||||
|
|
@ -2504,6 +2630,9 @@ packages:
|
|||
picomatch:
|
||||
optional: true
|
||||
|
||||
fflate@0.4.8:
|
||||
resolution: {integrity: sha512-FJqqoDBR00Mdj9ppamLa/Y7vxm+PRmNWA67N846RvsoYVMKB4q3y/de5PA7gUmRMYK/8CMz2GDZQmCRN1wBcWA==}
|
||||
|
||||
file-uri-to-path@1.0.0:
|
||||
resolution: {integrity: sha512-0Zt+s3L7Vf1biwWZ29aARiVYLx7iMGnEUl9x33fbB/j3jR81u/O2LbqK+Bm1CDSNDKVtJ/YjwY7TUd5SkeLQLw==}
|
||||
|
||||
|
|
@ -2522,6 +2651,15 @@ packages:
|
|||
resolution: {integrity: sha512-9UbaD6XdAL97+k/n+N7JwX46K/M6Zc6KcFYskrYL8wbBV/Uyk0CTAMY0VT+qiK5PM7AIc9aTWYtq65U7T+aCNQ==}
|
||||
engines: {node: '>=8'}
|
||||
|
||||
follow-redirects@1.16.0:
|
||||
resolution: {integrity: sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==}
|
||||
engines: {node: '>=4.0'}
|
||||
peerDependencies:
|
||||
debug: '*'
|
||||
peerDependenciesMeta:
|
||||
debug:
|
||||
optional: true
|
||||
|
||||
fontace@0.4.1:
|
||||
resolution: {integrity: sha512-lDMvbAzSnHmbYMTEld5qdtvNH2/pWpICOqpean9IgC7vUbUJc3k+k5Dokp85CegamqQpFbXf0rAVkbzpyTA8aw==}
|
||||
|
||||
|
|
@ -2924,6 +3062,9 @@ packages:
|
|||
lodash@4.18.1:
|
||||
resolution: {integrity: sha512-dMInicTPVE8d1e5otfwmmjlxkZoUpiVLwyeTdUsi/Caj/gfzzblBcCE5sRHV/AsjuCmxWrte2TNGSYuCeCq+0Q==}
|
||||
|
||||
long@5.3.2:
|
||||
resolution: {integrity: sha512-mNAgZ1GmyNhD7AuqnTG3/VQ26o760+ZYBPKjPvugO8+nLbYfX6TVpJPseBvopbdY+qpZ/lKUnmEc1LeZYS3QAA==}
|
||||
|
||||
longest-streak@3.1.0:
|
||||
resolution: {integrity: sha512-9Ri+o0JYgehTaVBBDoMqIl8GXtbWg711O3srftcHhZ0dqnETqLaoIK0x17fUw9rFSlK/0NlsKe0Ahhyl5pXE2g==}
|
||||
|
||||
|
|
@ -3455,11 +3596,21 @@ packages:
|
|||
resolution: {integrity: sha512-W62t/Se6rA0Az3DfCL0AqJwXuKwBeYg6nOaIgzP+xZ7N5BFCI7DYi1qs6ygUYT6rvfi6t9k65UMLJC+PHZpDAA==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
posthog-js@1.373.2:
|
||||
resolution: {integrity: sha512-wi9LjL+67iQsUPE4PtGp3SASWksYy0Nmo1F0Te9jDGn0wTAK5oIIFF+JxgM8II518wH5xJ2kSlyGqcrjcNFFAw==}
|
||||
|
||||
posthog-node@4.18.0:
|
||||
resolution: {integrity: sha512-XROs1h+DNatgKh/AlIlCtDxWzwrKdYDb2mOs58n4yN8BkGN9ewqeQwG5ApS4/IzwCb7HPttUkOVulkYatd2PIw==}
|
||||
engines: {node: '>=15.0.0'}
|
||||
|
||||
postject@1.0.0-alpha.6:
|
||||
resolution: {integrity: sha512-b9Eb8h2eVqNE8edvKdwqkrY6O7kAwmI8kcnBv1NScolYJbo59XUF0noFq+lxbC1yN20bmC0WBEbDC5H/7ASb0A==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
preact@10.29.1:
|
||||
resolution: {integrity: sha512-gQCLc/vWroE8lIpleXtdJhTFDogTdZG9AjMUpVkDf2iTCNwYNWA+u16dL41TqUDJO4gm2IgrcMv3uTpjd4Pwmg==}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
resolution: {integrity: sha512-8Mf2cbV7x1cXPUILADGI3wuhfqWvtiLA1iclTDbFRZkgRQS0NqsPZphna9V+HyTEadheuPmjaJMsbzKQFOzLug==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -3504,10 +3655,18 @@ packages:
|
|||
property-information@7.1.0:
|
||||
resolution: {integrity: sha512-TwEZ+X+yCJmYfL7TPUOcvBZ4QfoT5YenQiJuX//0th53DE6w0xxLEtfK3iyryQFddXuvkIk51EEgrJQ0WJkOmQ==}
|
||||
|
||||
protobufjs@7.5.7:
|
||||
resolution: {integrity: sha512-NGnrxS/nLKUo5nkbVQxlC71sB4hdfImdYIbFeSCidxtwATx0AHRPcANSLd0q5Bb2BkoSWo2iisQhGg5/r+ihbA==}
|
||||
engines: {node: '>=12.0.0'}
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==}
|
||||
engines: {node: '>= 0.10'}
|
||||
|
||||
proxy-from-env@2.1.0:
|
||||
resolution: {integrity: sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
pump@3.0.4:
|
||||
resolution: {integrity: sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==}
|
||||
|
||||
|
|
@ -3523,6 +3682,9 @@ packages:
|
|||
resolution: {integrity: sha512-6YHEFRL9mfgcAvql/XhwTvf5jKcOiiupt2FiJxHkiX1z4j7WL8J/jRHYLluORvc1XxB5rV20KoeK00gVJamspg==}
|
||||
engines: {node: '>=0.6'}
|
||||
|
||||
query-selector-shadow-dom@1.0.1:
|
||||
resolution: {integrity: sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw==}
|
||||
|
||||
quick-lru@5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
|
|
@ -4425,6 +4587,9 @@ packages:
|
|||
resolution: {integrity: sha512-QW95TCTaHmsYfHDybGMwO5IJIM93I/6vTRk+daHTWFPhwh+C8Cg7j7XyKrwrj8Ib6vYXe0ocYNrmzY4xAAN6ug==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
web-vitals@5.2.0:
|
||||
resolution: {integrity: sha512-i2z98bEmaCqSDiHEDu+gHl/dmR4Q+TxFmG3/13KkMO+o8UxQzCqWaDRCiLgEa41nlO4VpXSI0ASa1xWmO9sBlA==}
|
||||
|
||||
webidl-conversions@3.0.1:
|
||||
resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==}
|
||||
|
||||
|
|
@ -5277,12 +5442,117 @@ snapshots:
|
|||
'@next/swc-win32-x64-msvc@16.2.5':
|
||||
optional: true
|
||||
|
||||
'@opentelemetry/api-logs@0.208.0':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
|
||||
'@opentelemetry/api@1.9.1': {}
|
||||
|
||||
'@opentelemetry/core@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/core@2.7.1(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/exporter-logs-otlp-http@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-exporter-base': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-exporter-base@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/otlp-transformer': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/otlp-transformer@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-metrics': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-trace-base': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
protobufjs: 7.5.7
|
||||
|
||||
'@opentelemetry/resources@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/resources@2.7.1(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.7.1(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/sdk-logs@0.208.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-metrics@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
|
||||
'@opentelemetry/sdk-trace-base@2.2.0(@opentelemetry/api@1.9.1)':
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/core': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.2.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/semantic-conventions': 1.40.0
|
||||
|
||||
'@opentelemetry/semantic-conventions@1.40.0': {}
|
||||
|
||||
'@oslojs/encoding@1.1.0': {}
|
||||
|
||||
'@playwright/test@1.59.1':
|
||||
dependencies:
|
||||
playwright: 1.59.1
|
||||
|
||||
'@posthog/core@1.28.7':
|
||||
dependencies:
|
||||
'@posthog/types': 1.373.2
|
||||
|
||||
'@posthog/types@1.373.2': {}
|
||||
|
||||
'@protobufjs/aspromise@1.1.2': {}
|
||||
|
||||
'@protobufjs/base64@1.1.2': {}
|
||||
|
||||
'@protobufjs/codegen@2.0.5': {}
|
||||
|
||||
'@protobufjs/eventemitter@1.1.0': {}
|
||||
|
||||
'@protobufjs/fetch@1.1.0':
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/inquire': 1.1.1
|
||||
|
||||
'@protobufjs/float@1.0.2': {}
|
||||
|
||||
'@protobufjs/inquire@1.1.1': {}
|
||||
|
||||
'@protobufjs/path@1.1.2': {}
|
||||
|
||||
'@protobufjs/pool@1.1.0': {}
|
||||
|
||||
'@protobufjs/utf8@1.1.1': {}
|
||||
|
||||
'@rollup/pluginutils@5.3.0(rollup@4.60.2)':
|
||||
dependencies:
|
||||
'@types/estree': 1.0.8
|
||||
|
|
@ -5574,6 +5844,9 @@ snapshots:
|
|||
|
||||
'@types/tough-cookie@4.0.5': {}
|
||||
|
||||
'@types/trusted-types@2.0.7':
|
||||
optional: true
|
||||
|
||||
'@types/unist@3.0.3': {}
|
||||
|
||||
'@types/verror@1.10.11':
|
||||
|
|
@ -5939,6 +6212,14 @@ snapshots:
|
|||
|
||||
at-least-node@1.0.0: {}
|
||||
|
||||
axios@1.16.0:
|
||||
dependencies:
|
||||
follow-redirects: 1.16.0
|
||||
form-data: 4.0.5
|
||||
proxy-from-env: 2.1.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
axobject-query@4.1.0: {}
|
||||
|
||||
bail@2.0.2: {}
|
||||
|
|
@ -6220,6 +6501,8 @@ snapshots:
|
|||
|
||||
cookie@1.1.1: {}
|
||||
|
||||
core-js@3.49.0: {}
|
||||
|
||||
core-util-is@1.0.2:
|
||||
optional: true
|
||||
|
||||
|
|
@ -6396,6 +6679,10 @@ snapshots:
|
|||
dependencies:
|
||||
domelementtype: 2.3.0
|
||||
|
||||
dompurify@3.4.2:
|
||||
optionalDependencies:
|
||||
'@types/trusted-types': 2.0.7
|
||||
|
||||
domutils@3.2.2:
|
||||
dependencies:
|
||||
dom-serializer: 2.0.0
|
||||
|
|
@ -6745,6 +7032,8 @@ snapshots:
|
|||
optionalDependencies:
|
||||
picomatch: 4.0.4
|
||||
|
||||
fflate@0.4.8: {}
|
||||
|
||||
file-uri-to-path@1.0.0: {}
|
||||
|
||||
filelist@1.0.6:
|
||||
|
|
@ -6776,6 +7065,8 @@ snapshots:
|
|||
|
||||
flattie@1.1.1: {}
|
||||
|
||||
follow-redirects@1.16.0: {}
|
||||
|
||||
fontace@0.4.1:
|
||||
dependencies:
|
||||
fontkitten: 1.0.3
|
||||
|
|
@ -7278,6 +7569,8 @@ snapshots:
|
|||
|
||||
lodash@4.18.1: {}
|
||||
|
||||
long@5.3.2: {}
|
||||
|
||||
longest-streak@3.1.0: {}
|
||||
|
||||
loose-envify@1.4.0:
|
||||
|
|
@ -7715,7 +8008,7 @@ snapshots:
|
|||
|
||||
neotraverse@0.6.18: {}
|
||||
|
||||
next@16.2.5(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
next@16.2.5(@opentelemetry/api@1.9.1)(@playwright/test@1.59.1)(react-dom@18.3.1(react@18.3.1))(react@18.3.1):
|
||||
dependencies:
|
||||
'@next/env': 16.2.5
|
||||
'@swc/helpers': 0.5.15
|
||||
|
|
@ -7734,6 +8027,7 @@ snapshots:
|
|||
'@next/swc-linux-x64-musl': 16.2.5
|
||||
'@next/swc-win32-arm64-msvc': 16.2.5
|
||||
'@next/swc-win32-x64-msvc': 16.2.5
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@playwright/test': 1.59.1
|
||||
sharp: 0.34.5
|
||||
transitivePeerDependencies:
|
||||
|
|
@ -7930,11 +8224,35 @@ snapshots:
|
|||
picocolors: 1.1.1
|
||||
source-map-js: 1.2.1
|
||||
|
||||
posthog-js@1.373.2:
|
||||
dependencies:
|
||||
'@opentelemetry/api': 1.9.1
|
||||
'@opentelemetry/api-logs': 0.208.0
|
||||
'@opentelemetry/exporter-logs-otlp-http': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/resources': 2.7.1(@opentelemetry/api@1.9.1)
|
||||
'@opentelemetry/sdk-logs': 0.208.0(@opentelemetry/api@1.9.1)
|
||||
'@posthog/core': 1.28.7
|
||||
'@posthog/types': 1.373.2
|
||||
core-js: 3.49.0
|
||||
dompurify: 3.4.2
|
||||
fflate: 0.4.8
|
||||
preact: 10.29.1
|
||||
query-selector-shadow-dom: 1.0.1
|
||||
web-vitals: 5.2.0
|
||||
|
||||
posthog-node@4.18.0:
|
||||
dependencies:
|
||||
axios: 1.16.0
|
||||
transitivePeerDependencies:
|
||||
- debug
|
||||
|
||||
postject@1.0.0-alpha.6:
|
||||
dependencies:
|
||||
commander: 9.5.0
|
||||
optional: true
|
||||
|
||||
preact@10.29.1: {}
|
||||
|
||||
prebuild-install@7.1.3:
|
||||
dependencies:
|
||||
detect-libc: 2.1.2
|
||||
|
|
@ -7984,11 +8302,28 @@ snapshots:
|
|||
|
||||
property-information@7.1.0: {}
|
||||
|
||||
protobufjs@7.5.7:
|
||||
dependencies:
|
||||
'@protobufjs/aspromise': 1.1.2
|
||||
'@protobufjs/base64': 1.1.2
|
||||
'@protobufjs/codegen': 2.0.5
|
||||
'@protobufjs/eventemitter': 1.1.0
|
||||
'@protobufjs/fetch': 1.1.0
|
||||
'@protobufjs/float': 1.0.2
|
||||
'@protobufjs/inquire': 1.1.1
|
||||
'@protobufjs/path': 1.1.2
|
||||
'@protobufjs/pool': 1.1.0
|
||||
'@protobufjs/utf8': 1.1.1
|
||||
'@types/node': 20.19.39
|
||||
long: 5.3.2
|
||||
|
||||
proxy-addr@2.0.7:
|
||||
dependencies:
|
||||
forwarded: 0.2.0
|
||||
ipaddr.js: 1.9.1
|
||||
|
||||
proxy-from-env@2.1.0: {}
|
||||
|
||||
pump@3.0.4:
|
||||
dependencies:
|
||||
end-of-stream: 1.4.5
|
||||
|
|
@ -8004,6 +8339,8 @@ snapshots:
|
|||
dependencies:
|
||||
side-channel: 1.1.0
|
||||
|
||||
query-selector-shadow-dom@1.0.1: {}
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
||||
radix3@1.1.2: {}
|
||||
|
|
@ -9085,6 +9422,8 @@ snapshots:
|
|||
|
||||
web-streams-polyfill@4.0.0-beta.3: {}
|
||||
|
||||
web-vitals@5.2.0: {}
|
||||
|
||||
webidl-conversions@3.0.1: {}
|
||||
|
||||
webidl-conversions@8.0.1: {}
|
||||
|
|
|
|||
|
|
@ -73,6 +73,18 @@ export type ToolPackConfig = {
|
|||
silent: boolean;
|
||||
signed: boolean;
|
||||
telemetryRelayUrl?: string;
|
||||
/**
|
||||
* PostHog product-analytics ingest key, sourced from process.env.POSTHOG_KEY
|
||||
* at packaging time. Baked into open-design-config.json so the packaged
|
||||
* daemon can read it as POSTHOG_KEY env at launch — only official Open
|
||||
* Design builds (CI with the secret set) ship with this; forks compiling
|
||||
* locally produce binaries that omit the key and the integration
|
||||
* short-circuits cleanly. Apache-2.0 keeps the bundle public, but `phc_`
|
||||
* keys are write-only event ingest keys (cannot read your project data),
|
||||
* so embedding them in the binary is the PostHog-recommended pattern.
|
||||
*/
|
||||
posthogKey?: string;
|
||||
posthogHost?: string;
|
||||
to: ToolPackBuildOutput;
|
||||
webOutputMode: ToolPackWebOutputMode;
|
||||
workspaceRoot: string;
|
||||
|
|
@ -109,6 +121,36 @@ function resolveToolPackWebOutputMode(platform: ToolPackPlatform, value: string
|
|||
throw new Error(`unsupported OD_WEB_OUTPUT_MODE value: ${value}`);
|
||||
}
|
||||
|
||||
function resolveToolPackPosthogKey(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
// PostHog public keys start with `phc_`. We don't hard-fail on other
|
||||
// shapes — third-party PostHog deployments may use different prefixes —
|
||||
// but flag obviously-wrong values (whitespace, control chars) so a
|
||||
// misconfigured CI secret doesn't silently bake garbage into the bundle.
|
||||
if (/[\s\x00-\x1f]/.test(normalized)) {
|
||||
throw new Error(`POSTHOG_KEY contains whitespace or control chars: ${value}`);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function resolveToolPackPosthogHost(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
if (normalized.length === 0) return undefined;
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(normalized);
|
||||
} catch {
|
||||
throw new Error(`POSTHOG_HOST must be an absolute URL: ${value}`);
|
||||
}
|
||||
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||
throw new Error(`POSTHOG_HOST must be http(s): ${value}`);
|
||||
}
|
||||
return normalized.replace(/\/+$/, "");
|
||||
}
|
||||
|
||||
function resolveToolPackTelemetryRelayUrl(value: string | undefined): string | undefined {
|
||||
if (value == null) return undefined;
|
||||
const normalized = value.trim();
|
||||
|
|
@ -195,6 +237,8 @@ export function resolveToolPackConfig(
|
|||
silent: options.silent !== false,
|
||||
signed: options.signed === true,
|
||||
telemetryRelayUrl: resolveToolPackTelemetryRelayUrl(process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL),
|
||||
posthogKey: resolveToolPackPosthogKey(process.env.POSTHOG_KEY),
|
||||
posthogHost: resolveToolPackPosthogHost(process.env.POSTHOG_HOST),
|
||||
to: resolveToolPackBuildOutput(platform, options.to),
|
||||
webOutputMode: resolveToolPackWebOutputMode(platform, process.env.OD_WEB_OUTPUT_MODE),
|
||||
workspaceRoot: WORKSPACE_ROOT,
|
||||
|
|
|
|||
|
|
@ -392,6 +392,8 @@ async function writeAssembledApp(
|
|||
namespace: config.namespace,
|
||||
nodeCommandRelative: "open-design/bin/node",
|
||||
...(config.telemetryRelayUrl == null ? {} : { telemetryRelayUrl: config.telemetryRelayUrl }),
|
||||
...(config.posthogKey == null ? {} : { posthogKey: config.posthogKey }),
|
||||
...(config.posthogHost == null ? {} : { posthogHost: config.posthogHost }),
|
||||
...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }),
|
||||
},
|
||||
null,
|
||||
|
|
|
|||
|
|
@ -234,6 +234,8 @@ export async function writeAssembledApp(
|
|||
namespace: config.namespace,
|
||||
nodeCommandRelative: "open-design/bin/node",
|
||||
...(config.telemetryRelayUrl == null ? {} : { telemetryRelayUrl: config.telemetryRelayUrl }),
|
||||
...(config.posthogKey == null ? {} : { posthogKey: config.posthogKey }),
|
||||
...(config.posthogHost == null ? {} : { posthogHost: config.posthogHost }),
|
||||
...(usePrebundledStandaloneWeb ? { webSidecarEntryRelative: MAC_PREBUNDLED_WEB_SIDECAR_RELATIVE_PATH } : {}),
|
||||
webOutputMode: config.webOutputMode,
|
||||
...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }),
|
||||
|
|
|
|||
|
|
@ -20,6 +20,8 @@ function createPackagedConfig(config: ToolPackConfig, packagedVersion: string):
|
|||
appVersion: packagedVersion,
|
||||
namespace: config.namespace,
|
||||
...(config.telemetryRelayUrl == null ? {} : { telemetryRelayUrl: config.telemetryRelayUrl }),
|
||||
...(config.posthogKey == null ? {} : { posthogKey: config.posthogKey }),
|
||||
...(config.posthogHost == null ? {} : { posthogHost: config.posthogHost }),
|
||||
webOutputMode: config.webOutputMode,
|
||||
...(config.portable ? {} : { namespaceBaseRoot: config.roots.runtime.namespaceBaseRoot }),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,6 +3,8 @@ import { afterEach, describe, expect, it } from "vitest";
|
|||
import { resolveToolPackConfig } from "../src/config.js";
|
||||
|
||||
const savedTelemetryRelayUrl = process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL;
|
||||
const savedPosthogKey = process.env.POSTHOG_KEY;
|
||||
const savedPosthogHost = process.env.POSTHOG_HOST;
|
||||
|
||||
afterEach(() => {
|
||||
if (savedTelemetryRelayUrl == null) {
|
||||
|
|
@ -10,6 +12,16 @@ afterEach(() => {
|
|||
} else {
|
||||
process.env.OPEN_DESIGN_TELEMETRY_RELAY_URL = savedTelemetryRelayUrl;
|
||||
}
|
||||
if (savedPosthogKey == null) {
|
||||
delete process.env.POSTHOG_KEY;
|
||||
} else {
|
||||
process.env.POSTHOG_KEY = savedPosthogKey;
|
||||
}
|
||||
if (savedPosthogHost == null) {
|
||||
delete process.env.POSTHOG_HOST;
|
||||
} else {
|
||||
process.env.POSTHOG_HOST = savedPosthogHost;
|
||||
}
|
||||
});
|
||||
|
||||
describe("resolveToolPackConfig telemetry relay", () => {
|
||||
|
|
@ -33,3 +45,41 @@ describe("resolveToolPackConfig telemetry relay", () => {
|
|||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveToolPackConfig PostHog analytics", () => {
|
||||
it("bakes POSTHOG_KEY into packaged config when set at build time", () => {
|
||||
process.env.POSTHOG_KEY = "phc_test_abc123";
|
||||
process.env.POSTHOG_HOST = "https://us.i.posthog.com";
|
||||
const config = resolveToolPackConfig("mac", { namespace: "analytics-test" });
|
||||
expect(config.posthogKey).toBe("phc_test_abc123");
|
||||
expect(config.posthogHost).toBe("https://us.i.posthog.com");
|
||||
});
|
||||
|
||||
it("omits POSTHOG_KEY for fork builds that lack the secret", () => {
|
||||
delete process.env.POSTHOG_KEY;
|
||||
delete process.env.POSTHOG_HOST;
|
||||
const config = resolveToolPackConfig("mac", { namespace: "analytics-test" });
|
||||
expect(config.posthogKey).toBeUndefined();
|
||||
expect(config.posthogHost).toBeUndefined();
|
||||
});
|
||||
|
||||
it("rejects POSTHOG_KEY values that contain whitespace", () => {
|
||||
process.env.POSTHOG_KEY = "phc_test abc";
|
||||
expect(() => resolveToolPackConfig("mac")).toThrow(
|
||||
/POSTHOG_KEY contains whitespace/,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects invalid POSTHOG_HOST URLs", () => {
|
||||
process.env.POSTHOG_KEY = "phc_test_abc";
|
||||
process.env.POSTHOG_HOST = "not-a-url";
|
||||
expect(() => resolveToolPackConfig("mac")).toThrow(/POSTHOG_HOST must be an absolute URL/);
|
||||
});
|
||||
|
||||
it("strips trailing slashes from POSTHOG_HOST", () => {
|
||||
process.env.POSTHOG_KEY = "phc_test_abc";
|
||||
process.env.POSTHOG_HOST = "https://eu.i.posthog.com///";
|
||||
const config = resolveToolPackConfig("mac");
|
||||
expect(config.posthogHost).toBe("https://eu.i.posthog.com");
|
||||
});
|
||||
});
|
||||
|
|
|
|||
Loading…
Reference in a new issue