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
37 changes: 37 additions & 0 deletions docs/privacy/analytics-taxonomy.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
# Privacy-Safe Analytics Taxonomy

Byteflow analytics events must use the allowlist in `src/core/analytics/taxonomy.json`.
Runtime hooks are no-ops by default; any future provider must stay cookie-free,
anonymous, and aggregate-only.

## Allowed events

- `tool_loaded`
- `tool_action`
- `copy_output`
- `download_output`
- `search_performed`
- `related_tool_click`
- `pwa_installed`

## Allowed parameters

- `tool_id`
- `related_tool_id`
- `action_type`
- `language`
- `input_size_bucket`
- `query_length_bucket`
- `results_count`
- `size_bucket`
- `source_page`
- `platform`

## Forbidden data

Never send tool input, tool output, payloads, JWTs, tokens, secrets, full URLs,
file names, file contents, image contents, log bodies, hash values, search query text,
user identifiers, session identifiers, or exact sensitive lengths.

Search events may record only `query_length_bucket` and `results_count`.
File-related events may record only coarse size buckets and safe tool/action IDs.
219 changes: 188 additions & 31 deletions scripts/gates/check-analytics-taxonomy.js
Original file line number Diff line number Diff line change
@@ -1,58 +1,215 @@
#!/usr/bin/env node

import fs from "node:fs"
import path from "node:path"

const FILES = {
analytics: "src/core/analytics/analytics.ts",
routeAnalytics: "src/core/analytics/components/route-analytics.tsx",
toolActionBar: "src/features/tool-shell/tool-action-bar.tsx",
newsletterCta: "src/features/newsletter/components/newsletter-cta.tsx",
}
const ROOT = process.cwd()
const TAXONOMY_PATH = path.join(ROOT, "src/core/analytics/taxonomy.json")
const ANALYTICS_SOURCE_PATH = path.join(ROOT, "src/core/analytics/analytics.ts")
const SRC_ROOT = path.join(ROOT, "src")

const REQUIRED_EVENTS = [
"tool_loaded",
"tool_action",
"copy_output",
"download_output",
"search_performed",
"related_tool_click",
"pwa_installed",
]

const REQUIRED_ALLOWED_PARAMS = [
"tool_id",
"related_tool_id",
"action_type",
"language",
"input_size_bucket",
"query_length_bucket",
"results_count",
"size_bucket",
"source_page",
"platform",
]

const REQUIRED_FORBIDDEN_PARAMS = [
"input",
"output",
"payload",
"token",
"jwt",
"secret",
"url",
"filename",
"file_content",
"image_content",
"log_body",
"hash",
"query",
"search_query",
"user_id",
"session_id",
]

const ALLOWED_TRACK_FUNCTIONS = new Set([
"trackAllowlistedEvent",
"trackToolOpen",
"trackToolRun",
"trackCopyOutput",
"trackDownloadOutput",
"trackSearchPerformed",
"trackRelatedToolClick",
"trackPwaInstalled",
])

function read(filePath) {
if (!fs.existsSync(filePath)) {
throw new Error(`Missing required file: ${filePath}`)
throw new Error(`Missing required file: ${path.relative(ROOT, filePath)}`)
}
return fs.readFileSync(filePath, "utf8")
}

