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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 18 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -94,9 +94,9 @@ registered event name, its exact allowlisted property set, the event's declared
privacy level, and sufficient explicit consent. Unknown events, extra
properties, raw text, and mismatched consent or privacy classifications are
rejected before storage. The versioned registry and its focused tests live in
`workers/events/src/eventContract.ts`. Revision 2 is generated from
`workers/events/src/eventContract.ts`. Revision 3 is generated from
`scient-desktop/packages/scient-analytics/src/wireContract.ts`, with a shared
90-case conformance fixture for all 45 registered events. Do not edit that copy
conformance fixture for every registered event, plus revision-2 compatibility tests. Do not edit that copy
independently; the desktop analytics document owns regeneration instructions.
New events add an optional bounded `contractRevision`; legacy revision-1
payloads remain supported. Deploy this validator before releasing new producers.
Expand Down Expand Up @@ -229,6 +229,22 @@ This command is an operational bridge, not a substitute for account authenticati

## PostHog dashboards

`bun run analytics:insights` reads an aggregate-only product report from D1:
feature observations/repeat days, provider/model terminal usage, reported token
counts and coverage, providers observed ready, and terminal outcomes. It uses
revision-3 Product/Diagnostic participants and the previous 30 complete UTC days.
Unknown token totals remain null; cache/reasoning subsets are not added again.
Ready observations are not a current sign-in inventory, installations are not
people, and observed population is not feature eligibility. See the desktop
analytics document for producer meanings and omissions. Existing delivery and
erasure diagnostics remain in `analytics:report`.

The companion PostHog query definitions remain prepared, not installed or
live-qualified. Qualify their execution and project timezone before publishing;
match UTC to the D1 report when reconciling periods. Deploy the generated
revision-3 validator before releasing new desktop producers. No database
migration, collection-gate change or dashboard mutation is part of this extension.

The managed dashboard manifest is `scripts/posthog-dashboard-manifest.mjs`.
It records all planned product dashboards, their source-backed queries, and the
exact events each one needs.
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"test": "vitest run",
"db:migrate": "wrangler d1 migrations apply scientfactory-downloads --remote",
"analytics:report": "node scripts/analytics-report.mjs",
"analytics:insights": "node scripts/product-insights.mjs",
"analytics:dashboards": "node scripts/manage-posthog-dashboards.mjs",
"analytics:reconcile": "node scripts/reconcile-analytics-pipeline.mjs",
"identity:link": "node scripts/link-analytics-identity.mjs",
Expand Down
8 changes: 8 additions & 0 deletions scripts/posthog-dashboard-manifest.mjs
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { providerUsageInsights, featureUsageInsights } from "./product-insights-hogql.mjs";

