From 0bcbd60a28826218f03f3dc8c94b0fb17c2e1cda Mon Sep 17 00:00:00 2001 From: Caplets Test Date: Mon, 29 Jun 2026 05:55:51 -0400 Subject: [PATCH 1/5] feat(telemetry): add observability release wiring --- .changeset/tidy-observability-traces.md | 8 + .github/workflows/deploy.yml | 30 + .github/workflows/pr-preview-deploy.yml | 31 + .github/workflows/release.yml | 7 +- CONCEPTS.md | 12 + alchemy.run.ts | 9 + apps/catalog/astro.config.mjs | 24 +- apps/catalog/package.json | 3 + apps/catalog/src/lib/catalog-env.ts | 3 + apps/catalog/src/lib/server-observability.ts | 59 ++ .../pages/api/v1/catalog/install-signals.ts | 7 +- .../src/pages/caplets/[entryKey].astro | 1 + apps/catalog/src/pages/index.astro | 1 + apps/catalog/src/scripts/copy.ts | 5 +- apps/catalog/src/scripts/observability.ts | 136 ++++ apps/catalog/src/scripts/virtual-results.ts | 47 +- apps/catalog/test/observability.test.ts | 59 ++ apps/catalog/test/virtual-results.test.ts | 2 +- apps/docs/astro.config.mjs | 27 +- apps/docs/package.json | 8 +- .../src/components/CapletsObservability.astro | 3 + .../src/components/CapletsThemeProvider.astro | 6 + .../src/content/docs/privacy/indexing.mdx | 5 + .../docs/src/content/docs/troubleshooting.mdx | 5 + apps/docs/src/scripts/observability.ts | 121 ++++ apps/docs/test/observability.test.ts | 14 + apps/docs/vitest.config.ts | 7 + apps/landing/astro.config.mjs | 25 +- apps/landing/package.json | 8 +- apps/landing/src/pages/index.astro | 1 + apps/landing/src/scripts/copy.ts | 6 +- apps/landing/src/scripts/observability.ts | 146 ++++ apps/landing/test/observability.test.ts | 39 ++ apps/landing/vitest.config.ts | 7 + ...-feat-telemetry-observability-loop-plan.md | 418 ++++++++++++ docs/product/anonymous-telemetry.md | 8 + docs/product/telemetry-provider-readiness.md | 74 +- docs/product/telemetry-readout.md | 38 +- package.json | 8 +- packages/cli/rolldown.config.ts | 3 + packages/core/rolldown.config.ts | 5 + packages/core/src/cli.ts | 13 + packages/core/src/telemetry/events.ts | 15 +- packages/core/src/telemetry/index.ts | 9 + packages/core/src/telemetry/privacy.ts | 157 +++++ packages/core/src/telemetry/providers.ts | 5 +- packages/core/src/telemetry/runtime.ts | 26 +- packages/core/src/telemetry/state.ts | 99 +++ packages/core/test/telemetry-docs.test.ts | 22 + packages/core/test/telemetry-events.test.ts | 94 ++- .../core/test/telemetry-providers.test.ts | 24 + .../core/test/telemetry-redaction.test.ts | 44 +- packages/core/test/telemetry-release.test.ts | 102 ++- packages/core/test/telemetry-runtime.test.ts | 79 ++- .../core/test/telemetry-source-maps.test.ts | 44 ++ packages/core/test/telemetry-state.test.ts | 48 ++ packages/opencode/rolldown.config.ts | 3 + packages/pi/rolldown.config.ts | 3 + packages/web-observability/package.json | 18 + packages/web-observability/src/attribution.ts | 16 + packages/web-observability/src/events.ts | 89 +++ packages/web-observability/src/index.ts | 21 + packages/web-observability/src/privacy.ts | 107 +++ .../test/web-observability.test.ts | 103 +++ packages/web-observability/tsconfig.json | 8 + packages/web-observability/vitest.config.ts | 3 + pnpm-lock.yaml | 641 +++++++++++++++++- pnpm-workspace.yaml | 2 + scripts/check-sentry-source-maps.ts | 34 + scripts/check-telemetry-release-env.ts | 35 +- scripts/check-web-observability-env.ts | 79 +++ scripts/runtime-sentry-rolldown.ts | 33 + 72 files changed, 3322 insertions(+), 80 deletions(-) create mode 100644 .changeset/tidy-observability-traces.md create mode 100644 apps/catalog/src/lib/server-observability.ts create mode 100644 apps/catalog/src/scripts/observability.ts create mode 100644 apps/catalog/test/observability.test.ts create mode 100644 apps/docs/src/components/CapletsObservability.astro create mode 100644 apps/docs/src/scripts/observability.ts create mode 100644 apps/docs/test/observability.test.ts create mode 100644 apps/docs/vitest.config.ts create mode 100644 apps/landing/src/scripts/observability.ts create mode 100644 apps/landing/test/observability.test.ts create mode 100644 apps/landing/vitest.config.ts create mode 100644 docs/plans/2026-06-28-002-feat-telemetry-observability-loop-plan.md create mode 100644 packages/core/test/telemetry-source-maps.test.ts create mode 100644 packages/web-observability/package.json create mode 100644 packages/web-observability/src/attribution.ts create mode 100644 packages/web-observability/src/events.ts create mode 100644 packages/web-observability/src/index.ts create mode 100644 packages/web-observability/src/privacy.ts create mode 100644 packages/web-observability/test/web-observability.test.ts create mode 100644 packages/web-observability/tsconfig.json create mode 100644 packages/web-observability/vitest.config.ts create mode 100644 scripts/check-sentry-source-maps.ts create mode 100644 scripts/check-web-observability-env.ts create mode 100644 scripts/runtime-sentry-rolldown.ts 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..80342aee 100644 --- a/apps/catalog/package.json +++ b/apps/catalog/package.json @@ -15,12 +15,15 @@ "@astrojs/check": "^0.9.9", "@astrojs/cloudflare": "^13.7.0", "@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", + "posthog-js": "^1.395.0", "rehype-sanitize": "^6.0.0", "rehype-stringify": "^10.0.1", "remark-parse": "^11.0.0", 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/server-observability.ts b/apps/catalog/src/lib/server-observability.ts new file mode 100644 index 00000000..0c6bd608 --- /dev/null +++ b/apps/catalog/src/lib/server-observability.ts @@ -0,0 +1,59 @@ +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 projectId = url.pathname.split("/").filter(Boolean).at(-1); + if (!projectId) throw new Error("invalid catalog worker Sentry DSN"); + return `${url.protocol}//${url.host}/api/${projectId}/envelope/`; +} + +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/pages/api/v1/catalog/install-signals.ts b/apps/catalog/src/pages/api/v1/catalog/install-signals.ts index 18d0fd09..f88957c9 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) { + await captureCatalogServerError(error, env); return jsonResponse( { ok: false, 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..d1477504 --- /dev/null +++ b/apps/catalog/src/scripts/observability.ts @@ -0,0 +1,136 @@ +import * as Sentry from "@sentry/browser"; +import { + attributedInstallCommand, + buildWebEvent, + bucketResultCount, + bucketSearchTerm, + classifyRouteFamily, + filterSentryBrowserEvent, + type WebEventName, + 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: WebEventProperties): void { + if (!posthogEnabled) return; + try { + const event = buildWebEvent({ name, properties: { surface, ...properties } }); + posthog.capture(event.name, { + ...event.properties, + $process_person_profile: false, + $geoip_disable: true, + }); + } catch { + // Analytics must never affect catalog behavior. + } +} + +function referrerCategory(referrer: string): WebEventProperties["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..edd8bd2f 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, @@ -106,7 +107,13 @@ 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; + } = {}, + ): void { lastFocusedControl = document.activeElement instanceof HTMLElement ? document.activeElement : null; visibleRows = filterCatalogSearchRecords(rows, filters()) as CatalogSearchRow[]; @@ -124,6 +131,11 @@ export function initVirtualCatalogSearch( ) { lastFocusedControl.focus(); } + captureCatalogSearch({ + query: inputEl.value, + resultCount: visibleRows.length, + filterChanged: options.filterChanged, + }); } function renderVirtualRows(): void { @@ -176,20 +188,34 @@ 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(), { signal: events.signal }); + for (const control of [trust, setup, sort]) { + control?.addEventListener("input", () => applySearch({ filterChanged: filterName(control) }), { + signal: events.signal, + }); + 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 +229,7 @@ export function initVirtualCatalogSearch( } setTagValue("all"); if (sort) sort.value = "rank"; - applySearch(); + applySearch({ filterChanged: "reset" }); inputEl.focus(); }, { signal: events.signal }, @@ -243,6 +269,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/test/observability.test.ts b/apps/catalog/test/observability.test.ts new file mode 100644 index 00000000..6d0c6838 --- /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_INSTALL_ATTRIBUTION=catalog_install caplets 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/123", + PUBLIC_CAPLETS_ENVIRONMENT: "production", + PUBLIC_CAPLETS_RELEASE: "sites@test", + }); + + expect(fetch).toHaveBeenCalledWith( + "https://example.ingest.sentry.io/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..1c632ff3 100644 --- a/apps/catalog/test/virtual-results.test.ts +++ b/apps/catalog/test/virtual-results.test.ts @@ -259,7 +259,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_INSTALL_ATTRIBUTION=catalog_install caplets 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..8a5da01b 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", + "@caplets/web-observability": "workspace:*", + "@sentry/browser": "^10.62.0", "@tailwindcss/vite": "^4.3.1", "astro": "^6.4.6", + "posthog-js": "^1.395.0", "sharp": "^0.34.5", "tailwindcss": "^4.3.1", "typescript": "^6.0.3" }, "devDependencies": { - "vite": "^7.3.5" + "happy-dom": "^20.10.6", + "vite": "^7.3.5", + "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. */}