function assertIncludes(source, pattern, errorMessage, failures) {
if (!pattern.test(source)) {
failures.push(errorMessage)
function readJson(filePath) {
return JSON.parse(read(filePath))
}

function walkFiles(dir) {
if (!fs.existsSync(dir)) return []
const files = []
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
const entryPath = path.join(dir, entry.name)
if (entry.isDirectory()) {
if ([".next", "out", "node_modules"].includes(entry.name)) continue
files.push(...walkFiles(entryPath))
} else if (entry.isFile() && /\.(ts|tsx|js|jsx)$/.test(entry.name)) {
files.push(entryPath)
}
}
return files
}

function main() {
const failures = []
const analytics = read(FILES.analytics)
const routeAnalytics = read(FILES.routeAnalytics)
const toolActionBar = read(FILES.toolActionBar)
const newsletterCta = read(FILES.newsletterCta)
function addMissing(items, required, label, failures) {
const set = new Set(items)
for (const item of required) {
if (!set.has(item)) failures.push(`${label} missing ${item}`)
}
}

function checkTaxonomyShape(taxonomy, failures) {
const eventNames = Object.keys(taxonomy.events ?? {})
addMissing(eventNames, REQUIRED_EVENTS, "taxonomy.events", failures)
addMissing(taxonomy.allowedParams ?? [], REQUIRED_ALLOWED_PARAMS, "taxonomy.allowedParams", failures)
addMissing(taxonomy.forbiddenParams ?? [], REQUIRED_FORBIDDEN_PARAMS, "taxonomy.forbiddenParams", failures)

const allowedParams = new Set(taxonomy.allowedParams ?? [])
for (const [eventName, event] of Object.entries(taxonomy.events ?? {})) {
const eventAllowedParams = event.allowedParams ?? []
if (!Array.isArray(eventAllowedParams) || eventAllowedParams.length === 0) {
failures.push(`${eventName} must define allowedParams`)
continue
}
for (const param of eventAllowedParams) {
if (!allowedParams.has(param)) {
failures.push(`${eventName} references non-allowlisted param ${param}`)
}
}
}
}

function checkAnalyticsFacade(taxonomy, failures) {
const source = read(ANALYTICS_SOURCE_PATH)
const requiredExports = [
"isAnalyticsEnabled",
"buildAnalyticsPayload",
"trackAllowlistedEvent",
"trackToolOpen",
"trackToolRun",
"trackCopyOutput",
"trackDownloadOutput",
"trackSearchPerformed",
"trackRelatedToolClick",
"trackPwaInstalled",
]
for (const exportName of requiredExports) {
if (!new RegExp(`export function ${exportName}\\b|export const ${exportName}\\b`).test(source)) {
failures.push(`analytics.ts missing ${exportName} export`)
}
}
if (!source.includes("export const isAnalyticsEnabled = (): boolean => false")) {
failures.push("analytics.ts must keep analytics disabled by default")
}
for (const eventName of Object.keys(taxonomy.events)) {
if (!source.includes(`"${eventName}"`)) {
failures.push(`analytics.ts does not reference allowlisted event ${eventName}`)
}
}
if (/\btrackEvent\b|\btrackCTA\b|\btrackToolUsage\b|\btrackPageView\b|\btrackSeoLanding\b/.test(source)) {
failures.push("analytics.ts still exposes legacy free-form tracking APIs")
}
if (/\bsendBeacon\b|\bfetch\s*\(|document\.cookie|localStorage\.setItem|sessionStorage\.setItem/.test(source)) {
failures.push("analytics.ts must not send remote requests or persist identifiers")
}
}

function checkSourceUsage(taxonomy, failures) {
const forbiddenNames = new Set(taxonomy.forbiddenParams)
const files = walkFiles(SRC_ROOT)
const legacyApiPattern = /\btrack(Event|CTA|ToolUsage|PageView|SeoLanding)\s*\(/
const trackCallPattern = /\b(track[A-Z]\w*)\s*\(([\s\S]*?)\)/g

for (const filePath of files) {
const relativePath = path.relative(ROOT, filePath)
const source = read(filePath)
if (legacyApiPattern.test(source)) {
failures.push(`${relativePath} uses a legacy free-form analytics API`)
}

assertIncludes(analytics, /export (function|const) trackSeoLanding\b/, "analytics.ts missing trackSeoLanding export", failures)
assertIncludes(analytics, /export (function|const) trackToolRun\b/, "analytics.ts missing trackToolRun export", failures)
assertIncludes(analytics, /export (function|const) trackCopyOutput\b/, "analytics.ts missing trackCopyOutput export", failures)
assertIncludes(analytics, /export (function|const) trackDownloadOutput\b/, "analytics.ts missing trackDownloadOutput export", failures)
assertIncludes(analytics, /export (function|const) trackCTA\b/, "analytics.ts missing trackCTA export", failures)
for (const match of source.matchAll(trackCallPattern)) {
const fn = match[1]
if (!ALLOWED_TRACK_FUNCTIONS.has(fn) && relativePath !== "src/core/analytics/analytics.ts") {
failures.push(`${relativePath} uses non-allowlisted analytics function ${fn}`)
}
const callSource = match[0]
for (const forbiddenName of forbiddenNames) {
const pattern = new RegExp(`\\b${forbiddenName.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\b`)
if (pattern.test(callSource) && relativePath !== "src/core/analytics/analytics.ts") {
failures.push(`${relativePath} analytics call references forbidden field ${forbiddenName}`)
}
}
}
}
}

assertIncludes(routeAnalytics, /\btrackSeoLanding\b/, "route-analytics.tsx missing seo_landing tracking usage", failures)
assertIncludes(routeAnalytics, /\btrackCopyOutput\b/, "route-analytics.tsx missing copy_output tracking usage", failures)
assertIncludes(routeAnalytics, /\btrackDownloadOutput\b/, "route-analytics.tsx missing download_output tracking usage", failures)
function checkDocumentation(failures) {
const docs = read(path.join(ROOT, "docs/privacy/analytics-taxonomy.md"))
for (const eventName of REQUIRED_EVENTS) {
if (!docs.includes(eventName)) failures.push(`analytics taxonomy docs missing ${eventName}`)
}
for (const forbiddenName of ["tool input", "tool output", "search query text", "file names", "file contents"]) {
if (!docs.toLowerCase().includes(forbiddenName)) failures.push(`analytics taxonomy docs missing forbidden-data phrase: ${forbiddenName}`)
}
}

assertIncludes(toolActionBar, /\btrackToolRun\b/, "tool-action-bar.tsx missing tool_run tracking usage", failures)
assertIncludes(toolActionBar, /\btrackCopyOutput\b/, "tool-action-bar.tsx missing copy_output tracking usage", failures)
assertIncludes(toolActionBar, /\btrackDownloadOutput\b/, "tool-action-bar.tsx missing download_output tracking usage", failures)
assertIncludes(toolActionBar, /data-analytics-action=/, "tool-action-bar.tsx missing analytics action marker", failures)
function main() {
const failures = []
const taxonomy = readJson(TAXONOMY_PATH)

assertIncludes(newsletterCta, /trackCTA\(\"newsletter\",\s*\"click\"\)/, "newsletter-cta.tsx missing CTA click tracking", failures)
checkTaxonomyShape(taxonomy, failures)
checkAnalyticsFacade(taxonomy, failures)
checkSourceUsage(taxonomy, failures)
checkDocumentation(failures)

if (failures.length > 0) {
console.error(`[check:analytics-taxonomy] ${failures.length} issue(s) found:`)
for (const failure of failures) {
for (const failure of failures.slice(0, 120)) {
console.error(`- ${failure}`)
}
if (failures.length > 120) {
console.error(`... truncated ${failures.length - 120} additional issue(s).`)
}
process.exit(1)
}

console.log("[check:analytics-taxonomy] OK: required SEO and interaction analytics hooks are present.")
console.log("[check:analytics-taxonomy] OK: allowlisted events, safe parameters, docs, and source usage verified.")
}

main()
8 changes: 0 additions & 8 deletions src/app/[lang]/pricing/page.tsx
Original file line number Diff line number Diff line change
@@ -1,19 +1,13 @@
"use client"

import Link from "next/link"
import { useEffect } from "react"
import { Check, Sparkles, ShieldCheck, LockKeyhole, ArrowUpRight } from "lucide-react"
import { useLang } from "@/core/i18n/lang-provider"
import { trackCTA } from "@/core/analytics/analytics"

export default function PricingPage() {
const { t, lang } = useLang()
const p = t.pages

useEffect(() => {
trackCTA("pricing_page", "view")
}, [])

const principles = [
{
icon: Sparkles,
Expand Down Expand Up @@ -88,7 +82,6 @@ export default function PricingPage() {
<section className="flex flex-wrap items-center gap-3 pt-1">
<Link
href={`/${lang}/all-tools`}
onClick={() => trackCTA("pricing_primary", "click")}
className="inline-flex items-center gap-2 rounded-xl border border-primary/35 bg-primary/12 px-4 py-2.5 text-sm text-primary transition-colors hover:bg-primary/20"
>
{p.pricing_cta_primary}
Expand All @@ -97,7 +90,6 @@ export default function PricingPage() {
href="https://github.com/baixiangcpp/byteflow.tools"
target="_blank"
rel="noopener noreferrer"
onClick={() => trackCTA("pricing_secondary", "click")}
className="inline-flex items-center gap-2 rounded-xl border border-border/75 bg-background/55 px-4 py-2.5 text-sm text-muted-foreground transition-colors hover:text-foreground"
>
{p.pricing_cta_secondary}
Expand Down
29 changes: 29 additions & 0 deletions src/components/layout/command-palette.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { getCommandSearchToolByKey } from "@/generated/command-search-index"
import { readFavoriteToolKeys, readRecentToolKeys, TOOL_DISCOVERY_UPDATED_EVENT } from "@/core/storage/tool-discovery-state"
import { useSystemCommands } from "@/core/commands/registry"
import { scoreCommandSearch } from "@/core/search/command-search"
import { trackSearchPerformed } from "@/core/analytics/analytics"
import { getAllToolsHref } from "@/core/routing/all-tools-route"
import { cn } from "@/core/utils/utils"

Expand Down Expand Up @@ -325,6 +326,7 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut =
}, [buildToolCommand])

const [search, setSearch] = React.useState("")
const lastSearchTelemetry = React.useRef("")
const systemCommands = useSystemCommands()
const isCommandMode = search.startsWith(">")
const labelSources = React.useMemo(() => ({
Expand All @@ -333,6 +335,33 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut =
pages: t.pages as unknown as Record<string, string>,
}), [commonLabels, t.nav, t.pages])

React.useEffect(() => {
const trimmed = search.trim()
if (trimmed.length === 0 || trimmed.startsWith(">")) return

const resultCount = [
...recommendedCommands,
...favoriteCommands,
...recentCommands,
...toolGroups.flatMap((group) =>
group.items.map((tool) => buildToolCommand(tool.key)).filter((item): item is ToolCommandItem => item !== null),
),
].filter((item, index, items) => {
if (items.findIndex((candidate) => candidate.toolKey === item.toolKey) !== index) return false
return scoreCommandSearch(item.searchValue, trimmed, item.searchKeywords) > 0
}).length
const telemetryKey = `${trimmed.length}:${resultCount}`
if (lastSearchTelemetry.current === telemetryKey) return
lastSearchTelemetry.current = telemetryKey

trackSearchPerformed({
language: lang,
queryLength: trimmed.length,
resultsCount: resultCount,
sourcePage: "command_palette",
})
}, [buildToolCommand, favoriteCommands, lang, recentCommands, recommendedCommands, search])

return (
<CommandDialog
open={open}
Expand Down
2 changes: 0 additions & 2 deletions src/components/layout/language-switcher.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,6 @@ import { useRouter, usePathname, useSearchParams } from "next/navigation"
import { Globe, ChevronDown } from "lucide-react"
import { LOCALES, LOCALE_NAMES, type Locale } from "@/core/i18n/i18n"
import { useLang } from "@/core/i18n/lang-provider"
import { trackEvent } from "@/core/analytics/analytics"

import {
DropdownMenu,
Expand Down Expand Up @@ -36,7 +35,6 @@ export function LanguageSwitcher() {
const queryString = searchParams.toString()
const hash = typeof window !== "undefined" ? window.location.hash : ""
const targetPath = `${segments.join("/")}${queryString ? `?${queryString}` : ""}${hash}`
trackEvent("navigation", "language_switch", `${lang}->${newLang}`)
router.replace(targetPath)
}

Expand Down
Loading