diff --git a/docs/privacy/analytics-taxonomy.md b/docs/privacy/analytics-taxonomy.md new file mode 100644 index 00000000..9c22168e --- /dev/null +++ b/docs/privacy/analytics-taxonomy.md @@ -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. diff --git a/scripts/gates/check-analytics-taxonomy.js b/scripts/gates/check-analytics-taxonomy.js index a1eb817c..87d1ea4e 100644 --- a/scripts/gates/check-analytics-taxonomy.js +++ b/scripts/gates/check-analytics-taxonomy.js @@ -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() diff --git a/src/app/[lang]/pricing/page.tsx b/src/app/[lang]/pricing/page.tsx index 7de1297b..91813d83 100644 --- a/src/app/[lang]/pricing/page.tsx +++ b/src/app/[lang]/pricing/page.tsx @@ -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, @@ -88,7 +82,6 @@ export default function PricingPage() {
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} @@ -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} diff --git a/src/components/layout/command-palette.tsx b/src/components/layout/command-palette.tsx index 68cf69bc..c593358d 100644 --- a/src/components/layout/command-palette.tsx +++ b/src/components/layout/command-palette.tsx @@ -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" @@ -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(() => ({ @@ -333,6 +335,33 @@ export function CommandPalette({ open: openProp, onOpenChange, enableShortcut = pages: t.pages as unknown as Record, }), [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 ( ${newLang}`) router.replace(targetPath) } diff --git a/src/core/analytics/analytics.ts b/src/core/analytics/analytics.ts index 04328ae2..010dde89 100644 --- a/src/core/analytics/analytics.ts +++ b/src/core/analytics/analytics.ts @@ -1,26 +1,55 @@ /** * Privacy-first analytics facade. * - * Byteflow relies on Cloudflare Web Analytics at the hosting layer for - * cookie-free page analytics. Runtime event hooks remain no-ops so UI code can - * keep semantic instrumentation markers without sending tool usage data. + * Runtime hooks are intentionally no-ops unless a future provider is wired + * behind this allowlisted taxonomy. The API only accepts safe event names and + * coarse parameters, so callers cannot pass tool input, output, raw URLs, + * filenames, tokens, secrets, or search query text. */ -type EventParams = Record +import taxonomy from "./taxonomy.json" + +type AnalyticsTaxonomy = typeof taxonomy +type AnalyticsEventName = keyof AnalyticsTaxonomy["events"] +type AnalyticsParamName = AnalyticsTaxonomy["allowedParams"][number] +type SizeBucket = AnalyticsTaxonomy["sizeBuckets"][number] +type QueryLengthBucket = AnalyticsTaxonomy["queryLengthBuckets"][number] +type AnalyticsValue = string | number | boolean | undefined +type AnalyticsPayload = Partial> type InteractionAnalyticsAction = "copy_output" | "download_output" | null -type SeoLandingParams = { - locale: string - routeType: string -} -type TrackPageView = (path: string) => void -type TrackToolUsage = (toolKey: string, action: string, params?: EventParams) => void -type TrackSeoLanding = (params: SeoLandingParams) => void -type TrackToolInteraction = (toolKey: string, trigger: string) => void -type TrackEvent = (category: string, action: string, label?: string, params?: EventParams) => void -type TrackCTA = (ctaName: string, action: "view" | "click" | "dismiss") => void +type ToolInteractionTrigger = + | "ui_click_fallback" + | "format" + | "minify" + | "validate" + | "convert" + | "generate" + | "decode" + | "encode" + | "hash" + | "run" + | "copy" + | "download" + | "handoff" + | "other" + | string const COPY_SIGNAL_KEYWORDS = ["copy", "copier", "kopieren", "复制", "複製", "コピー", "복사"] const DOWNLOAD_SIGNAL_KEYWORDS = ["download", "herunterladen", "telecharger", "télécharger", "下载", "下載", "ダウンロード", "다운로드"] +const KNOWN_ACTION_KEYWORDS = [ + "format", + "minify", + "validate", + "convert", + "generate", + "decode", + "encode", + "hash", + "run", + "copy", + "download", + "handoff", +] function normalizeInteractionSignal(signal: string): string { return signal @@ -30,8 +59,48 @@ function normalizeInteractionSignal(signal: string): string { .replace(/[\u0300-\u036f]/g, "") } +function normalizeActionType(action: ToolInteractionTrigger): string { + const normalized = normalizeInteractionSignal(action).replace(/[^a-z0-9_:-]+/g, "_") + if (!normalized) return "other" + if (normalized.startsWith("to_") || normalized.includes("handoff")) return "handoff" + const keyword = KNOWN_ACTION_KEYWORDS.find((item) => normalized.includes(item)) + return keyword ?? "other" +} + +function bucketSize(value: number | undefined): SizeBucket { + if (typeof value !== "number" || !Number.isFinite(value) || value < 0) return "unknown" + if (value === 0) return "empty" + if (value <= 1_024) return "tiny" + if (value <= 64_000) return "small" + if (value <= 1_000_000) return "medium" + if (value <= 10_000_000) return "large" + return "huge" +} + +function bucketQueryLength(value: number): QueryLengthBucket { + if (!Number.isFinite(value) || value <= 0) return "empty" + if (value <= 3) return "short" + if (value <= 24) return "medium" + if (value <= 80) return "long" + return "very_long" +} + +function safeLocale(language?: string | null): string | undefined { + if (!language) return undefined + return language.replace(/[^A-Za-z-]/g, "").slice(0, 12) || undefined +} + +function safeIdentifier(value?: string | null): string | undefined { + if (!value) return undefined + return value.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 96) || undefined +} + +function safeSourcePage(value?: string | null): string | undefined { + if (!value) return undefined + return value.replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 64) || undefined +} + export const isAnalyticsEnabled = (): boolean => false -const noop = () => undefined export function detectInteractionAnalyticsAction( signal: string, @@ -56,12 +125,95 @@ export function detectInteractionAnalyticsAction( return null } -export const trackPageView: TrackPageView = noop -export const trackToolUsage: TrackToolUsage = noop -export const trackSeoLanding: TrackSeoLanding = noop -export const trackToolOpen: (toolKey: string) => void = noop -export const trackToolRun: TrackToolInteraction = noop -export const trackCopyOutput: TrackToolInteraction = noop -export const trackDownloadOutput: TrackToolInteraction = noop -export const trackEvent: TrackEvent = noop -export const trackCTA: TrackCTA = noop +export function buildAnalyticsPayload(event: AnalyticsEventName, params: AnalyticsPayload = {}) { + const allowedParams = new Set(taxonomy.events[event].allowedParams) + return Object.fromEntries( + Object.entries(params).filter(([key, value]) => allowedParams.has(key) && value !== undefined), + ) as AnalyticsPayload +} + +export function trackAllowlistedEvent(_event: AnalyticsEventName, _params: AnalyticsPayload = {}): void { + // No-op by design. Future providers must call buildAnalyticsPayload first. + void _event + void _params +} + +export function trackToolOpen(toolKey: string, language?: string | null, sourcePage?: string | null): void { + trackAllowlistedEvent("tool_loaded", buildAnalyticsPayload("tool_loaded", { + tool_id: safeIdentifier(toolKey), + language: safeLocale(language), + source_page: safeSourcePage(sourcePage), + })) +} + +export function trackToolRun( + toolKey: string, + action: ToolInteractionTrigger, + options: { language?: string | null; inputSize?: number; sourcePage?: string | null } = {}, +): void { + trackAllowlistedEvent("tool_action", buildAnalyticsPayload("tool_action", { + tool_id: safeIdentifier(toolKey), + action_type: normalizeActionType(action), + language: safeLocale(options.language), + input_size_bucket: bucketSize(options.inputSize), + source_page: safeSourcePage(options.sourcePage), + })) +} + +export function trackCopyOutput(toolKey: string, trigger: ToolInteractionTrigger, language?: string | null): void { + trackAllowlistedEvent("copy_output", buildAnalyticsPayload("copy_output", { + tool_id: safeIdentifier(toolKey), + action_type: normalizeActionType(trigger), + language: safeLocale(language), + })) +} + +export function trackDownloadOutput( + toolKey: string, + trigger: ToolInteractionTrigger, + options: { language?: string | null; size?: number; sourcePage?: string | null } = {}, +): void { + trackAllowlistedEvent("download_output", buildAnalyticsPayload("download_output", { + tool_id: safeIdentifier(toolKey), + action_type: normalizeActionType(trigger), + language: safeLocale(options.language), + size_bucket: bucketSize(options.size), + source_page: safeSourcePage(options.sourcePage), + })) +} + +export function trackSearchPerformed(params: { + language?: string | null + queryLength: number + resultsCount: number + sourcePage?: string | null +}): void { + trackAllowlistedEvent("search_performed", buildAnalyticsPayload("search_performed", { + language: safeLocale(params.language), + query_length_bucket: bucketQueryLength(params.queryLength), + results_count: Math.max(0, Math.trunc(params.resultsCount)), + source_page: safeSourcePage(params.sourcePage), + })) +} + +export function trackRelatedToolClick(params: { + toolKey: string + relatedToolKey: string + language?: string | null + sourcePage?: string | null +}): void { + trackAllowlistedEvent("related_tool_click", buildAnalyticsPayload("related_tool_click", { + tool_id: safeIdentifier(params.toolKey), + related_tool_id: safeIdentifier(params.relatedToolKey), + language: safeLocale(params.language), + source_page: safeSourcePage(params.sourcePage), + })) +} + +export function trackPwaInstalled(language?: string | null, platform?: string | null, sourcePage = "install_app"): void { + trackAllowlistedEvent("pwa_installed", buildAnalyticsPayload("pwa_installed", { + language: safeLocale(language), + platform: safeIdentifier(platform), + source_page: safeSourcePage(sourcePage), + })) +} diff --git a/src/core/analytics/components/deferred-analytics.tsx b/src/core/analytics/components/deferred-analytics.tsx index 6b6782a0..b27d8b06 100644 --- a/src/core/analytics/components/deferred-analytics.tsx +++ b/src/core/analytics/components/deferred-analytics.tsx @@ -8,20 +8,10 @@ const RouteAnalytics = dynamic( { ssr: false }, ) -const WebVitalsAnalytics = dynamic( - () => import("./web-vitals-analytics").then((mod) => mod.WebVitalsAnalytics), - { ssr: false }, -) - export function DeferredAnalytics() { const isMounted = useDeferredMount({ delayMs: 1500, activateOnInteraction: true }) if (!isMounted) return null - return ( - <> - - - - ) + return } diff --git a/src/core/analytics/components/route-analytics.tsx b/src/core/analytics/components/route-analytics.tsx index 68fd89d7..6f565406 100644 --- a/src/core/analytics/components/route-analytics.tsx +++ b/src/core/analytics/components/route-analytics.tsx @@ -3,10 +3,7 @@ import * as React from "react" import { usePathname } from "next/navigation" import { - trackPageView, trackToolOpen, - trackEvent, - trackSeoLanding, trackCopyOutput, trackDownloadOutput, detectInteractionAnalyticsAction, @@ -14,20 +11,6 @@ import { import { getRouteToolBySlug } from "@/generated/route-tool-lookup" import { getRouteContext } from "@/core/routing/route-context" -const SEO_LANDING_SESSION_KEY = "byteflow:seo_landing_tracked" -const ORGANIC_REFERRER_HOST_KEYWORDS = ["google.", "bing.", "duckduckgo.", "yahoo.", "baidu.", "yandex."] - -function isOrganicSearchReferrer() { - if (typeof document === "undefined") return false - if (!document.referrer) return false - try { - const host = new URL(document.referrer).hostname.toLowerCase() - return ORGANIC_REFERRER_HOST_KEYWORDS.some((keyword) => host.includes(keyword)) - } catch { - return false - } -} - export function RouteAnalytics() { const pathname = usePathname() const lastTrackedPath = React.useRef("") @@ -36,29 +19,13 @@ export function RouteAnalytics() { if (!pathname || lastTrackedPath.current === pathname) return lastTrackedPath.current = pathname - trackPageView(pathname) - const context = getRouteContext(pathname) if (!context.locale) return - trackEvent("navigation", "locale_page_view", context.locale) - try { - const tracked = sessionStorage.getItem(SEO_LANDING_SESSION_KEY) === "1" - if (!tracked && isOrganicSearchReferrer()) { - trackSeoLanding({ - locale: context.locale, - routeType: context.routeType, - }) - sessionStorage.setItem(SEO_LANDING_SESSION_KEY, "1") - } - } catch { - // Storage access can fail in strict privacy modes; skip tracking in that case. - } - if (!context.slug) return const tool = getRouteToolBySlug(context.slug) if (tool) { - trackToolOpen(tool.key) + trackToolOpen(tool.key, context.locale, "tool_page") } }, [pathname]) @@ -90,12 +57,12 @@ export function RouteAnalytics() { hasDownloadAttribute: Boolean(anchor?.download), }) if (analyticsAction === "copy_output") { - trackCopyOutput(tool.key, "ui_click_fallback") + trackCopyOutput(tool.key, "ui_click_fallback", context.locale) return } if (analyticsAction === "download_output") { - trackDownloadOutput(tool.key, "ui_click_fallback") + trackDownloadOutput(tool.key, "ui_click_fallback", { language: context.locale, sourcePage: "tool_page" }) } } diff --git a/src/core/analytics/components/web-vitals-analytics.tsx b/src/core/analytics/components/web-vitals-analytics.tsx deleted file mode 100644 index 1f035093..00000000 --- a/src/core/analytics/components/web-vitals-analytics.tsx +++ /dev/null @@ -1,30 +0,0 @@ -"use client" - -import { useMemo } from "react" -import { usePathname } from "next/navigation" -import { useReportWebVitals } from "next/web-vitals" -import { trackEvent } from "@/core/analytics/analytics" -import { getRouteContext } from "@/core/routing/route-context" - -const TRACKED_CWV_METRICS = new Set(["LCP", "INP", "CLS"]) - -export function WebVitalsAnalytics() { - const pathname = usePathname() - const context = useMemo(() => getRouteContext(pathname || "/"), [pathname]) - - useReportWebVitals((metric) => { - if (!TRACKED_CWV_METRICS.has(metric.name)) return - - trackEvent("web_vitals", "cwv_metric", `${metric.name}:${context.routeType}`, { - metric_name: metric.name, - metric_value: Number(metric.value.toFixed(2)), - metric_delta: Number(metric.delta.toFixed(2)), - metric_rating: metric.rating, - metric_id: metric.id, - route_type: context.routeType, - locale: context.locale || "global", - }) - }) - - return null -} diff --git a/src/core/analytics/taxonomy.json b/src/core/analytics/taxonomy.json new file mode 100644 index 00000000..abce0244 --- /dev/null +++ b/src/core/analytics/taxonomy.json @@ -0,0 +1,71 @@ +{ + "events": { + "tool_loaded": { + "description": "A tool page became active in the browser.", + "allowedParams": ["tool_id", "language", "source_page"] + }, + "tool_action": { + "description": "A user ran a non-copy, non-download tool action.", + "allowedParams": ["tool_id", "action_type", "language", "input_size_bucket", "source_page"] + }, + "copy_output": { + "description": "A user copied a tool output through a UI control.", + "allowedParams": ["tool_id", "action_type", "language", "source_page"] + }, + "download_output": { + "description": "A user downloaded generated output through a UI control.", + "allowedParams": ["tool_id", "action_type", "language", "size_bucket", "source_page"] + }, + "search_performed": { + "description": "A search UI produced results without storing the query text.", + "allowedParams": ["language", "query_length_bucket", "results_count", "source_page"] + }, + "related_tool_click": { + "description": "A user clicked a related tool recommendation.", + "allowedParams": ["tool_id", "related_tool_id", "language", "source_page"] + }, + "pwa_installed": { + "description": "The browser reported successful PWA installation.", + "allowedParams": ["language", "platform", "source_page"] + } + }, + "allowedParams": [ + "tool_id", + "related_tool_id", + "action_type", + "language", + "input_size_bucket", + "query_length_bucket", + "results_count", + "size_bucket", + "source_page", + "platform" + ], + "forbiddenParams": [ + "input", + "input_text", + "output", + "output_text", + "payload", + "token", + "jwt", + "secret", + "url", + "full_url", + "filename", + "file_name", + "file_content", + "image_content", + "log_body", + "hash", + "query", + "search_query", + "raw_url", + "page_path", + "metric_id", + "user_id", + "session_id" + ], + "sizeBuckets": ["empty", "tiny", "small", "medium", "large", "huge", "unknown"], + "queryLengthBuckets": ["empty", "short", "medium", "long", "very_long"] +} diff --git a/src/core/i18n/translations/de.json b/src/core/i18n/translations/de.json index f3255e09..b728d8c4 100644 --- a/src/core/i18n/translations/de.json +++ b/src/core/i18n/translations/de.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "Cookies & Speicherung", "privacy_cookies_desc": "localStorage wird nur für sichere UI-Einstellungen wie Theme, Sprache, Sidebar-Status und lokale Tool-Optionen verwendet. Sensible Payloads, Tokens, Logs, Dateiinhalte und Tool-Ausgaben sind nicht zur Speicherung vorgesehen.", "privacy_analytics_title": "Analyse", - "privacy_analytics_desc": "Wir können Cookie-freie Cloudflare Web Analytics nutzen, um aggregierte Seitenaufrufe zu verstehen. Tool-Eingaben, Ausgaben oder personenbezogene Daten werden dadurch nicht verfolgt.", + "privacy_analytics_desc": "Analytics sind auf eine Cookie-freie Ereignistaxonomie mit Positivliste für aggregierte Produktsignale begrenzt. Erlaubte Ereignisse sind Tool geladen, Tool-Aktion, Kopieren, Download, Suche ausgeführt, Klick auf verwandtes Tool und PWA installiert. Suchereignisse erfassen nur Längen-Buckets und Ergebniszahlen. Tool-Eingaben, Ausgaben, JWTs, Geheimnisse, Logs, Dateinamen, Dateiinhalte, Bildinhalte, Hashwerte, vollständige URLs, Nutzer-IDs und Sitzungs-IDs sind niemals Analytics-Felder.", "privacy_third_party_title": "Drittanbieter-Dienste", "privacy_third_party_desc": "Einige Tools können externe APIs oder von Ihnen angegebene URLs anfragen, um Funktionen bereitzustellen. Diese Tools sind als externe Anfrage markiert und beschreiben den Netzwerkzweck vor der Ausführung.", "privacy_contact_title": "Kontakt", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "Strategie für lokalen Speicher", "trust_center_storage_desc": "Lokaler Speicher ist für sichere Einstellungen wie Theme, Sprache, Sidebar-Status, zuletzt genutzte Tool-IDs und lokale Presets reserviert. Tool-Payloads, Token, Logs, Dateiinhalte und erzeugte Ausgaben dürfen standardmäßig nicht persistiert werden.", "trust_center_analytics_title": "Analytics-Strategie", - "trust_center_analytics_desc": "Analytics sind auf aggregierte Produktsignale wie Seitenaufrufe und sichere Ereignisnamen beschränkt. Tool-Eingaben, Ausgaben, JWTs, Geheimnisse, Logtexte, Dateiinhalte, Bildinhalte, Suchtexte und vollständige URLs sind keine Analytics-Felder.", + "trust_center_analytics_desc": "Analytics verwenden eine Ereignistaxonomie mit Positivliste und sind im Laufzeitcode standardmäßig deaktiviert. Wenn ein Cookie-freier Aggregationsanbieter aktiviert wird, darf er nur sichere Ereignisnamen und grobe Parameter wie Tool-ID, Aktionstyp, Sprache, Größen-Bucket, Ergebniszahl und Quellseite erhalten. Tool-Eingaben, Ausgaben, JWTs, Geheimnisse, Logtexte, Dateinamen, Dateiinhalte, Bildinhalte, Hashwerte, Suchtexte, vollständige URLs, Nutzer-IDs und Sitzungs-IDs sind verboten.", "trust_center_pwa_title": "PWA-Cache-Strategie", "trust_center_pwa_desc": "Die PWA kann versionierte App-Shell-Dateien, statische Assets, Icons und Tool-Chunks für Offline-Nutzung zwischenspeichern. Antworten externer Anfragen bleiben network-only, und gecachte App-Dateien lassen sich auf der Installationsseite löschen. Tool-Eingaben, Ausgaben, hochgeladene Dateiinhalte und Antworten externer Anfragen dürfen nicht gecacht werden.", "trust_center_security_headers_title": "Sicherheitsheader und CSP", diff --git a/src/core/i18n/translations/en.json b/src/core/i18n/translations/en.json index aaee6fb2..6ce93e29 100644 --- a/src/core/i18n/translations/en.json +++ b/src/core/i18n/translations/en.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "Cookies & Storage", "privacy_cookies_desc": "We use localStorage only for safe UI preferences such as theme, language, sidebar state, and local tool settings. Sensitive payloads, tokens, logs, file contents, and tool outputs are not meant to be stored there.", "privacy_analytics_title": "Analytics", - "privacy_analytics_desc": "We may use cookie-free Cloudflare Web Analytics to understand aggregate page views. We do not track tool inputs, outputs, or personal data through analytics.", + "privacy_analytics_desc": "Analytics are limited to an allowlisted, cookie-free taxonomy for aggregate product signals. Allowed events include tool loaded, tool action, copy, download, search performed, related-tool click, and PWA installed. Search events record only query-length buckets and result counts; tool inputs, outputs, JWTs, secrets, logs, file names, file contents, image contents, hash values, full URLs, user IDs, and session IDs are never analytics fields.", "privacy_third_party_title": "Third-Party Services", "privacy_third_party_desc": "Some tools may request external APIs or user-provided URLs to provide functionality. Those tools are labeled External request and describe the network purpose before you run them.", "privacy_contact_title": "Contact", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "Local storage strategy", "trust_center_storage_desc": "Local storage is reserved for safe preferences such as theme, language, sidebar state, recent tool IDs, and local presets. Tool payloads, tokens, logs, file contents, and generated outputs must not be persisted by default.", "trust_center_analytics_title": "Analytics strategy", - "trust_center_analytics_desc": "Analytics are limited to aggregate product signals such as page views and safe event names. Tool input, output, JWTs, secrets, log bodies, file contents, image contents, search query text, and full URLs are not analytics fields.", + "trust_center_analytics_desc": "Analytics use an allowlisted event taxonomy and are disabled by default in runtime code. If a cookie-free aggregate provider is enabled, it may receive only safe event names and coarse parameters such as tool ID, action type, language, size bucket, result count, and source page. Tool input, output, JWTs, secrets, log bodies, file names, file contents, image contents, hash values, search query text, full URLs, user IDs, and session IDs are forbidden.", "trust_center_pwa_title": "PWA cache strategy", "trust_center_pwa_desc": "The PWA may cache versioned app shell files, static assets, icons, and tool chunks for offline use. External-request responses are network-only, and you can clear cached app files from the install page. Tool input, output, uploaded file content, and external-request responses must not be cached.", "trust_center_security_headers_title": "Security headers and CSP", diff --git a/src/core/i18n/translations/fr.json b/src/core/i18n/translations/fr.json index accb8647..f2c9e3e7 100644 --- a/src/core/i18n/translations/fr.json +++ b/src/core/i18n/translations/fr.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "Cookies et stockage", "privacy_cookies_desc": "Nous utilisons localStorage uniquement pour des préférences d’interface sûres comme le thème, la langue, l’état de la barre latérale et les réglages locaux d’outils. Les payloads sensibles, tokens, logs, contenus de fichiers et sorties d’outils ne sont pas destinés à y être stockés.", "privacy_analytics_title": "Analytique", - "privacy_analytics_desc": "Nous pouvons utiliser Cloudflare Web Analytics sans cookie pour comprendre les vues de pages agrégées. Nous ne suivons pas les entrées, sorties ni données personnelles des outils via l'analytique.", + "privacy_analytics_desc": "L’analytics est limitée à une taxonomie d’événements sur liste d’autorisation, sans cookie, pour des signaux produit agrégés. Les événements autorisés sont outil chargé, action d’outil, copie, téléchargement, recherche effectuée, clic sur outil connexe et PWA installée. Les événements de recherche enregistrent seulement des tranches de longueur et le nombre de résultats. Entrées, sorties, JWT, secrets, journaux, noms de fichiers, contenus de fichiers, contenus d’images, valeurs de hachage, URL complètes, identifiants utilisateur et identifiants de session ne sont jamais des champs analytics.", "privacy_third_party_title": "Services tiers", "privacy_third_party_desc": "Certains outils peuvent demander des API externes ou des URL que vous fournissez pour fonctionner. Ces outils sont marqués Requête externe et décrivent l’objectif réseau avant l’exécution.", "privacy_contact_title": "Contact", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "Stratégie de stockage local", "trust_center_storage_desc": "Le stockage local est réservé aux préférences sûres comme le thème, la langue, l’état de la barre latérale, les IDs d’outils récents et les presets locaux. Les payloads d’outil, tokens, journaux, contenus de fichiers et sorties générées ne doivent pas être persistés par défaut.", "trust_center_analytics_title": "Stratégie analytics", - "trust_center_analytics_desc": "L’analytics est limitée aux signaux produit agrégés comme les vues de page et des noms d’événements sûrs. Entrées, sorties, JWT, secrets, corps de journaux, contenus de fichiers, contenus d’images, texte de recherche et URL complètes ne sont pas des champs analytics.", + "trust_center_analytics_desc": "L’analytics utilise une taxonomie d’événements sur liste d’autorisation et le code d’exécution désactive l’envoi par défaut. Si un fournisseur d’agrégation sans cookie est activé, il ne peut recevoir que des noms d’événements sûrs et des paramètres grossiers comme l’ID d’outil, le type d’action, la langue, la tranche de taille, le nombre de résultats et la page source. Entrées, sorties, JWT, secrets, corps de journaux, noms de fichiers, contenus de fichiers, contenus d’images, valeurs de hachage, texte de recherche, URL complètes, identifiants utilisateur et identifiants de session sont interdits.", "trust_center_pwa_title": "Stratégie de cache PWA", "trust_center_pwa_desc": "La PWA peut mettre en cache des fichiers de shell versionnés, des assets statiques, des icônes et des modules d’outils pour l’usage hors ligne. Les réponses de requêtes externes restent network-only, et les fichiers d’app en cache peuvent être effacés depuis la page d’installation. Elle ne doit pas cacher les entrées, sorties, contenus de fichiers téléversés ni réponses de requêtes externes.", "trust_center_security_headers_title": "En-têtes de sécurité et CSP", diff --git a/src/core/i18n/translations/ja.json b/src/core/i18n/translations/ja.json index 6624c16a..3030c72a 100644 --- a/src/core/i18n/translations/ja.json +++ b/src/core/i18n/translations/ja.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "Cookie とストレージ", "privacy_cookies_desc": "localStorage はテーマ、言語、サイドバー状態、ローカルツール設定など安全な UI 設定にのみ使用します。機密 payload、token、ログ、ファイル内容、ツール出力は保存対象ではありません。", "privacy_analytics_title": "解析", - "privacy_analytics_desc": "集計されたページ閲覧数を把握するため、Cookie を使わない Cloudflare Web Analytics を利用する場合があります。ツールの入力、出力、個人データを解析で追跡することはありません。", + "privacy_analytics_desc": "分析は Cookie を使わない許可リスト形式のイベント分類に基づく集計プロダクトシグナルに限定します。許可されるイベントは、ツール読み込み、ツール操作、コピー、ダウンロード、検索実行、関連ツールクリック、PWA インストールです。検索イベントはクエリ長のバケットと結果数だけを記録します。ツール入力、出力、JWT、秘密情報、ログ、ファイル名、ファイル内容、画像内容、ハッシュ値、完全な URL、ユーザー ID、セッション ID は分析項目にしません。", "privacy_third_party_title": "外部サービス", "privacy_third_party_desc": "一部のツールは、機能提供のために外部 API または入力された URL をリクエストする場合があります。それらは「外部リクエスト」と表示され、実行前にネットワーク目的を説明します。", "privacy_contact_title": "お問い合わせ", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "ローカル保存方針", "trust_center_storage_desc": "ローカル保存はテーマ、言語、サイドバー状態、最近使ったツール ID、ローカルプリセットなど安全な設定に限定します。ツール payload、token、ログ、ファイル内容、生成出力は既定で永続化してはいけません。", "trust_center_analytics_title": "分析方針", - "trust_center_analytics_desc": "分析はページビューや安全なイベント名など集計された製品シグナルに限定します。ツール入力、出力、JWT、秘密情報、ログ本文、ファイル内容、画像内容、検索語、完全な URL は分析項目にしません。", + "trust_center_analytics_desc": "分析は許可リスト形式のイベント分類を使い、実行時コードでは既定で送信を無効にしています。Cookie を使わない集計サービスを有効にする場合でも、安全なイベント名と、ツール ID、操作種別、言語、サイズバケット、結果数、参照元ページなどの粗いパラメータだけを送れます。ツール入力、出力、JWT、秘密情報、ログ本文、ファイル名、ファイル内容、画像内容、ハッシュ値、検索語、完全な URL、ユーザー ID、セッション ID は禁止です。", "trust_center_pwa_title": "PWA キャッシュ方針", "trust_center_pwa_desc": "PWA はオフライン利用のために、バージョン付きのアプリシェル、静的アセット、アイコン、ツールチャンクをキャッシュできます。外部リクエスト応答は network-only で、インストールページからキャッシュ済みアプリファイルを消去できます。ツール入力、出力、アップロードファイル内容、外部リクエスト応答はキャッシュしてはいけません。", "trust_center_security_headers_title": "セキュリティヘッダーと CSP", diff --git a/src/core/i18n/translations/ko.json b/src/core/i18n/translations/ko.json index 719899fb..33fd988f 100644 --- a/src/core/i18n/translations/ko.json +++ b/src/core/i18n/translations/ko.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "쿠키 및 저장소", "privacy_cookies_desc": "localStorage는 테마, 언어, 사이드바 상태, 로컬 도구 설정 같은 안전한 UI 환경설정에만 사용합니다. 민감한 payload, token, 로그, 파일 내용, 도구 출력은 저장 대상이 아닙니다.", "privacy_analytics_title": "분석", - "privacy_analytics_desc": "집계된 페이지 조회를 파악하기 위해 쿠키 없는 Cloudflare Web Analytics를 사용할 수 있습니다. 분석을 통해 도구 입력, 출력 또는 개인 데이터를 추적하지 않습니다.", + "privacy_analytics_desc": "분석은 쿠키 없는 허용 목록 이벤트 분류의 집계 제품 신호로 제한됩니다. 허용 이벤트는 도구 로드, 도구 작업, 복사, 다운로드, 검색 실행, 관련 도구 클릭, PWA 설치입니다. 검색 이벤트는 쿼리 길이 버킷과 결과 수만 기록합니다. 도구 입력, 출력, JWT, 비밀값, 로그, 파일 이름, 파일 내용, 이미지 내용, 해시값, 전체 URL, 사용자 ID, 세션 ID는 분석 필드가 아닙니다.", "privacy_third_party_title": "제3자 서비스", "privacy_third_party_desc": "일부 도구는 기능 제공을 위해 외부 API 또는 사용자가 제공한 URL을 요청할 수 있습니다. 이런 도구는 “외부 요청”으로 표시되며 실행 전에 네트워크 목적을 설명합니다.", "privacy_contact_title": "문의", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "로컬 저장소 전략", "trust_center_storage_desc": "로컬 저장소는 테마, 언어, 사이드바 상태, 최근 도구 ID, 로컬 프리셋 같은 안전한 설정에 한정됩니다. 도구 payload, token, 로그, 파일 내용, 생성 출력은 기본적으로 지속 저장하면 안 됩니다.", "trust_center_analytics_title": "분석 전략", - "trust_center_analytics_desc": "분석은 페이지 조회와 안전한 이벤트 이름 같은 집계 제품 신호로 제한됩니다. 도구 입력, 출력, JWT, 비밀값, 로그 본문, 파일 내용, 이미지 내용, 검색 원문, 전체 URL은 분석 필드가 아닙니다.", + "trust_center_analytics_desc": "분석은 허용 목록 이벤트 분류를 사용하며 런타임 코드는 기본적으로 전송을 비활성화합니다. 쿠키 없는 집계 제공자가 활성화되더라도 안전한 이벤트 이름과 도구 ID, 작업 유형, 언어, 크기 버킷, 결과 수, 출처 페이지 같은 거친 매개변수만 받을 수 있습니다. 도구 입력, 출력, JWT, 비밀값, 로그 본문, 파일 이름, 파일 내용, 이미지 내용, 해시값, 검색 원문, 전체 URL, 사용자 ID, 세션 ID는 금지됩니다.", "trust_center_pwa_title": "PWA 캐시 전략", "trust_center_pwa_desc": "PWA는 오프라인 사용을 위해 버전이 지정된 앱 셸, 정적 자산, 아이콘, 도구 청크를 캐시할 수 있습니다. 외부 요청 응답은 network-only이며, 설치 페이지에서 캐시된 앱 파일을 지울 수 있습니다. 도구 입력, 도구 출력, 업로드 파일 내용, 외부 요청 응답은 캐시하면 안 됩니다.", "trust_center_security_headers_title": "보안 헤더와 CSP", diff --git a/src/core/i18n/translations/zh-CN.json b/src/core/i18n/translations/zh-CN.json index 9d6792a2..42fc7326 100644 --- a/src/core/i18n/translations/zh-CN.json +++ b/src/core/i18n/translations/zh-CN.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "Cookie 与本地存储", "privacy_cookies_desc": "我们仅使用 localStorage 保存主题、语言、侧边栏状态和本地工具设置等安全界面偏好。敏感 payload、token、日志、文件内容和工具输出不应保存在这里。", "privacy_analytics_title": "分析", - "privacy_analytics_desc": "我们可能使用无 Cookie 的 Cloudflare Web Analytics 来了解汇总页面访问量。我们不会通过分析追踪工具输入、输出或个人数据。", + "privacy_analytics_desc": "分析仅限无 Cookie、白名单化事件分类中的聚合产品信号。允许事件包括工具加载、工具操作、复制、下载、执行搜索、相关工具点击和 PWA 安装。搜索事件只记录查询长度分桶和结果数量;工具输入、输出、JWT、密钥、日志、文件名、文件内容、图片内容、哈希值、完整 URL、用户 ID 和会话 ID 永远不是分析字段。", "privacy_third_party_title": "第三方服务", "privacy_third_party_desc": "部分工具可能请求外部 API 或你提供的 URL 来提供功能。这些工具会标记为“外部请求”,并在运行前说明网络用途。", "privacy_contact_title": "联系", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "本地存储策略", "trust_center_storage_desc": "本地存储仅用于主题、语言、侧边栏状态、最近工具 ID 和本地预设等安全偏好。工具 payload、token、日志、文件内容和生成输出默认不得持久化。", "trust_center_analytics_title": "分析策略", - "trust_center_analytics_desc": "分析仅限页面访问量和安全事件名等聚合产品信号。工具输入、输出、JWT、密钥、日志正文、文件内容、图片内容、搜索原文和完整 URL 都不是分析字段。", + "trust_center_analytics_desc": "分析使用白名单事件分类,并且运行时代码默认禁用上报。若启用无 Cookie 的聚合提供方,也只能接收安全事件名和粗粒度参数,例如工具 ID、操作类型、语言、大小分桶、结果数量和来源页面。工具输入、输出、JWT、密钥、日志正文、文件名、文件内容、图片内容、哈希值、搜索原文、完整 URL、用户 ID 和会话 ID 均被禁止。", "trust_center_pwa_title": "PWA 缓存策略", "trust_center_pwa_desc": "PWA 可缓存带版本的应用外壳、静态资源、图标和工具代码块以支持离线使用。外部请求响应始终走 network-only,你也可以在安装页清除已缓存应用文件。工具输入、工具输出、上传文件内容和外部请求响应不得被缓存。", "trust_center_security_headers_title": "安全响应头与 CSP", diff --git a/src/core/i18n/translations/zh-TW.json b/src/core/i18n/translations/zh-TW.json index b1d695ba..dddf17aa 100644 --- a/src/core/i18n/translations/zh-TW.json +++ b/src/core/i18n/translations/zh-TW.json @@ -2767,7 +2767,7 @@ "privacy_cookies_title": "Cookie 與本地儲存", "privacy_cookies_desc": "我們僅使用 localStorage 儲存主題、語言、側邊欄狀態和本地工具設定等安全介面偏好。敏感 payload、token、日誌、檔案內容和工具輸出不應儲存在這裡。", "privacy_analytics_title": "分析", - "privacy_analytics_desc": "我們可能使用無 Cookie 的 Cloudflare Web Analytics 來了解彙總頁面瀏覽量。我們不會透過分析追蹤工具輸入、輸出或個人資料。", + "privacy_analytics_desc": "分析僅限無 Cookie、白名單化事件分類中的彙總產品訊號。允許事件包括工具載入、工具操作、複製、下載、執行搜尋、相關工具點擊和 PWA 安裝。搜尋事件只記錄查詢長度分桶和結果數量;工具輸入、輸出、JWT、金鑰、日誌、檔名、檔案內容、圖片內容、雜湊值、完整 URL、使用者 ID 和工作階段 ID 永遠不是分析欄位。", "privacy_third_party_title": "第三方服務", "privacy_third_party_desc": "部分工具可能會請求外部 API 或你提供的 URL 以提供功能。這些工具會標記為「外部請求」,並在執行前說明網路用途。", "privacy_contact_title": "聯絡", @@ -2821,7 +2821,7 @@ "trust_center_storage_title": "本地儲存策略", "trust_center_storage_desc": "本地儲存僅用於主題、語言、側邊欄狀態、最近工具 ID 和本地預設等安全偏好。工具 payload、token、日誌、檔案內容和產生輸出預設不得持久化。", "trust_center_analytics_title": "分析策略", - "trust_center_analytics_desc": "分析僅限頁面瀏覽量和安全事件名稱等彙總產品訊號。工具輸入、輸出、JWT、金鑰、日誌本文、檔案內容、圖片內容、搜尋原文和完整 URL 都不是分析欄位。", + "trust_center_analytics_desc": "分析使用白名單事件分類,且執行階段程式碼預設停用上報。若啟用無 Cookie 的彙總提供方,也只能接收安全事件名稱和粗粒度參數,例如工具 ID、操作類型、語言、大小分桶、結果數量和來源頁面。工具輸入、輸出、JWT、金鑰、日誌本文、檔名、檔案內容、圖片內容、雜湊值、搜尋原文、完整 URL、使用者 ID 和工作階段 ID 均被禁止。", "trust_center_pwa_title": "PWA 快取策略", "trust_center_pwa_desc": "PWA 可快取帶版本的應用外殼、靜態資源、圖示和工具程式碼區塊以支援離線使用。外部請求回應一律採 network-only,你也可以在安裝頁清除已快取應用檔案。工具輸入、工具輸出、上傳檔案內容和外部請求回應不得被快取。", "trust_center_security_headers_title": "安全回應標頭與 CSP", diff --git a/src/core/seo/components/related-tools.tsx b/src/core/seo/components/related-tools.tsx index bca920f8..ba0e768a 100644 --- a/src/core/seo/components/related-tools.tsx +++ b/src/core/seo/components/related-tools.tsx @@ -4,6 +4,7 @@ import Link from "next/link" import { useLang } from "@/core/i18n/lang-provider" import { getDiscoveryRelatedTools } from "@/generated/discovery-tool-index" import { requireTranslationValue } from "@/core/i18n/i18n" +import { trackRelatedToolClick } from "@/core/analytics/analytics" import { ArrowRight } from "lucide-react" /** @@ -37,6 +38,12 @@ export function RelatedTools({ trackRelatedToolClick({ + toolKey, + relatedToolKey: tool.key, + language: lang, + sourcePage: source === "fallback" ? "related_tools_fallback" : "related_tools_inline", + })} className="group flex items-start gap-3 rounded-xl border border-border/70 bg-background/55 p-3 transition-[border-color,background-color,box-shadow] duration-200 hover:border-primary/30 hover:bg-accent/40" >
diff --git a/src/features/install-app/components/install-app-client.tsx b/src/features/install-app/components/install-app-client.tsx index 36d4bfeb..74e4a71c 100644 --- a/src/features/install-app/components/install-app-client.tsx +++ b/src/features/install-app/components/install-app-client.tsx @@ -4,7 +4,7 @@ import * as React from "react" import Link from "next/link" import Image from "next/image" import { CheckCircle2, Download, Globe, Shield, Smartphone, Trash2, WifiOff, Zap } from "lucide-react" -import { trackEvent } from "@/core/analytics/analytics" +import { trackPwaInstalled } from "@/core/analytics/analytics" import { Button } from "@/components/ui/button" import { getAllToolsHref } from "@/core/routing/all-tools-route" import type { Locale } from "@/core/i18n/i18n" @@ -85,18 +85,14 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel const guideRef = React.useRef(null) const installSuccessTrackedRef = React.useRef(false) - const trackInstallSuccess = React.useCallback((platformName?: string) => { + const recordInstallSuccess = React.useCallback((platformName?: string) => { if (installSuccessTrackedRef.current) return installSuccessTrackedRef.current = true - trackEvent("pwa", "pwa_install_success", "cta_page", { - locale, - ...(platformName ? { platform: platformName } : {}), - }) + trackPwaInstalled(locale, platformName, "install_app") }, [locale]) React.useEffect(() => { setInstalled(isStandaloneInstalled()) - trackEvent("pwa", "pwa_cta_page_view", locale, { locale }) const onBeforeInstallPrompt = (event: Event) => { event.preventDefault() @@ -105,7 +101,7 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel const onInstalled = () => { setInstalled(true) setDeferredPrompt(null) - trackInstallSuccess() + recordInstallSuccess() } window.addEventListener("beforeinstallprompt", onBeforeInstallPrompt as EventListener) @@ -114,7 +110,7 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel window.removeEventListener("beforeinstallprompt", onBeforeInstallPrompt as EventListener) window.removeEventListener("appinstalled", onInstalled) } - }, [locale, trackInstallSuccess]) + }, [locale, recordInstallSuccess]) const activeGuide = copy.guides[platform] const faqJsonLd = React.useMemo( @@ -130,21 +126,16 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel [copy.faq], ) - const scrollToGuide = (source: "install_button" | "already_manual") => { + const scrollToGuide = () => { guideRef.current?.scrollIntoView({ behavior: "smooth", block: "start" }) setManualHintVisible(true) - trackEvent("pwa", "pwa_manual_guide_view", source, { locale, platform }) } const handleInstall = async () => { if (installed) return - trackEvent("pwa", "pwa_install_click", "cta_page", { - locale, - has_prompt: Boolean(deferredPrompt), - }) if (!deferredPrompt) { - scrollToGuide("install_button") + scrollToGuide() return } @@ -153,13 +144,12 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel const choice = await deferredPrompt.userChoice if (choice.outcome === "accepted") { setInstalled(true) - trackInstallSuccess(choice.platform) + recordInstallSuccess(choice.platform) } else { - trackEvent("pwa", "pwa_install_dismissed", "cta_page", { locale, platform: choice.platform }) - scrollToGuide("already_manual") + scrollToGuide() } } catch { - scrollToGuide("already_manual") + scrollToGuide() } finally { setDeferredPrompt(null) } @@ -266,7 +256,6 @@ export function InstallAppClient({ locale, copy, allToolsLabel, trustCenterLabel variant={platform === key ? "default" : "outline"} onClick={() => { setPlatform(key) - trackEvent("pwa", "pwa_manual_guide_view", "tab_switch", { locale, platform: key }) }} > {copy.guides[key].label} diff --git a/src/features/newsletter/components/newsletter-cta.tsx b/src/features/newsletter/components/newsletter-cta.tsx index 7fd1ca85..3663a2a4 100644 --- a/src/features/newsletter/components/newsletter-cta.tsx +++ b/src/features/newsletter/components/newsletter-cta.tsx @@ -2,7 +2,6 @@ import * as React from "react" import { Sparkles, X } from "lucide-react" -import { trackCTA } from "@/core/analytics/analytics" import { requireTranslationValue } from "@/core/i18n/i18n" import { useLang } from "@/core/i18n/lang-provider" import { openExternalUrl } from "@/core/security/external-url" @@ -19,19 +18,16 @@ export function NewsletterCTA() { if (readStorageString(STORAGE_KEY)) return const timer = setTimeout(() => { setVisible(true) - trackCTA("newsletter", "view") }, 15000) return () => clearTimeout(timer) }, []) const handleDismiss = () => { - trackCTA("newsletter", "dismiss") setVisible(false) writeStorageString(STORAGE_KEY, "1") } const handleClick = () => { - trackCTA("newsletter", "click") openExternalUrl("https://github.com/baixiangcpp/byteflow.tools") setVisible(false) writeStorageString(STORAGE_KEY, "1") diff --git a/src/features/tool-shell/tool-action-bar.tsx b/src/features/tool-shell/tool-action-bar.tsx index 23455a24..443cc7cc 100644 --- a/src/features/tool-shell/tool-action-bar.tsx +++ b/src/features/tool-shell/tool-action-bar.tsx @@ -77,7 +77,7 @@ export function ToolActionBar({ handoffPayload?: string }) { const pathname = usePathname() - const { t } = useLang() + const { t, lang } = useLang() const toolKey = React.useMemo(() => { const context = getRouteContext(pathname) if (!context.slug) return null @@ -108,16 +108,16 @@ export function ToolActionBar({ const analyticsAction = classifyAnalyticsAction(action.id) if (toolKey && analyticsAction) { if (analyticsAction === "tool_run") { - trackToolRun(toolKey, action.id) + trackToolRun(toolKey, action.id, { language: lang, sourcePage: "tool_action_bar" }) } else if (analyticsAction === "copy_output") { - trackCopyOutput(toolKey, action.id) + trackCopyOutput(toolKey, action.id, lang) } else if (analyticsAction === "download_output") { - trackDownloadOutput(toolKey, action.id) + trackDownloadOutput(toolKey, action.id, { language: lang, sourcePage: "tool_action_bar" }) } } action.onClick?.() }, - [toolKey], + [lang, toolKey], ) return ( diff --git a/src/lib/sitemap-lastmod.json b/src/lib/sitemap-lastmod.json index 63949910..15e26899 100644 --- a/src/lib/sitemap-lastmod.json +++ b/src/lib/sitemap-lastmod.json @@ -202,13 +202,13 @@ "fr": "2026-06-20T00:00:00.000Z" }, "pricing": { - "en": "2026-06-14T00:00:00.000Z", - "zh-CN": "2026-06-14T00:00:00.000Z", - "zh-TW": "2026-06-14T00:00:00.000Z", - "ja": "2026-06-14T00:00:00.000Z", - "ko": "2026-06-14T00:00:00.000Z", - "de": "2026-06-14T00:00:00.000Z", - "fr": "2026-06-14T00:00:00.000Z" + "en": "2026-06-22T00:00:00.000Z", + "zh-CN": "2026-06-22T00:00:00.000Z", + "zh-TW": "2026-06-22T00:00:00.000Z", + "ja": "2026-06-22T00:00:00.000Z", + "ko": "2026-06-22T00:00:00.000Z", + "de": "2026-06-22T00:00:00.000Z", + "fr": "2026-06-22T00:00:00.000Z" }, "contact": { "en": "2026-06-14T00:00:00.000Z", diff --git a/tests/component/install-app-analytics.test.tsx b/tests/component/install-app-analytics.test.tsx index fc2532f7..bdf4d3f9 100644 --- a/tests/component/install-app-analytics.test.tsx +++ b/tests/component/install-app-analytics.test.tsx @@ -3,7 +3,7 @@ import { beforeEach, describe, expect, it, vi } from "vitest" import { InstallAppClient } from "@/features/install-app/components/install-app-client" import { getInstallPageCopy } from "@/core/utils/install-app-copy" -const trackEventMock = vi.hoisted(() => vi.fn()) +const trackPwaInstalledMock = vi.hoisted(() => vi.fn()) vi.mock("next/link", () => ({ default: ({ href, children, ...props }: { href: string; children: React.ReactNode }) => ( @@ -20,7 +20,7 @@ vi.mock("next/image", () => ({ })) vi.mock("@/core/analytics/analytics", () => ({ - trackEvent: trackEventMock, + trackPwaInstalled: trackPwaInstalledMock, })) function installMatchMedia() { @@ -41,7 +41,7 @@ function installMatchMedia() { describe("install app analytics", () => { beforeEach(() => { - trackEventMock.mockReset() + trackPwaInstalledMock.mockReset() installMatchMedia() }) @@ -75,7 +75,7 @@ describe("install app analytics", () => { }) await waitFor(() => { - const successCalls = trackEventMock.mock.calls.filter(([, action]) => action === "pwa_install_success") + const successCalls = trackPwaInstalledMock.mock.calls.filter(([language]) => language === "en") expect(successCalls).toHaveLength(1) }) }) diff --git a/tests/component/install-app-page.test.tsx b/tests/component/install-app-page.test.tsx index fe0d58a6..5b20a61d 100644 --- a/tests/component/install-app-page.test.tsx +++ b/tests/component/install-app-page.test.tsx @@ -19,7 +19,7 @@ vi.mock("next/image", () => ({ })) vi.mock("@/core/analytics/analytics", () => ({ - trackEvent: vi.fn(), + trackPwaInstalled: vi.fn(), })) describe("install app page", () => { diff --git a/tests/guards/analytics-taxonomy.test.ts b/tests/guards/analytics-taxonomy.test.ts new file mode 100644 index 00000000..3f992af4 --- /dev/null +++ b/tests/guards/analytics-taxonomy.test.ts @@ -0,0 +1,47 @@ +import { execFileSync } from "node:child_process" +import fs from "node:fs" +import path from "node:path" +import { describe, expect, it } from "vitest" +import taxonomy from "@/core/analytics/taxonomy.json" + +const PROJECT_ROOT = process.cwd() + +describe("BF-031 analytics taxonomy guard", () => { + it("passes the repository analytics taxonomy gate", () => { + const output = execFileSync(process.execPath, [path.join(PROJECT_ROOT, "scripts/gates/check-analytics-taxonomy.js")], { + cwd: PROJECT_ROOT, + encoding: "utf8", + }) + + expect(output).toContain("[check:analytics-taxonomy] OK") + }) + + it("documents a strict event and parameter allowlist", () => { + expect(Object.keys(taxonomy.events)).toEqual([ + "tool_loaded", + "tool_action", + "copy_output", + "download_output", + "search_performed", + "related_tool_click", + "pwa_installed", + ]) + expect(taxonomy.events.search_performed.allowedParams).toEqual([ + "language", + "query_length_bucket", + "results_count", + "source_page", + ]) + expect(taxonomy.forbiddenParams).toContain("search_query") + expect(taxonomy.forbiddenParams).toContain("file_content") + expect(taxonomy.forbiddenParams).toContain("jwt") + }) + + it("keeps privacy docs aligned with the taxonomy", () => { + const docs = fs.readFileSync(path.join(PROJECT_ROOT, "docs/privacy/analytics-taxonomy.md"), "utf8") + expect(docs).toContain("search_performed") + expect(docs).toContain("query_length_bucket") + expect(docs).toContain("Never send tool input") + expect(docs).toContain("search query text") + }) +}) diff --git a/tests/guards/app-layout-performance-guard.test.ts b/tests/guards/app-layout-performance-guard.test.ts index fa37fc60..6094b431 100644 --- a/tests/guards/app-layout-performance-guard.test.ts +++ b/tests/guards/app-layout-performance-guard.test.ts @@ -65,7 +65,7 @@ describe("app layout performance guard", () => { const source = fs.readFileSync(path.join(process.cwd(), "src/core/analytics/components/deferred-analytics.tsx"), "utf8") expect(source).toContain('import("./route-analytics")') - expect(source).toContain('import("./web-vitals-analytics")') + expect(source).not.toContain('import("./web-vitals-analytics")') expect(source).toContain('useDeferredMount({ delayMs: 1500, activateOnInteraction: true })') }) diff --git a/tests/guards/sensitive-storage-audit.test.ts b/tests/guards/sensitive-storage-audit.test.ts index f0c81181..32203500 100644 --- a/tests/guards/sensitive-storage-audit.test.ts +++ b/tests/guards/sensitive-storage-audit.test.ts @@ -72,13 +72,14 @@ describe("sensitive storage audit", () => { it("keeps analytics runtime hooks as no-ops and avoids forbidden payload parameter names", () => { const analytics = read("src/core/analytics/analytics.ts") expect(analytics).toContain("export const isAnalyticsEnabled = (): boolean => false") - expect(analytics).toContain("export const trackToolUsage: TrackToolUsage = noop") - expect(analytics).toContain("export const trackEvent: TrackEvent = noop") + expect(analytics).toContain("export function trackAllowlistedEvent") + expect(analytics).toContain("export function trackSearchPerformed") + expect(analytics).toContain("query_length_bucket") const callSites = sourceFilesUnder("src") .filter((file) => read(file).includes("track")) - const forbiddenParamNames = /\b(input|output|payload|token|jwt|secret|url|filename|fileContent|query|pagePath|page_path)\b/ + const forbiddenParamNames = /\b(input_text|output_text|payload|token|jwt|secret|full_url|filename|fileName|fileContent|file_content|image_content|log_body|search_query|pagePath|page_path|user_id|session_id)\b/ const offenders = callSites.flatMap((file) => { const source = read(file) const calls = source.match(/track[A-Z]\w*\([\s\S]*?\)/g) ?? [] diff --git a/tests/unit/analytics-utils.test.ts b/tests/unit/analytics-utils.test.ts index 7b4a0401..b04a73e7 100644 --- a/tests/unit/analytics-utils.test.ts +++ b/tests/unit/analytics-utils.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from "vitest" -import { detectInteractionAnalyticsAction } from "@/core/analytics/analytics" +import { buildAnalyticsPayload, detectInteractionAnalyticsAction } from "@/core/analytics/analytics" describe("analytics interaction detection", () => { it("detects copy interactions across localized labels", () => { @@ -20,4 +20,17 @@ describe("analytics interaction detection", () => { expect(detectInteractionAnalyticsAction("Run tool")).toBeNull() expect(detectInteractionAnalyticsAction("")).toBeNull() }) + + it("drops parameters that are not allowed for an event", () => { + expect(buildAnalyticsPayload("search_performed", { + language: "en", + results_count: 4, + query_length_bucket: "medium", + tool_id: "json_formatter", + })).toEqual({ + language: "en", + results_count: 4, + query_length_bucket: "medium", + }) + }) })