{"protocol":"clank-doc/1","frameworkVersion":"0.13.0","slug":"product-analytics","title":"Privacy-first product analytics","description":"Clank includes typed product analytics in @clank.run/framework/analytics . Events live in the application's own isolated SQLite database, use the application's existing backup and deployment boundary, and require no analytics service or npm","group":{"id":"full-stack","title":"Full stack"},"url":"https://docs.clank.run/docs/product-analytics","source":"docs/product-analytics.md","headings":["Define the product contract","Open it beside the app database","Record from authoritative server actions","Read aggregate product signals","Agent discovery and privacy operations","Operational notes"],"tableOfContents":[{"id":"define-the-product-contract","title":"Define the product contract","level":2},{"id":"open-it-beside-the-app-database","title":"Open it beside the app database","level":2},{"id":"record-from-authoritative-server-actions","title":"Record from authoritative server actions","level":2},{"id":"read-aggregate-product-signals","title":"Read aggregate product signals","level":2},{"id":"agent-discovery-and-privacy-operations","title":"Agent discovery and privacy operations","level":2},{"id":"operational-notes","title":"Operational notes","level":2}],"markdown":"# Privacy-first product analytics\n\nClank includes typed product analytics in `@clank.run/framework/analytics`. Events live in the\napplication's own isolated SQLite database, use the application's existing backup and deployment\nboundary, and require no analytics service or npm dependency. Definitions are data-first so a\nhuman, an agent, and the runtime all see the same event, dimension, measure, retention, sampling,\nand funnel contract.\n\nAnalytics is deliberately not a raw event warehouse. Consent is required for every write, raw\nidentities are HMAC-pseudonymized before storage, raw events cannot be read through the public API,\nsmall cohorts are hidden, dimensions have finite cardinality, retention is bounded, and each app\nhas a hard event capacity. This gives an agent useful product signals without quietly creating a\nsecond customer-data system.\n\n## Define the product contract\n\nUse namespaced event names and explain why each event exists. String properties must be finite\nenums. Numeric properties require explicit minimum and maximum values. Only configured enum,\nboolean, or literal properties can become dimensions; only configured bounded numbers can become\nmeasures.\n\n```ts\nimport { defineAnalytics, s } from \"@clank.run/framework\";\n\nexport const productAnalytics = defineAnalytics({\n  events: {\n    \"todo.created\": {\n      description: \"A signed-in user creates a to-do.\",\n      properties: {\n        source: s.enum([\"toolbar\", \"shortcut\"]),\n        latency_ms: s.number({ min: 0, max: 10_000 }),\n      },\n      dimensions: [\"source\"],\n      measures: [\"latency_ms\"],\n      retentionDays: 30,\n    },\n    \"todo.completed\": {\n      description: \"A signed-in user completes a to-do.\",\n      properties: { surface: s.enum([\"board\", \"list\"]) },\n      dimensions: [\"surface\"],\n      retentionDays: 30,\n    },\n  },\n  funnels: {\n    activation: {\n      description: \"Creation followed by a completed to-do.\",\n      steps: [\"todo.created\", \"todo.completed\"],\n      withinMs: 7 * 24 * 60 * 60 * 1_000,\n    },\n  },\n});\n```\n\nThe runtime rejects fields that look identity-bearing or sensitive, including IDs, names, email,\nphone, addresses, credentials, tokens, free-form content, searches, URLs, and IP or user-agent\ndata. It also rejects open strings, unbounded numbers, unknown properties, duplicate dimensions,\nunknown funnel steps, and funnels whose events have different sampling rates. Keep business data\nin normal typed tables; analytics properties should describe aggregate product state.\n\n## Open it beside the app database\n\nOpen analytics once during server startup. Use a deployment secret of at least 32 bytes; never put\nit in source or send it to a browser. Changing this secret changes pseudonyms, so use the same\nsecret for the lifetime of a production dataset.\n\n```ts\nimport { openAnalytics } from \"@clank.run/framework/analytics\";\nimport { database } from \"./database.ts\";\nimport { productAnalytics } from \"./product-analytics.ts\";\n\nexport const analytics = await openAnalytics(productAnalytics, database, {\n  identitySecret: process.env.CLANK_ANALYTICS_SECRET!,\n  minimumCohortSize: 3,\n  maxStoredEvents: 1_000_000,\n});\n```\n\n`openAnalytics` creates reserved internal tables in that app database. Those tables cannot collide\nwith application tables, do not enter live-query document revisions, and remain inside the same\nper-project SQL and backup boundary. Expired rows are deleted during writes or by `purge()`. The\ndefaults are 30 days per event, at most 400 days, one million stored events, 100,000 rows per funnel\nscan, and a three-subject minimum cohort. Every limit can only be adjusted inside its documented\nhard bound.\n\n## Record from authoritative server actions\n\nCall `track` after the associated application mutation succeeds. Resolve the subject from server\nauthentication—never accept a user ID supplied by the browser. The user or workspace's explicit\nanalytics preference determines `consent`; pass browser or account do-not-track state as well.\n\n```ts\nconst todoId = context.db.table(\"todos\").insert(args);\n\nanalytics.track(\"todo.created\", {\n  source: args.source,\n  latency_ms: Math.min(10_000, performance.now() - startedAt),\n}, {\n  consent: context.user.profile.analyticsConsent ? \"granted\" : \"denied\",\n  subject: context.user.id,\n  sessionId: context.auth.session.id,\n  idempotencyKey: `todo-created:${todoId}`,\n});\n```\n\nTracking returns `{ stored: true }` or a non-throwing reason: `consent`, `do_not_track`, `sampled`,\n`duplicate`, or `capacity`. Schema errors still throw because they are programming mistakes.\nOccurrences supplied by a browser must be within 24 hours of server time. Idempotency keys,\nsubjects, anonymous IDs, and session IDs are stored only as domain-separated keyed digests.\n\nFor optional interaction telemetry, `createAnalyticsClient` provides a typed, memory-only queue.\nIt validates before enqueueing, caps memory, batches at 25 events, honors consent and DNT both when\nqueued and when sent, and discards queued events when consent is withdrawn. Its `send` callback is\napplication-owned: post through a same-origin, rate-limited backend mutation and resolve identity\nagain on the server with `analytics.ingest`. The client event contains no subject identity and is\nnever persisted in local storage.\n\n## Read aggregate product signals\n\n`query` returns time buckets, an optional dimension breakdown, and an optional measure average.\nCounts and averages become `null` when fewer than `minimumCohortSize` distinct pseudonymous\nsubjects contributed. A dimension's hidden event count is returned only as `withheld`; its value is\nnot disclosed. Empty time buckets safely return zero.\n\n```ts\nconst usage = analytics.query({\n  event: \"todo.created\",\n  from: Date.now() - 30 * 24 * 60 * 60 * 1_000,\n  to: Date.now(),\n  interval: \"day\",\n  dimension: \"source\",\n  measure: \"latency_ms\",\n});\n\nconst activation = analytics.funnel(\"activation\", {\n  from: Date.now() - 30 * 24 * 60 * 60 * 1_000,\n  to: Date.now(),\n});\n```\n\nQueries cannot span more than 400 days or 400 time buckets. Funnels scan a bounded row count,\ncount each pseudonymous subject once per reached step, enforce the declared step order and time\nwindow, and suppress small stages. Results report the stored sample rate and never extrapolate it.\nExpose only the particular aggregate queries an application's roles should access; having an MCP\ntoken does not automatically grant analytics access.\n\n## Agent discovery and privacy operations\n\n`analytics.manifest` is immutable `clank-analytics/1` data. It describes every event JSON schema,\ndimension, measure, retention period, sample rate, funnel, and privacy rule. An app can return that\nmanifest from an agent-readable query, and expose curated aggregate queries through normal Clank\nbackend actions. This keeps the app's MCP server synchronized with the exact analytics contract.\n\nUse `analytics.forgetSubject({ subject: user.id })` when an authenticated account requests\nerasure. Anonymous installations can erase with the original first-party `anonymousId`. The\nmethod derives the same pseudonym and deletes all matching events without ever scanning for or\nstoring the raw identity. `diagnostics()` returns only total event count and oldest/newest\ntimestamps; it never returns subjects, sessions, properties, event names, or secret material.\n\n## Operational notes\n\n- Generate `CLANK_ANALYTICS_SECRET` independently from auth, OAuth, encryption, and CSRF secrets.\n- Treat the consent decision as application data and audit preference changes in the normal app.\n- Rate-limit browser ingestion. The storage ceiling is a last boundary, not a rate limiter.\n- Schedule `purge()` if an app has long periods without writes and requires prompt expiry.\n- Funnel conversion is based on stored samples. All events in a funnel must share one sample rate.\n- Backups contain pseudonymous analytics rows until their normal backup retention expires. Apply\n  account-erasure policy to backups as documented in [Recovery and rollback](./recovery.md).\n- Do not expose internal analytics tables through database access, MCP tools, logs, exports, or\n  collaboration presence. Use only the aggregate runtime methods.\n"}