diff --git a/.caplets.lock.json b/.caplets.lock.json deleted file mode 100644 index d4b08032..00000000 --- a/.caplets.lock.json +++ /dev/null @@ -1,26 +0,0 @@ -{ - "version": 1, - "entries": [ - { - "id": "github", - "destination": "github", - "kind": "directory", - "source": { - "type": "local", - "path": "/tmp/caplets-cli-remote-install-explicit-project-0UFEXG/project/.caplets/repo/caplets/github", - "portability": "non_portable" - }, - "installedHash": "sha256:73c123669a3f22f683156dc2746d8b21442078be5e7463686506ae9057381cd6", - "installedAt": "2026-06-26T19:27:24.767Z", - "updatedAt": "2026-06-26T19:27:24.809Z", - "risk": { - "backendFamilies": ["mcp"], - "safety": "local_control", - "projectBindingRequired": false, - "mutating": false, - "destructive": false, - "bodyHash": "sha256:73c123669a3f22f683156dc2746d8b21442078be5e7463686506ae9057381cd6" - } - } - ] -} diff --git a/.changeset/tidy-observability-traces.md b/.changeset/tidy-observability-traces.md new file mode 100644 index 00000000..8cbedd79 --- /dev/null +++ b/.changeset/tidy-observability-traces.md @@ -0,0 +1,8 @@ +--- +"@caplets/core": patch +"caplets": patch +"@caplets/opencode": patch +"@caplets/pi": patch +--- + +Add privacy-gated anonymous telemetry, Sentry source-map uploads for runtime packages, and observability wiring for the public sites. diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c9f177c7..ebca98ce 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -47,6 +47,23 @@ jobs: - name: Run quality gates run: pnpm verify + - name: Check observability deploy env + run: pnpm telemetry:check-web-env && pnpm telemetry:check-source-maps + env: + PUBLIC_CAPLETS_POSTHOG_TOKEN: ${{ secrets.CAPLETS_POSTHOG_TOKEN }} + PUBLIC_CAPLETS_POSTHOG_HOST: ${{ secrets.CAPLETS_POSTHOG_HOST }} + PUBLIC_CAPLETS_LANDING_SENTRY_DSN: ${{ secrets.CAPLETS_LANDING_SENTRY_DSN }} + PUBLIC_CAPLETS_DOCS_SENTRY_DSN: ${{ secrets.CAPLETS_DOCS_SENTRY_DSN }} + PUBLIC_CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.CAPLETS_SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.CAPLETS_SENTRY_ORG }} + CAPLETS_LANDING_SENTRY_PROJECT: ${{ secrets.CAPLETS_LANDING_SENTRY_PROJECT }} + CAPLETS_DOCS_SENTRY_PROJECT: ${{ secrets.CAPLETS_DOCS_SENTRY_PROJECT }} + CAPLETS_CATALOG_SENTRY_PROJECT: ${{ secrets.CAPLETS_CATALOG_SENTRY_PROJECT }} + PUBLIC_CAPLETS_RELEASE: sites@${{ github.sha }} + PUBLIC_CAPLETS_ENVIRONMENT: production + - name: Deploy sites run: pnpm run alchemy:deploy env: @@ -56,3 +73,16 @@ jobs: CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }} CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} CLOUDFLARE_EMAIL: ${{ secrets.CLOUDFLARE_EMAIL }} + PUBLIC_CAPLETS_POSTHOG_TOKEN: ${{ secrets.CAPLETS_POSTHOG_TOKEN }} + PUBLIC_CAPLETS_POSTHOG_HOST: ${{ secrets.CAPLETS_POSTHOG_HOST }} + PUBLIC_CAPLETS_LANDING_SENTRY_DSN: ${{ secrets.CAPLETS_LANDING_SENTRY_DSN }} + PUBLIC_CAPLETS_DOCS_SENTRY_DSN: ${{ secrets.CAPLETS_DOCS_SENTRY_DSN }} + PUBLIC_CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.CAPLETS_SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.CAPLETS_SENTRY_ORG }} + CAPLETS_LANDING_SENTRY_PROJECT: ${{ secrets.CAPLETS_LANDING_SENTRY_PROJECT }} + CAPLETS_DOCS_SENTRY_PROJECT: ${{ secrets.CAPLETS_DOCS_SENTRY_PROJECT }} + CAPLETS_CATALOG_SENTRY_PROJECT: ${{ secrets.CAPLETS_CATALOG_SENTRY_PROJECT }} + PUBLIC_CAPLETS_RELEASE: sites@${{ github.sha }} + PUBLIC_CAPLETS_ENVIRONMENT: production diff --git a/.github/workflows/pr-preview-deploy.yml b/.github/workflows/pr-preview-deploy.yml index 266e1430..3766e097 100644 --- a/.github/workflows/pr-preview-deploy.yml +++ b/.github/workflows/pr-preview-deploy.yml @@ -53,6 +53,24 @@ jobs: if: ${{ github.event.action != 'closed' }} run: pnpm verify + - name: Check observability preview env + if: ${{ github.event.action != 'closed' }} + run: pnpm telemetry:check-web-env && pnpm telemetry:check-source-maps + env: + PUBLIC_CAPLETS_POSTHOG_TOKEN: ${{ secrets.CAPLETS_POSTHOG_TOKEN }} + PUBLIC_CAPLETS_POSTHOG_HOST: ${{ secrets.CAPLETS_POSTHOG_HOST }} + PUBLIC_CAPLETS_LANDING_SENTRY_DSN: ${{ secrets.CAPLETS_LANDING_SENTRY_DSN }} + PUBLIC_CAPLETS_DOCS_SENTRY_DSN: ${{ secrets.CAPLETS_DOCS_SENTRY_DSN }} + PUBLIC_CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.CAPLETS_SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.CAPLETS_SENTRY_ORG }} + CAPLETS_LANDING_SENTRY_PROJECT: ${{ secrets.CAPLETS_LANDING_SENTRY_PROJECT }} + CAPLETS_DOCS_SENTRY_PROJECT: ${{ secrets.CAPLETS_DOCS_SENTRY_PROJECT }} + CAPLETS_CATALOG_SENTRY_PROJECT: ${{ secrets.CAPLETS_CATALOG_SENTRY_PROJECT }} + PUBLIC_CAPLETS_RELEASE: preview-${{ github.event.pull_request.number }}@${{ github.sha }} + PUBLIC_CAPLETS_ENVIRONMENT: preview + - name: Deploy preview if: ${{ github.event.action != 'closed' }} run: pnpm run alchemy:deploy @@ -66,6 +84,19 @@ jobs: GITHUB_REPOSITORY_NAME: ${{ github.event.repository.name }} GITHUB_TOKEN: ${{ github.token }} PULL_REQUEST: ${{ github.event.pull_request.number }} + PUBLIC_CAPLETS_POSTHOG_TOKEN: ${{ secrets.CAPLETS_POSTHOG_TOKEN }} + PUBLIC_CAPLETS_POSTHOG_HOST: ${{ secrets.CAPLETS_POSTHOG_HOST }} + PUBLIC_CAPLETS_LANDING_SENTRY_DSN: ${{ secrets.CAPLETS_LANDING_SENTRY_DSN }} + PUBLIC_CAPLETS_DOCS_SENTRY_DSN: ${{ secrets.CAPLETS_DOCS_SENTRY_DSN }} + PUBLIC_CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + CAPLETS_CATALOG_SENTRY_DSN: ${{ secrets.CAPLETS_CATALOG_SENTRY_DSN }} + SENTRY_AUTH_TOKEN: ${{ secrets.CAPLETS_SENTRY_AUTH_TOKEN }} + SENTRY_ORG: ${{ secrets.CAPLETS_SENTRY_ORG }} + CAPLETS_LANDING_SENTRY_PROJECT: ${{ secrets.CAPLETS_LANDING_SENTRY_PROJECT }} + CAPLETS_DOCS_SENTRY_PROJECT: ${{ secrets.CAPLETS_DOCS_SENTRY_PROJECT }} + CAPLETS_CATALOG_SENTRY_PROJECT: ${{ secrets.CAPLETS_CATALOG_SENTRY_PROJECT }} + PUBLIC_CAPLETS_RELEASE: preview-${{ github.event.pull_request.number }}@${{ github.sha }} + PUBLIC_CAPLETS_ENVIRONMENT: preview - name: Destroy preview if: ${{ github.event.action == 'closed' }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ed636387..c6d9377d 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -61,7 +61,12 @@ jobs: title: "chore: version packages" env: CAPLETS_POSTHOG_TOKEN: ${{ secrets.CAPLETS_POSTHOG_TOKEN }} - CAPLETS_SENTRY_DSN: ${{ secrets.CAPLETS_SENTRY_DSN }} + CAPLETS_RUNTIME_SENTRY_DSN: ${{ secrets.CAPLETS_RUNTIME_SENTRY_DSN }} + CAPLETS_SENTRY_AUTH_TOKEN: ${{ secrets.CAPLETS_SENTRY_AUTH_TOKEN }} + CAPLETS_SENTRY_ORG: ${{ secrets.CAPLETS_SENTRY_ORG }} + CAPLETS_RUNTIME_SENTRY_PROJECT: ${{ secrets.CAPLETS_RUNTIME_SENTRY_PROJECT }} + CAPLETS_SENTRY_RELEASE: caplets-runtime@${{ github.sha }} + CAPLETS_SENTRY_ENVIRONMENT: production GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} - name: Check whether CLI package was published diff --git a/CONCEPTS.md b/CONCEPTS.md index 74d1daab..3c0950d7 100644 --- a/CONCEPTS.md +++ b/CONCEPTS.md @@ -152,6 +152,18 @@ Opt-out Caplets usage and reliability reporting that uses a stable anonymous ins Anonymous Telemetry is split by purpose: PostHog receives product usage events, while Sentry receives sanitized reliability events. It must not collect raw config, prompts, Code Mode code, tool arguments, tool outputs, paths, URLs, hostnames, Caplet IDs, credentials, or unsanitized error payloads. +### Telemetry Observability Loop + +The combined PostHog and Sentry feedback loop that connects public-site intent, runtime usage, provider delivery health, and debuggable release errors into maintainable product readouts. + +Telemetry Observability Loop keeps PostHog as the usage and conversion system, keeps Sentry as the reliability system, and preserves Anonymous Telemetry boundaries by separating public catalog indexing and avoiding known-user attribution. + +### Anonymous Install Attribution + +A short nonsecret categorical marker generated from public-site install intent and optionally reported by the CLI on first activation. + +Anonymous Install Attribution connects landing, docs, and catalog intent to activation readouts without carrying browser visitor identities, account identities, raw source URLs, or hidden user identifiers into runtime telemetry. + ## Remote Attach ### Remote Attach diff --git a/alchemy.run.ts b/alchemy.run.ts index cebb192a..417418c4 100644 --- a/alchemy.run.ts +++ b/alchemy.run.ts @@ -48,6 +48,15 @@ export const catalogPage = await Astro("catalog-page", { }, bindings: { CATALOG_DB: catalogDatabase, + ...(process.env.CAPLETS_CATALOG_SENTRY_DSN + ? { CAPLETS_CATALOG_SENTRY_DSN: process.env.CAPLETS_CATALOG_SENTRY_DSN } + : {}), + ...(process.env.PUBLIC_CAPLETS_ENVIRONMENT + ? { PUBLIC_CAPLETS_ENVIRONMENT: process.env.PUBLIC_CAPLETS_ENVIRONMENT } + : {}), + ...(process.env.PUBLIC_CAPLETS_RELEASE + ? { PUBLIC_CAPLETS_RELEASE: process.env.PUBLIC_CAPLETS_RELEASE } + : {}), }, domains: [catalogPageDomain], }); diff --git a/apps/catalog/astro.config.mjs b/apps/catalog/astro.config.mjs index b8a170b5..cf77f8c9 100644 --- a/apps/catalog/astro.config.mjs +++ b/apps/catalog/astro.config.mjs @@ -1,5 +1,6 @@ // @ts-check import cloudflare from "@astrojs/cloudflare"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "astro/config"; import { fileURLToPath } from "node:url"; @@ -11,6 +12,11 @@ const cloudflareDevWorkers = fileURLToPath( new URL("./src/cloudflare-workers-dev.ts", import.meta.url), ); const isProductionBuild = process.env.NODE_ENV === "production"; +const sentryProject = process.env.CAPLETS_CATALOG_SENTRY_PROJECT; +const sentryRelease = process.env.PUBLIC_CAPLETS_RELEASE; +const sentryConfigured = Boolean( + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && sentryProject && sentryRelease, +); const optimizeExclude = [ "tailwind-variants", "unified", @@ -30,8 +36,24 @@ export default defineConfig({ vite: { build: { assetsInlineLimit: 0, + sourcemap: sentryConfigured ? "hidden" : false, }, - plugins: [tailwindcss()], + plugins: [ + tailwindcss(), + ...(sentryConfigured + ? [ + sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: sentryProject, + release: { name: sentryRelease }, + sourcemaps: { + filesToDeleteAfterUpload: ["./dist/**/*.map"], + }, + }), + ] + : []), + ], resolve: { alias: { "@astrojs/cloudflare/entrypoints/middleware.js": cloudflareDevMiddleware, diff --git a/apps/catalog/package.json b/apps/catalog/package.json index 2dc85314..a9c3cda9 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -13,14 +13,17 @@ }, "dependencies": { "@astrojs/check": "^0.9.9", - "@astrojs/cloudflare": "^13.7.0", + "@astrojs/cloudflare": "^14.0.1", "@caplets/core": "workspace:*", + "@caplets/web-observability": "workspace:*", "@hugeicons/core-free-icons": "^4.2.2", + "@sentry/browser": "^10.62.0", "@tabler/icons": "^3.44.0", "@tailwindcss/forms": "^0.5.11", "@tailwindcss/vite": "^4.3.1", "@tanstack/virtual-core": "^3.17.2", - "astro": "^6.4.6", + "astro": "^7.0.3", + "posthog-js": "^1.395.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", @@ -33,8 +36,8 @@ }, "devDependencies": { "happy-dom": "^20.10.6", - "vite": "^7.3.5", - "vitest": "^4.1.8", + "vite": "^8.1.0", + "vitest": "^4.1.9", "wrangler": "^4.105.0" }, "packageManager": "pnpm@11.7.0" diff --git a/apps/catalog/src/components/CapletDetail.astro b/apps/catalog/src/components/CapletDetail.astro index bc2435d1..9f5bbe68 100644 --- a/apps/catalog/src/components/CapletDetail.astro +++ b/apps/catalog/src/components/CapletDetail.astro @@ -1,5 +1,5 @@ --- -import { ChevronRightIcon } from "@hugeicons/core-free-icons"; +import { ChevronRightIcon } from "../lib/hugeicons"; import type { CatalogEntryRecord } from "../lib/catalog-store"; import { renderCatalogMarkdown, splitCatalogMarkdown } from "../lib/markdown"; import HugeIcon from "./HugeIcon.astro"; diff --git a/apps/catalog/src/components/CatalogHeader.astro b/apps/catalog/src/components/CatalogHeader.astro index 47522eac..2f932817 100644 --- a/apps/catalog/src/components/CatalogHeader.astro +++ b/apps/catalog/src/components/CatalogHeader.astro @@ -1,5 +1,5 @@ --- -import { Cancel01Icon } from "@hugeicons/core-free-icons"; +import { Cancel01Icon } from "../lib/hugeicons"; import HugeIcon from "./HugeIcon.astro"; import ThemeToggle from "./ThemeToggle.astro"; diff --git a/apps/catalog/src/components/InstallCommand.astro b/apps/catalog/src/components/InstallCommand.astro index 7562ca40..57505767 100644 --- a/apps/catalog/src/components/InstallCommand.astro +++ b/apps/catalog/src/components/InstallCommand.astro @@ -1,6 +1,6 @@ --- import type { CatalogInstallCommand } from "@caplets/core/catalog"; -import { Copy01Icon } from "@hugeicons/core-free-icons"; +import { Copy01Icon } from "../lib/hugeicons"; import HugeIcon from "./HugeIcon.astro"; import { Button } from "./starwind/button"; diff --git a/apps/catalog/src/components/SafetyNotice.astro b/apps/catalog/src/components/SafetyNotice.astro index 1e3bfa5b..9681df68 100644 --- a/apps/catalog/src/components/SafetyNotice.astro +++ b/apps/catalog/src/components/SafetyNotice.astro @@ -1,6 +1,6 @@ --- import type { CatalogWarning } from "@caplets/core/catalog"; -import { AlertCircleIcon } from "@hugeicons/core-free-icons"; +import { AlertCircleIcon } from "../lib/hugeicons"; import HugeIcon from "./HugeIcon.astro"; type Props = { diff --git a/apps/catalog/src/components/SearchShell.astro b/apps/catalog/src/components/SearchShell.astro index 6fd5b806..3447163a 100644 --- a/apps/catalog/src/components/SearchShell.astro +++ b/apps/catalog/src/components/SearchShell.astro @@ -12,7 +12,7 @@ import { Key01Icon, Link01Icon, Settings02Icon, -} from "@hugeicons/core-free-icons"; +} from "../lib/hugeicons"; type Props = { entries: CatalogEntryRecord[]; diff --git a/apps/catalog/src/components/ThemeToggle.astro b/apps/catalog/src/components/ThemeToggle.astro index 7e552903..85e1f8af 100644 --- a/apps/catalog/src/components/ThemeToggle.astro +++ b/apps/catalog/src/components/ThemeToggle.astro @@ -5,7 +5,7 @@ import { Moon02Icon, Sun01Icon, Tick02Icon, -} from "@hugeicons/core-free-icons"; +} from "../lib/hugeicons"; import HugeIcon from "./HugeIcon.astro"; const options = [ diff --git a/apps/catalog/src/lib/catalog-env.ts b/apps/catalog/src/lib/catalog-env.ts index 82cb22d1..54768ad2 100644 --- a/apps/catalog/src/lib/catalog-env.ts +++ b/apps/catalog/src/lib/catalog-env.ts @@ -4,6 +4,9 @@ import { env } from "cloudflare:workers"; export type CatalogEnv = { CATALOG_DB?: D1Database; + CAPLETS_CATALOG_SENTRY_DSN?: string; + PUBLIC_CAPLETS_ENVIRONMENT?: string; + PUBLIC_CAPLETS_RELEASE?: string; }; export function getCatalogEnv(): CatalogEnv { diff --git a/apps/catalog/src/lib/hugeicons.ts b/apps/catalog/src/lib/hugeicons.ts new file mode 100644 index 00000000..5782a52a --- /dev/null +++ b/apps/catalog/src/lib/hugeicons.ts @@ -0,0 +1,35 @@ +import AlertCircleIcon from "@hugeicons/core-free-icons/AlertCircleIcon"; +import ArrowDown01Icon from "@hugeicons/core-free-icons/ArrowDown01Icon"; +import BadgeCheckIcon from "@hugeicons/core-free-icons/BadgeCheckIcon"; +import Cancel01Icon from "@hugeicons/core-free-icons/Cancel01Icon"; +import ChevronRightIcon from "@hugeicons/core-free-icons/ChevronRightIcon"; +import ComputerIcon from "@hugeicons/core-free-icons/ComputerIcon"; +import ComputerUserIcon from "@hugeicons/core-free-icons/ComputerUserIcon"; +import Copy01Icon from "@hugeicons/core-free-icons/Copy01Icon"; +import DatabaseSyncIcon from "@hugeicons/core-free-icons/DatabaseSyncIcon"; +import Key01Icon from "@hugeicons/core-free-icons/Key01Icon"; +import Link01Icon from "@hugeicons/core-free-icons/Link01Icon"; +import Moon02Icon from "@hugeicons/core-free-icons/Moon02Icon"; +import Settings02Icon from "@hugeicons/core-free-icons/Settings02Icon"; +import Shield01Icon from "@hugeicons/core-free-icons/Shield01Icon"; +import Sun01Icon from "@hugeicons/core-free-icons/Sun01Icon"; +import Tick02Icon from "@hugeicons/core-free-icons/Tick02Icon"; + +export { + AlertCircleIcon, + ArrowDown01Icon, + BadgeCheckIcon, + Cancel01Icon, + ChevronRightIcon, + ComputerIcon, + ComputerUserIcon, + Copy01Icon, + DatabaseSyncIcon, + Key01Icon, + Link01Icon, + Moon02Icon, + Settings02Icon, + Shield01Icon, + Sun01Icon, + Tick02Icon, +}; diff --git a/apps/catalog/src/lib/server-observability.ts b/apps/catalog/src/lib/server-observability.ts new file mode 100644 index 00000000..2346fb59 --- /dev/null +++ b/apps/catalog/src/lib/server-observability.ts @@ -0,0 +1,62 @@ +import type { CatalogEnv } from "./catalog-env"; + +export async function captureCatalogServerError(error: unknown, env: CatalogEnv): Promise { + if (!env.CAPLETS_CATALOG_SENTRY_DSN) return; + try { + await fetch(sentryEnvelopeUrl(env.CAPLETS_CATALOG_SENTRY_DSN), { + method: "POST", + headers: { "content-type": "application/x-sentry-envelope" }, + body: sentryEnvelopeBody(error, env), + }); + } catch { + // Server observability is best effort and must not affect indexing responses. + } +} + +function sentryEnvelopeUrl(dsn: string): string { + const url = new URL(dsn); + const pathSegments = url.pathname.split("/").filter(Boolean); + const projectId = pathSegments.at(-1); + if (!projectId) throw new Error("invalid catalog worker Sentry DSN"); + const pathPrefix = pathSegments.slice(0, -1).join("/"); + const envelopePath = `${pathPrefix ? `/${pathPrefix}` : ""}/api/${projectId}/envelope/`; + return `${url.protocol}//${url.host}${envelopePath}`; +} + +function sentryEnvelopeBody(error: unknown, env: CatalogEnv): string { + const sentAt = new Date().toISOString(); + const eventId = crypto.randomUUID().replaceAll("-", ""); + const envelopeHeader = { + event_id: eventId, + dsn: env.CAPLETS_CATALOG_SENTRY_DSN, + sent_at: sentAt, + }; + const event = { + event_id: eventId, + timestamp: sentAt, + platform: "javascript", + level: "error", + release: env.PUBLIC_CAPLETS_RELEASE, + environment: env.PUBLIC_CAPLETS_ENVIRONMENT, + tags: { + surface: "catalog", + route_family: "catalog", + page_family: "catalog", + }, + exception: exceptionFor(error), + }; + return `${JSON.stringify(envelopeHeader)}\n${JSON.stringify({ type: "event" })}\n${JSON.stringify(event)}\n`; +} + +function exceptionFor(error: unknown): { values: Array<{ type: string }> } { + return { + values: [ + { + type: + error instanceof Error && /^[A-Za-z][A-Za-z0-9_.-]{0,79}$/u.test(error.name) + ? error.name + : "Error", + }, + ], + }; +} diff --git a/apps/catalog/src/lib/status-icons.ts b/apps/catalog/src/lib/status-icons.ts index 99790ab6..37345bbf 100644 --- a/apps/catalog/src/lib/status-icons.ts +++ b/apps/catalog/src/lib/status-icons.ts @@ -7,7 +7,7 @@ import { Link01Icon, Settings02Icon, Shield01Icon, -} from "@hugeicons/core-free-icons"; +} from "./hugeicons"; import type { CatalogSearchStatusCode } from "./search-row"; type IconNode = readonly [string, Readonly>]; @@ -29,4 +29,4 @@ export const catalogTrustIcons: Record = { community: Shield01Icon, }; -export { AlertCircleIcon, Copy01Icon } from "@hugeicons/core-free-icons"; +export { AlertCircleIcon, Copy01Icon } from "./hugeicons"; diff --git a/apps/catalog/src/pages/api/v1/catalog/install-signals.ts b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts index 18d0fd09..e06f818e 100644 --- a/apps/catalog/src/pages/api/v1/catalog/install-signals.ts +++ b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts @@ -2,6 +2,7 @@ import type { APIContext } from "astro"; import { getCatalogEnv } from "../../../../lib/catalog-env"; import { acceptInstallSignal, parseInstallSignalRequest } from "../../../../lib/ingest"; import { jsonResponse } from "../../../../lib/catalog-response"; +import { captureCatalogServerError } from "../../../../lib/server-observability"; export async function POST(context: APIContext): Promise { let signal: Awaited>; @@ -14,10 +15,11 @@ export async function POST(context: APIContext): Promise { ); } + const env = getCatalogEnv(); try { const result = await acceptInstallSignal({ signal, - db: getCatalogEnv().CATALOG_DB, + db: env.CATALOG_DB, }); if (result.status === "unavailable") { return jsonResponse( @@ -39,7 +41,8 @@ export async function POST(context: APIContext): Promise { headers: { "cache-control": "no-store" }, }, ); - } catch { + } catch (error) { + scheduleCatalogServerError(context, error, env); return jsonResponse( { ok: false, @@ -49,3 +52,18 @@ export async function POST(context: APIContext): Promise { ); } } + +function scheduleCatalogServerError( + context: APIContext, + error: unknown, + env: ReturnType, +): void { + const pending = captureCatalogServerError(error, env).catch(() => undefined); + const waitUntil = (context.locals as { runtime?: { ctx?: { waitUntil?: unknown } } }).runtime?.ctx + ?.waitUntil; + if (typeof waitUntil === "function") { + waitUntil(pending); + return; + } + void pending; +} diff --git a/apps/catalog/src/pages/caplets/[entryKey].astro b/apps/catalog/src/pages/caplets/[entryKey].astro index 1448930a..acc0b804 100644 --- a/apps/catalog/src/pages/caplets/[entryKey].astro +++ b/apps/catalog/src/pages/caplets/[entryKey].astro @@ -50,6 +50,7 @@ if (!entry) {
diff --git a/apps/catalog/src/pages/index.astro b/apps/catalog/src/pages/index.astro index 7b28bf3c..4a4b6c4e 100644 --- a/apps/catalog/src/pages/index.astro +++ b/apps/catalog/src/pages/index.astro @@ -39,6 +39,7 @@ const entries = await listCatalogEntries(getCatalogEnv());
diff --git a/apps/catalog/src/scripts/copy.ts b/apps/catalog/src/scripts/copy.ts index 0d9acf9b..0082f670 100644 --- a/apps/catalog/src/scripts/copy.ts +++ b/apps/catalog/src/scripts/copy.ts @@ -1,3 +1,5 @@ +import { attributedCatalogCommand, captureCatalogInstallCopy } from "./observability"; + const copyStatus = document.querySelector("[data-copy-status]") as HTMLElement | null; let clearCopyStatusTimer = 0; @@ -16,7 +18,8 @@ function announceCopyStatus(message: string): void { async function copyCommand(button: HTMLButtonElement): Promise { const command = button.dataset.copyCommand ?? ""; try { - await navigator.clipboard.writeText(command); + await navigator.clipboard.writeText(attributedCatalogCommand(command)); + captureCatalogInstallCopy(); announceCopyStatus("Install command copied."); } catch { announceCopyStatus("Copy failed. Select the command text manually."); diff --git a/apps/catalog/src/scripts/observability.ts b/apps/catalog/src/scripts/observability.ts new file mode 100644 index 00000000..fcfe5e62 --- /dev/null +++ b/apps/catalog/src/scripts/observability.ts @@ -0,0 +1,140 @@ +import * as Sentry from "@sentry/browser"; +import { + attributedInstallCommand, + buildWebEvent, + bucketResultCount, + bucketSearchTerm, + classifyRouteFamily, + filterSentryBrowserEvent, + type WebEventName, + type WebEventPropertySet, + type WebEventProperties, +} from "@caplets/web-observability"; +import posthog from "posthog-js"; + +const surface = "catalog"; +const posthogToken = import.meta.env.PUBLIC_CAPLETS_POSTHOG_TOKEN; +const posthogHost = import.meta.env.PUBLIC_CAPLETS_POSTHOG_HOST; +const sentryDsn = import.meta.env.PUBLIC_CAPLETS_CATALOG_SENTRY_DSN; +const release = import.meta.env.PUBLIC_CAPLETS_RELEASE; +const environment = import.meta.env.PUBLIC_CAPLETS_ENVIRONMENT ?? import.meta.env.MODE; +let posthogEnabled = false; + +if (posthogToken) { + posthog.init(posthogToken, { + api_host: posthogHost || "https://us.i.posthog.com", + autocapture: false, + capture_pageview: false, + disable_session_recording: true, + disable_surveys: true, + disable_web_experiments: true, + disable_persistence: true, + persistence: "memory", + }); + posthogEnabled = true; +} + +if (sentryDsn) { + Sentry.init({ + dsn: sentryDsn, + release, + environment, + sendDefaultPii: false, + tracesSampleRate: 0, + beforeSend(event) { + return filterSentryBrowserEvent( + event as unknown as Record, + ) as unknown as typeof event; + }, + }); +} + +const routeFamily = classifyRouteFamily(window.location.pathname); +captureCatalogEvent("caplets_site_pageview", { + route_family: routeFamily, + page_family: routeFamily, + referrer_category: referrerCategory(document.referrer), +}); + +export function attributedCatalogCommand(command: string): string { + return attributedInstallCommand(command, surface); +} + +export function captureCatalogInstallCopy(): void { + captureCatalogEvent("caplets_install_intent", { + route_family: routeFamily, + page_family: routeFamily, + section_category: "install", + install_intent_category: "copy", + result_interaction_category: "copy_install", + }); +} + +export function captureCatalogResultOpen(): void { + captureCatalogEvent("caplets_site_intent", { + route_family: routeFamily, + page_family: routeFamily, + section_category: "catalog", + result_interaction_category: "open_detail", + }); +} + +export function captureCatalogSearch(input: { + query: string; + resultCount: number; + filterChanged?: "trust" | "setup" | "tag" | "reset" | undefined; +}): void { + captureCatalogEvent("caplets_catalog_search", { + route_family: "catalog", + page_family: "catalog", + section_category: "search", + search_length_bucket: bucketSearchTerm(input.query), + result_count_bucket: bucketResultCount(input.resultCount), + filter_category: filterCategory(input.filterChanged), + empty_state_category: + input.resultCount > 0 ? "unknown" : input.query.trim() ? "no_results" : "no_query", + }); +} + +function captureCatalogEvent(name: WebEventName, properties: WebEventPropertySet): void { + if (!posthogEnabled) return; + try { + const event = buildWebEvent({ + name, + properties: { surface, ...properties } as WebEventProperties, + }); + posthog.capture(event.name, { + ...event.properties, + $process_person_profile: false, + $geoip_disable: true, + }); + } catch { + // Analytics must never affect catalog behavior. + } +} + +function referrerCategory(referrer: string): WebEventPropertySet["referrer_category"] { + if (!referrer) return "direct"; + try { + const host = new URL(referrer).hostname; + if (host.includes("google") || host.includes("bing") || host.includes("duckduckgo")) + return "search"; + if (host.includes("github") || host.includes("x.com") || host.includes("twitter")) + return "social"; + if (host.includes("docs.caplets")) return "docs"; + if (host.includes("catalog.caplets")) return "catalog"; + } catch { + return "external"; + } + return "external"; +} + +function filterCategory( + changed: "trust" | "setup" | "tag" | "reset" | undefined, +): NonNullable { + if (changed === "reset") return "clear"; + if (changed === "tag") return "tag"; + if (changed === "trust") return "source"; + if (changed === "setup") return "auth"; + return "unknown"; +} diff --git a/apps/catalog/src/scripts/virtual-results.ts b/apps/catalog/src/scripts/virtual-results.ts index 0ead52c9..57ddc993 100644 --- a/apps/catalog/src/scripts/virtual-results.ts +++ b/apps/catalog/src/scripts/virtual-results.ts @@ -7,6 +7,7 @@ import { } from "@tanstack/virtual-core"; import { filterCatalogSearchRecords, type CatalogSearchFilters } from "../lib/search-filter"; import type { CatalogSearchRow } from "../lib/search-row"; +import { captureCatalogResultOpen, captureCatalogSearch } from "./observability"; import { AlertCircleIcon, catalogStatusIcons, @@ -56,6 +57,8 @@ export function initVirtualCatalogSearch( const rows = parseRows(index.textContent ?? "[]"); let visibleRows = [...rows]; let lastFocusedControl: HTMLElement | null = null; + let searchTelemetryTimer: number | undefined; + let lastSearchTelemetrySignature = ""; const renderedRows = new Map(); const virtualizer = new Virtualizer({ count: visibleRows.length, @@ -106,7 +109,14 @@ export function initVirtualCatalogSearch( window.history.replaceState(null, "", next); } - function applySearch(options: { writeUrl?: boolean; resetScroll?: boolean } = {}): void { + function applySearch( + options: { + writeUrl?: boolean; + resetScroll?: boolean; + filterChanged?: "trust" | "setup" | "tag" | "reset" | undefined; + trackSearch?: "immediate" | "debounced" | false | undefined; + } = {}, + ): void { lastFocusedControl = document.activeElement instanceof HTMLElement ? document.activeElement : null; visibleRows = filterCatalogSearchRecords(rows, filters()) as CatalogSearchRow[]; @@ -124,6 +134,37 @@ export function initVirtualCatalogSearch( ) { lastFocusedControl.focus(); } + emitSearchTelemetry(options); + } + + function emitSearchTelemetry(options: { + filterChanged?: "trust" | "setup" | "tag" | "reset" | undefined; + trackSearch?: "immediate" | "debounced" | false | undefined; + }): void { + if (options.trackSearch === false) return; + if (options.trackSearch === "debounced") { + if (searchTelemetryTimer) window.clearTimeout(searchTelemetryTimer); + searchTelemetryTimer = window.setTimeout(() => { + searchTelemetryTimer = undefined; + captureSearchTelemetry(options.filterChanged); + }, 450); + return; + } + captureSearchTelemetry(options.filterChanged); + } + + function captureSearchTelemetry( + filterChanged: "trust" | "setup" | "tag" | "reset" | undefined, + ): void { + const payload = { + query: inputEl.value, + resultCount: visibleRows.length, + filterChanged, + }; + const signature = JSON.stringify(payload); + if (signature === lastSearchTelemetrySignature) return; + lastSearchTelemetrySignature = signature; + captureCatalogSearch(payload); } function renderVirtualRows(): void { @@ -176,20 +217,33 @@ export function initVirtualCatalogSearch( if (!target || target.closest("[data-row-action]") || target.closest("a")) return; const row = target.closest("[data-result-row]"); const href = row?.dataset.detailHref; - if (href) window.location.href = href; + if (href) { + captureCatalogResultOpen(); + window.location.href = href; + } } const events = new AbortController(); - for (const control of controls()) { - control?.addEventListener("input", () => applySearch(), { signal: events.signal }); - control?.addEventListener("change", () => applySearch(), { signal: events.signal }); + inputEl.addEventListener("input", () => applySearch({ trackSearch: "debounced" }), { + signal: events.signal, + }); + for (const control of [trust, setup, sort]) { + control?.addEventListener("change", () => applySearch({ filterChanged: filterName(control) }), { + signal: events.signal, + }); } + tag?.addEventListener("input", () => applySearch({ filterChanged: "tag" }), { + signal: events.signal, + }); + tag?.addEventListener("change", () => applySearch({ filterChanged: "tag" }), { + signal: events.signal, + }); tagSelect?.addEventListener( "starwind-select:change", (event) => { const customEvent = event as CustomEvent<{ value?: string }>; setTagValue(customEvent.detail.value ?? "all", { syncSelect: false }); - applySearch(); + applySearch({ filterChanged: "tag" }); }, { signal: events.signal }, ); @@ -203,7 +257,7 @@ export function initVirtualCatalogSearch( } setTagValue("all"); if (sort) sort.value = "rank"; - applySearch(); + applySearch({ filterChanged: "reset" }); inputEl.focus(); }, { signal: events.signal }, @@ -213,7 +267,7 @@ export function initVirtualCatalogSearch( "popstate", () => { applyUrlState(); - applySearch({ writeUrl: false }); + applySearch({ writeUrl: false, trackSearch: false }); }, { signal: events.signal }, ); @@ -229,11 +283,12 @@ export function initVirtualCatalogSearch( resultListEl.addEventListener("click", navigateFromRowClick, { signal: events.signal }); applyUrlState(); - applySearch({ writeUrl: false, resetScroll: false }); + applySearch({ writeUrl: false, resetScroll: false, trackSearch: false }); return { applySearch, destroy() { + if (searchTelemetryTimer) window.clearTimeout(searchTelemetryTimer); events.abort(); cleanupVirtualizer(); }, @@ -243,6 +298,13 @@ export function initVirtualCatalogSearch( }; } +function filterName( + control: HTMLInputElement | HTMLSelectElement, +): "trust" | "setup" | "tag" | undefined { + const name = control.dataset.filter; + return name === "trust" || name === "setup" || name === "tag" ? name : undefined; +} + function setTagValue(value: string, options: { syncSelect?: boolean } = {}): void { const syncSelect = options.syncSelect ?? true; const normalizedValue = value.trim() || "all"; diff --git a/apps/catalog/src/types/hugeicons-core-free-icons.d.ts b/apps/catalog/src/types/hugeicons-core-free-icons.d.ts new file mode 100644 index 00000000..fb5216c0 --- /dev/null +++ b/apps/catalog/src/types/hugeicons-core-free-icons.d.ts @@ -0,0 +1,5 @@ +declare module "@hugeicons/core-free-icons/*" { + type IconNode = readonly [string, Readonly>]; + const icon: readonly IconNode[]; + export default icon; +} diff --git a/apps/catalog/test/ingest.test.ts b/apps/catalog/test/ingest.test.ts index a064a9f1..af74352f 100644 --- a/apps/catalog/test/ingest.test.ts +++ b/apps/catalog/test/ingest.test.ts @@ -1,11 +1,18 @@ import type { D1Database } from "@cloudflare/workers-types"; import type { CatalogEntry } from "@caplets/core/catalog"; import type { APIContext } from "astro"; -import { describe, expect, it } from "vitest"; +import { afterEach, describe, expect, it, vi } from "vitest"; +// @ts-expect-error @astrojs/cloudflare provides this virtual module at runtime. +import { env as workerEnv } from "cloudflare:workers"; import { acceptInstallSignal, parseInstallSignalRequest } from "../src/lib/ingest"; import { POST as postInstallSignal } from "../src/pages/api/v1/catalog/install-signals"; describe("catalog install signal ingestion", () => { + afterEach(() => { + for (const key of Object.keys(workerEnv)) delete (workerEnv as Record)[key]; + vi.unstubAllGlobals(); + }); + it("accepts revision-bound public GitHub signals without echoing raw private values", async () => { const db = fakeD1(); await expect( @@ -334,6 +341,47 @@ describe("catalog install signal ingestion", () => { expect(response.status).toBe(202); expect(response.headers.get("cache-control")).toBe("no-store"); }); + + it("returns internal errors without awaiting best-effort Sentry capture", async () => { + Object.assign(workerEnv, { + CATALOG_DB: { + prepare() { + throw new Error("database unavailable"); + }, + }, + CAPLETS_CATALOG_SENTRY_DSN: "https://public@example.ingest.sentry.io/123", + PUBLIC_CAPLETS_ENVIRONMENT: "production", + PUBLIC_CAPLETS_RELEASE: "sites@test", + }); + vi.stubGlobal( + "fetch", + vi.fn(() => new Promise(() => undefined)), + ); + + const response = await Promise.race([ + postInstallSignal({ + request: new Request("https://catalog.caplets.dev/api/v1/catalog/install-signals", { + method: "POST", + body: JSON.stringify({ + source: "community/tools", + capletId: "deploy", + sourcePath: "caplets/deploy/CAPLET.md", + resolvedRevision: "abc123", + }), + }), + locals: {}, + } as APIContext), + new Promise<"timeout">((resolve) => setTimeout(() => resolve("timeout"), 25)), + ]); + + expect(response).toBeInstanceOf(Response); + if (!(response instanceof Response)) return; + expect(response.status).toBe(500); + await expect(response.json()).resolves.toMatchObject({ + ok: false, + error: { code: "internal_error" }, + }); + }); }); function fakeD1( diff --git a/apps/catalog/test/observability.test.ts b/apps/catalog/test/observability.test.ts new file mode 100644 index 00000000..75d55369 --- /dev/null +++ b/apps/catalog/test/observability.test.ts @@ -0,0 +1,59 @@ +// @vitest-environment happy-dom + +import { beforeEach, describe, expect, it, vi } from "vitest"; + +describe("catalog observability", () => { + beforeEach(() => { + vi.resetModules(); + vi.restoreAllMocks(); + document.body.innerHTML = ""; + Object.defineProperty(navigator, "clipboard", { + configurable: true, + value: { writeText: vi.fn().mockResolvedValue(undefined) }, + }); + }); + + it("adds categorical attribution to copied catalog install commands", async () => { + document.body.innerHTML = ` + +
+ `; + + await import("../src/scripts/copy"); + document.querySelector("button")?.dispatchEvent(new MouseEvent("click", { bubbles: true })); + await Promise.resolve(); + + expect(navigator.clipboard.writeText).toHaveBeenCalledWith( + "caplets telemetry attribution catalog_install\ncaplets add npm", + ); + }); + + it("loads browser observability without provider env", async () => { + await expect(import("../src/scripts/observability")).resolves.toBeDefined(); + }); + + it("sends catalog worker errors as sanitized Sentry envelopes", async () => { + const fetch = vi.fn().mockResolvedValue(new Response(null, { status: 200 })); + vi.stubGlobal("fetch", fetch); + vi.stubGlobal("crypto", { randomUUID: () => "00000000-0000-4000-8000-000000000001" }); + const { captureCatalogServerError } = await import("../src/lib/server-observability"); + + await captureCatalogServerError(new Error("raw /home/alex/secret"), { + CAPLETS_CATALOG_SENTRY_DSN: "https://public@example.ingest.sentry.io/sentry/123", + PUBLIC_CAPLETS_ENVIRONMENT: "production", + PUBLIC_CAPLETS_RELEASE: "sites@test", + }); + + expect(fetch).toHaveBeenCalledWith( + "https://example.ingest.sentry.io/sentry/api/123/envelope/", + expect.objectContaining({ + method: "POST", + headers: { "content-type": "application/x-sentry-envelope" }, + }), + ); + const body = String(fetch.mock.calls[0]?.[1]?.body); + expect(body).toContain('"surface":"catalog"'); + expect(body).toContain('"release":"sites@test"'); + expect(body).not.toContain("raw /home/alex/secret"); + }); +}); diff --git a/apps/catalog/test/virtual-results.test.ts b/apps/catalog/test/virtual-results.test.ts index ae56b452..f4aaaca1 100644 --- a/apps/catalog/test/virtual-results.test.ts +++ b/apps/catalog/test/virtual-results.test.ts @@ -1,9 +1,28 @@ // @vitest-environment happy-dom -import { beforeEach, describe, expect, it, vi } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { catalogSearchRowFixture, manyCatalogSearchRows } from "./fixtures/catalog-search-rows"; import type { CatalogSearchRow } from "../src/lib/search-row"; +const captureCatalogSearch = vi.hoisted(() => vi.fn()); +const captureCatalogResultOpen = vi.hoisted(() => vi.fn()); +const captureCatalogInstallCopy = vi.hoisted(() => vi.fn()); + +vi.mock("../src/scripts/observability", () => ({ + attributedCatalogCommand: (command: string) => + `caplets telemetry attribution catalog_install\n${command}`, + captureCatalogInstallCopy, + captureCatalogResultOpen, + captureCatalogSearch, +})); + +const activeSearches: Array<{ destroy(): void }> = []; + +function trackSearch(search: T): T { + if (search) activeSearches.push(search); + return search; +} + describe("virtual catalog results", () => { beforeEach(() => { vi.resetModules(); @@ -36,13 +55,28 @@ describe("virtual catalog results", () => { writeText: vi.fn().mockResolvedValue(undefined), }, }); + captureCatalogSearch.mockClear(); + captureCatalogResultOpen.mockClear(); + captureCatalogInstallCopy.mockClear(); + vi.useRealTimers(); + }); + + afterEach(() => { + for (const search of activeSearches.splice(0)) { + try { + search.destroy(); + } catch { + // Tests may explicitly destroy a search instance before cleanup. + } + } + vi.useRealTimers(); }); it("renders a bounded row window for large result sets", async () => { mountSearchShell(manyCatalogSearchRows(10_000)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - const search = initVirtualCatalogSearch(); + const search = trackSearch(initVirtualCatalogSearch()); expect(search).toBeDefined(); expect(document.querySelector("[data-result-status]")?.textContent).toBe("10000 Caplets"); @@ -55,7 +89,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(200)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); input().value = "Caplet 199"; input().dispatchEvent(new Event("input", { bubbles: true })); @@ -65,6 +99,51 @@ describe("virtual catalog results", () => { ]); }); + it("debounces search telemetry for typed queries", async () => { + vi.useFakeTimers(); + mountSearchShell(manyCatalogSearchRows(200)); + + const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); + trackSearch(initVirtualCatalogSearch()); + captureCatalogSearch.mockClear(); + + input().value = "c"; + input().dispatchEvent(new Event("input", { bubbles: true })); + input().value = "ca"; + input().dispatchEvent(new Event("input", { bubbles: true })); + input().value = "caplet 19"; + input().dispatchEvent(new Event("input", { bubbles: true })); + + expect(captureCatalogSearch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(449); + expect(captureCatalogSearch).not.toHaveBeenCalled(); + await vi.advanceTimersByTimeAsync(1); + + expect(captureCatalogSearch).toHaveBeenCalledTimes(1); + expect(captureCatalogSearch).toHaveBeenCalledWith({ + query: "caplet 19", + resultCount: 11, + filterChanged: undefined, + }); + }); + + it("emits one search telemetry event for paired filter input and change events", async () => { + mountSearchShell(manyCatalogSearchRows(20)); + + const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); + trackSearch(initVirtualCatalogSearch()); + captureCatalogSearch.mockClear(); + + select("trust").value = "official"; + select("trust").dispatchEvent(new Event("input", { bubbles: true })); + select("trust").dispatchEvent(new Event("change", { bubbles: true })); + + expect(captureCatalogSearch).toHaveBeenCalledTimes(1); + expect(captureCatalogSearch).toHaveBeenCalledWith( + expect.objectContaining({ filterChanged: "trust" }), + ); + }); + it("renders catalog icons with remote image privacy controls", async () => { mountSearchShell([ catalogSearchRowFixture({ @@ -75,7 +154,7 @@ describe("virtual catalog results", () => { ]); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); const icon = document.querySelector(".catalog-result-row__icon") as HTMLImageElement; expect(icon).toBeInstanceOf(HTMLImageElement); @@ -88,7 +167,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(200)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); const firstRow = resultRows()[0]; window.scrollTo({ top: 72 }); @@ -100,7 +179,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(80), "http://localhost:3000/?q=caplet-70"); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); expect(input().value).toBe("caplet-70"); expect(document.querySelector("[data-result-status]")?.textContent).toBe("1 Caplet"); @@ -114,7 +193,7 @@ describe("virtual catalog results", () => { ); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); window.history.pushState(null, "", "http://localhost:3000/"); window.dispatchEvent(new PopStateEvent("popstate")); @@ -129,7 +208,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(20)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - const search = initVirtualCatalogSearch(); + const search = trackSearch(initVirtualCatalogSearch()); expect(document.querySelector("[data-result-status]")?.textContent).toBe("20 Caplets"); search?.destroy(); @@ -155,7 +234,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(10)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); expect(resultSpacer().style.height).toBe("1880px"); }); @@ -164,7 +243,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(20)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); tagInput().value = "od"; tagInput().dispatchEvent(new Event("input", { bubbles: true })); @@ -176,7 +255,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(20)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); tagSelect().dispatchEvent( new CustomEvent("starwind-select:change", { bubbles: true, @@ -207,7 +286,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(10)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); expect(resultSpacer().style.height).toBe("3200px"); }); @@ -226,7 +305,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(10)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); expect(resultSpacer().style.height).toBe("1680px"); }); @@ -235,7 +314,7 @@ describe("virtual catalog results", () => { mountSearchShell(manyCatalogSearchRows(20)); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); const row = document.querySelector("[data-result-row]") as HTMLElement; row @@ -250,7 +329,7 @@ describe("virtual catalog results", () => { await import("../src/scripts/copy"); const { initVirtualCatalogSearch } = await import("../src/scripts/virtual-results"); - initVirtualCatalogSearch(); + trackSearch(initVirtualCatalogSearch()); const button = document.querySelector("[data-copy-command]") as HTMLButtonElement; expect(button).toBeTruthy(); @@ -259,7 +338,7 @@ describe("virtual catalog results", () => { await new Promise((resolve) => window.setTimeout(resolve, 0)); expect(navigator.clipboard.writeText).toHaveBeenCalledWith( - "caplets install spiritledsoftware/caplets caplet-0", + "caplets telemetry attribution catalog_install\ncaplets install spiritledsoftware/caplets caplet-0", ); expect(document.querySelector("[data-copy-status]")?.textContent).toBe( "Install command copied.", diff --git a/apps/docs/astro.config.mjs b/apps/docs/astro.config.mjs index 18f8b1ba..b41c8174 100644 --- a/apps/docs/astro.config.mjs +++ b/apps/docs/astro.config.mjs @@ -1,8 +1,15 @@ // @ts-check import starlight from "@astrojs/starlight"; +import { sentryVitePlugin } from "@sentry/vite-plugin"; import tailwindcss from "@tailwindcss/vite"; import { defineConfig } from "astro/config"; +const sentryProject = process.env.CAPLETS_DOCS_SENTRY_PROJECT; +const sentryRelease = process.env.PUBLIC_CAPLETS_RELEASE; +const sentryConfigured = Boolean( + process.env.SENTRY_AUTH_TOKEN && process.env.SENTRY_ORG && sentryProject && sentryRelease, +); + export default defineConfig({ site: "https://docs.caplets.dev", integrations: [ @@ -66,6 +73,24 @@ export default defineConfig({ }), ], vite: { - plugins: [tailwindcss()], + build: { + sourcemap: sentryConfigured ? "hidden" : false, + }, + plugins: [ + tailwindcss(), + ...(sentryConfigured + ? [ + sentryVitePlugin({ + authToken: process.env.SENTRY_AUTH_TOKEN, + org: process.env.SENTRY_ORG, + project: sentryProject, + release: { name: sentryRelease }, + sourcemaps: { + filesToDeleteAfterUpload: ["./dist/**/*.map"], + }, + }), + ] + : []), + ], }, }); diff --git a/apps/docs/package.json b/apps/docs/package.json index b914b5b2..cddd8d6f 100644 --- a/apps/docs/package.json +++ b/apps/docs/package.json @@ -8,19 +8,25 @@ "build": "astro build", "dev": "astro dev", "preview": "astro preview", + "test": "vitest run test", "typecheck": "astro check" }, "dependencies": { "@astrojs/check": "^0.9.9", - "@astrojs/starlight": "^0.40.0", + "@astrojs/starlight": "^0.41.1", + "@caplets/web-observability": "workspace:*", + "@sentry/browser": "^10.62.0", "@tailwindcss/vite": "^4.3.1", - "astro": "^6.4.6", - "sharp": "^0.34.5", + "astro": "^7.0.3", + "posthog-js": "^1.395.0", + "sharp": "^0.35.2", "tailwindcss": "^4.3.1", "typescript": "^6.0.3" }, "devDependencies": { - "vite": "^7.3.5" + "happy-dom": "^20.10.6", + "vite": "^8.1.0", + "vitest": "^4.1.9" }, "packageManager": "pnpm@11.7.0" } diff --git a/apps/docs/src/components/CapletsObservability.astro b/apps/docs/src/components/CapletsObservability.astro new file mode 100644 index 00000000..e0f1b70b --- /dev/null +++ b/apps/docs/src/components/CapletsObservability.astro @@ -0,0 +1,3 @@ + diff --git a/apps/docs/src/components/CapletsThemeProvider.astro b/apps/docs/src/components/CapletsThemeProvider.astro index 73912682..96fc7932 100644 --- a/apps/docs/src/components/CapletsThemeProvider.astro +++ b/apps/docs/src/components/CapletsThemeProvider.astro @@ -1,3 +1,9 @@ +--- +import CapletsObservability from "./CapletsObservability.astro"; +--- + + + {/* This is intentionally inlined to avoid FOUC. */}