const event = (name, customName = name, math = "total") => ({
kind: "EventsNode",
event: name,
Expand Down Expand Up @@ -331,6 +333,7 @@ GROUP BY current.week ORDER BY current.week`,
name: "03 — Scient providers and agent runtime",
phase: "planned",
requiredEvents: [
"provider.turn.usage",
"provider.session.started",
"provider.session.recovered",
"provider.turn.completed",
Expand All @@ -344,6 +347,7 @@ GROUP BY current.week ORDER BY current.week`,
"Runtime-mode distribution",
],
insights: [
...providerUsageInsights,
preparedInsight(
"Provider terminal outcomes",
"Completed, failed and stopped turns for the same Product-consenting population, by provider. Stopped turns are not failures.",
Expand Down Expand Up @@ -377,6 +381,9 @@ GROUP BY provider, failure_class ORDER BY failures DESC`,
phase: "planned",
requiredEvents: [
"surface.opened",
"panel.viewed",
"settings.viewed",
"usage.viewed",
"project.initialization.completed",
"thread.fork.completed",
"voice.transcription.completed",
Expand All @@ -390,6 +397,7 @@ GROUP BY provider, failure_class ORDER BY failures DESC`,
"Fork and voice completion",
],
insights: [
...featureUsageInsights,
preparedInsight(
"Feature completion by installation",
"Unique installations completing bounded Scient feature outcomes.",
Expand Down
66 changes: 66 additions & 0 deletions scripts/product-insights-hogql.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// Same Product population and complete-day window as product-insights.mjs.
const population = `properties.source = 'desktop' AND properties.consent_level IN ('product', 'diagnostic')
AND properties.contractRevision = '3' AND timestamp >= toStartOfDay(now()) - INTERVAL 30 DAY AND timestamp < toStartOfDay(now())`;
const insight = (name, description, query) => ({
name,
description,
query: { kind: "HogQLQuery", query },
aliases: [],
});

export const providerUsageInsights = [
insight(
"Provider and model usage",
"Deduplicated live terminal usage reports, not model-picker clicks. Missing models are unknown; private and mixed-model labels use other. Counts include failed and stopped turns.",
`SELECT properties.provider AS provider, properties.modelKey AS model,
uniqExact(distinct_id) AS installations, uniqExact(properties.event_id) AS reported_turns
FROM events WHERE ${population} AND event = 'provider.turn.usage'
GROUP BY provider, model ORDER BY reported_turns DESC`,
),
insight(
"Reported tokens and coverage",
"Main-agent reported counts only. Cache and reasoning subsets are not added again. Missing counts are not zero; complete/partial/unavailable turns show coverage. Not all-provider billing or subagent totals.",
`SELECT provider,
count() AS reported_turns, countIf(status = 'complete') AS complete_turns,
countIf(status = 'partial') AS partial_turns, countIf(status = 'unavailable') AS unavailable_turns,
count(input_tokens) AS input_reporting_turns, count(output_tokens) AS output_reporting_turns,
if(count(input_tokens) = 0, NULL, sum(input_tokens)) AS reported_input_tokens,
if(count(output_tokens) = 0, NULL, sum(output_tokens)) AS reported_output_tokens
FROM (SELECT properties.event_id AS id, any(properties.provider) AS provider,
any(properties.usageStatus) AS status, any(toFloat(properties.inputTokens)) AS input_tokens,
any(toFloat(properties.outputTokens)) AS output_tokens FROM events WHERE ${population}
AND event = 'provider.turn.usage' GROUP BY id)
GROUP BY provider ORDER BY reported_turns DESC`,
),
insight(
"Providers observed ready",
"Installations with a provider observed ready during the window. Not signed-in people, current connection inventory, or proof the provider was used.",
`SELECT properties.provider AS provider, uniqExact(distinct_id) AS installations
FROM events WHERE ${population} AND ((event = 'provider.discovered' AND properties.state = 'ready')
OR (event = 'provider.readiness.changed' AND properties.to = 'ready')) GROUP BY provider ORDER BY installations DESC`,
),
];

export const featureUsageInsights = [
insight(
"Panel adoption and repeat days",
"Observed visible category entries, including a visible restored panel. Background tabs are excluded. Repeat means at least two distinct days, not runtime sessions; no eligibility-adjusted adoption rate is claimed.",
`SELECT category, count() AS installations, sum(observations) AS observations, countIf(days >= 2) AS repeat_installations
FROM (SELECT distinct_id, properties.category AS category, uniqExact(properties.event_id) AS observations,
uniqExact(toDate(timestamp)) AS days FROM events WHERE ${population} AND event = 'panel.viewed'
GROUP BY distinct_id, category) GROUP BY category ORDER BY installations DESC`,
),
insight(
"Settings sections visited",
"Visible section entries, not preference changes or evidence of preference. Routes and search text are never collected.",
`SELECT properties.section AS section, uniqExact(distinct_id) AS installations, uniqExact(properties.event_id) AS observations
FROM events WHERE ${population} AND event = 'settings.viewed' GROUP BY section ORDER BY installations DESC`,
),
insight(
"Usage tools viewed",
"Visible metric/range/breakdown combinations. Counts reflect view changes, not imported transcript usage or monetary spend.",
`SELECT properties.metric AS metric, properties.window AS range_days, properties.breakdown AS breakdown,
uniqExact(distinct_id) AS installations, uniqExact(properties.event_id) AS observations
FROM events WHERE ${population} AND event = 'usage.viewed' GROUP BY metric, range_days, breakdown ORDER BY observations DESC`,
),
];
79 changes: 79 additions & 0 deletions scripts/product-insights.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { spawnSync } from "node:child_process";
import { resolve } from "node:path";
import { fileURLToPath } from "node:url";

// Aggregate-only, occurrence-time report. No identities or free text returned.
// Full UTC days avoid comparing today's partial activity with complete days.
export const productInsightsQuery = `
WITH eligible AS (
SELECT event_name, distinct_id, date(occurred_at) AS day, properties_json AS p
FROM analytics_events
WHERE source = 'desktop' AND consent_level IN ('product', 'diagnostic')
AND julianday(occurred_at) >= julianday(date('now', '-30 days'))
AND julianday(occurred_at) < julianday(date('now'))
AND json_extract(properties_json, '$.contractRevision') = '3'
), views AS (
SELECT event_name, distinct_id, day,
CASE event_name
WHEN 'panel.viewed' THEN json_extract(p, '$.category')
WHEN 'settings.viewed' THEN json_extract(p, '$.section')
WHEN 'feature.viewed' THEN json_extract(p, '$.feature')
WHEN 'usage.viewed' THEN json_extract(p, '$.metric') || ':' || json_extract(p, '$.window') || ':' || json_extract(p, '$.breakdown')
WHEN 'usage.availability' THEN json_extract(p, '$.state')
WHEN 'setting.changed' THEN json_extract(p, '$.setting') || ':' || json_extract(p, '$.value')
WHEN 'scient.operation.completed' THEN json_extract(p, '$.operationKind')
ELSE event_name END AS category
FROM eligible WHERE event_name IN ('panel.viewed', 'settings.viewed', 'feature.viewed', 'usage.viewed', 'usage.availability', 'usage.refresh.requested', 'setting.changed', 'scient.operation.completed', 'voice.transcription.completed')
), per_installation AS (
SELECT event_name, category, distinct_id, count(*) AS observations, count(DISTINCT day) AS days
FROM views GROUP BY event_name, category, distinct_id
), tokens AS (
SELECT distinct_id, json_extract(p, '$.provider') AS provider,
json_extract(p, '$.modelKey') AS model,
json_extract(p, '$.usageStatus') AS status,
json_extract(p, '$.inputTokens') AS input_tokens,
json_extract(p, '$.outputTokens') AS output_tokens
FROM eligible WHERE event_name = 'provider.turn.usage'
)
SELECT 'feature_observations' AS metric, event_name || ':' || category AS category,
count(*) AS installations, sum(observations) AS observations,
sum(days >= 2) AS repeat_installations, NULL AS value
FROM per_installation GROUP BY event_name, category
UNION ALL
SELECT 'provider_model_reported_turns', provider || ':' || model, count(DISTINCT distinct_id), count(*), NULL, NULL
FROM tokens GROUP BY provider, model
UNION ALL
SELECT 'provider_token_coverage', provider || ':' || status, count(DISTINCT distinct_id), count(*), NULL, NULL
FROM tokens GROUP BY provider, status
UNION ALL
SELECT 'reported_input_tokens', provider, count(DISTINCT distinct_id), count(input_tokens), NULL, sum(input_tokens)
FROM tokens GROUP BY provider
UNION ALL
SELECT 'reported_output_tokens', provider, count(DISTINCT distinct_id), count(output_tokens), NULL, sum(output_tokens)
FROM tokens GROUP BY provider
UNION ALL
SELECT 'provider_observed_ready', json_extract(p, '$.provider'), count(DISTINCT distinct_id), count(*), NULL, NULL
FROM eligible
WHERE event_name = 'provider.discovered' AND json_extract(p, '$.state') = 'ready'
OR event_name = 'provider.readiness.changed' AND json_extract(p, '$.to') = 'ready'
GROUP BY json_extract(p, '$.provider')
UNION ALL
SELECT 'provider_terminal_outcomes', json_extract(p, '$.provider') || ':' || event_name,
count(DISTINCT distinct_id), count(*), NULL, NULL
FROM eligible WHERE event_name IN ('provider.turn.completed', 'provider.turn.failed', 'provider.turn.stopped')
GROUP BY json_extract(p, '$.provider'), event_name
UNION ALL
SELECT 'observed_product_population', 'revision-3', count(DISTINCT distinct_id), count(*), NULL, NULL
FROM eligible
ORDER BY metric, observations DESC, category
`;

if (process.argv[1] && resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
const result = spawnSync(
"wrangler",
["d1", "execute", "scientfactory-downloads", "--remote", "--command", productInsightsQuery],
{ stdio: "inherit", timeout: 60_000 },
);
if (result.error) console.error("Product insights report failed or timed out");
process.exitCode = result.error ? 1 : (result.status ?? 1);
}
70 changes: 70 additions & 0 deletions scripts/product-insights.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import { expect, it } from "vitest";
import { testDatabase } from "../workers/events/src/sqlite.testSupport.ts";
import { productInsightsQuery } from "./product-insights.mjs";

it("separates adoption, repeat days, readiness, unknown tokens and failure populations", () => {
const store = testDatabase();
try {
const insert = store.sqlite.prepare(
`INSERT INTO analytics_events (event_id, event_name, source, privacy_level, consent_level, occurred_at, distinct_id, properties_json) VALUES (?, ?, 'desktop', 'product', ?, ?, ?, ?)`,
);
let id = 0;
const add = (
name,
props,
{ consent = "product", day = "2026-09-05", installation = "PRIVATE", revision = "3" } = {},
) =>
insert.run(
String(++id),
name,
consent,
day,
installation,
JSON.stringify({ contractRevision: revision, ...props }),
);
add("panel.viewed", { category: "browser" });
add("panel.viewed", { category: "browser" });
add("panel.viewed", { category: "browser" }, { day: "2026-09-04" });
add("panel.viewed", { category: "browser" }, { consent: "essential" });
add("panel.viewed", { category: "browser" }, { revision: "2" });
add("panel.viewed", { category: "browser" }, { day: "2026-09-06" });
add("provider.discovered", { provider: "codex", state: "ready" });
add("provider.discovered", { provider: "pi", state: "unavailable" });
add("provider.turn.usage", {
provider: "codex",
modelKey: "gpt-5.6-sol",
usageStatus: "complete",
inputTokens: 100,
outputTokens: 50,
});
add("provider.turn.usage", {
provider: "codex",
modelKey: "unknown",
usageStatus: "unavailable",
});
add("provider.turn.usage", { provider: "pi", modelKey: "other", usageStatus: "unavailable" });
add("provider.turn.failed", { provider: "codex" });
add("provider.turn.failed", { provider: "codex" }, { consent: "essential" });
const rows = store.sqlite
.prepare(productInsightsQuery.replaceAll("'now'", "'2026-09-06'"))
.all();
expect(rows.find((r) => r.metric === "feature_observations")).toMatchObject({
installations: 1,
observations: 3,
repeat_installations: 1,
});
expect(
rows.find((r) => r.metric === "reported_input_tokens" && r.category === "codex"),
).toMatchObject({ observations: 1, value: 100 });
expect(
rows.find((r) => r.metric === "reported_input_tokens" && r.category === "pi"),
).toMatchObject({ observations: 0, value: null });
expect(rows.filter((r) => r.metric === "provider_observed_ready")).toHaveLength(1);
expect(rows.find((r) => r.metric === "provider_terminal_outcomes")).toMatchObject({
observations: 1,
});
expect(JSON.stringify(rows)).not.toContain("PRIVATE");
} finally {
store.close();
}
});
2 changes: 1 addition & 1 deletion src/pages/privacy.astro
Original file line number Diff line number Diff line change
Expand Up @@ -66,7 +66,7 @@ import Layout from "../layouts/Layout.astro";
<div class="content-section__body">
<h2 id="privacy-app">You control analytics in Scient.</h2>
<p>
Scient shares feature usage, failures and basic performance information to help improve the app. Sharing is on by default in supported releases; saved preferences are preserved. Turn it off or read “What’s shared?” in Settings → General → Privacy and analytics. Turning it back on includes usage, reliability and delivery counters.
Scient shares feature usage, provider/model categories, reported token counts, failures and basic performance information to help improve the app. Sharing is on by default in supported releases; saved preferences are preserved. Turn it off or read “What’s shared?” in Settings → General → Privacy and analytics. Turning it back on includes usage, reliability and delivery counters.
</p>
<p>
Analytics never collects conversations, file contents or names, paths, URLs, research results, credentials, provider account identities, or raw errors. There is no automatic click capture or session recording. Events use random installation identifiers. Your AI provider separately receives the content needed for your requests under its own privacy policy.
Expand Down
Loading