observability-and-growth
Full instrumentation from day one. PostHog consolidates product analytics + feature flags + error tracking (one platform, one bill). GA4 via GTM (14-step automation, custom dimensions over events, server-side tagging). Sentry (deep error tracking + performance). Stripe (webhook-f
Install
npx skills add https://github.com/heymegabyte/claude-skills/tree/master/13-observability-and-growth
claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install heymegabyte-claude-skills@llmmart
git clone https://github.com/heymegabyte/claude-skills.git
The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole heymegabyte/claude-skills collection as a plugin from our marketplace. Git is the plain clone.
Skill manifest
13 — Observability and Growth
Tiered PostHog+Sentry+GA4 stacks wired from day one; feature flags, PLG patterns, incident auto-remediation.
Instrumentation tiers
Per _kernel/standards.md#integrations:
- Tier 1 (solo) — PostHog + Workers Tracing OTLP (2 vendors max, cookie-free, free tier covers <10k MAU)
- Tier 2 (enterprise) — + Sentry @sentry/cloudflare v9 + GA4/GTM + Axiom
- Tier 3 (LLM-heavy >10k calls/mo) — + AI Gateway
PostHog (Tier 1 cornerstone)
- Snippet on every HTML page w/
persistence:'memory'(cookie-free);capture_pageview+capture_pageleave+autocapture:true - Unified platform: product analytics + feature flags + session replay + error tracking
- CSP:
script-src+connect-srcfor posthog domain - Per-feature event naming:
<feature>:<action>(signup:complete,editor:save,share:copy)
Sentry (Tier 2)
@sentry/cloudflarev9 +withSentrywrapper; project viamcp__sentry__create_project(org:megabyte-labs)SENTRY_DSNviawrangler secret put- Pattern:
withSentry(env => ({ dsn, tracesSampleRate: 1.0, sendDefaultPii: false }), worker) - Breadcrumbs before risky ops; capture exception w/ context tags (
worker|route|userId) - Release tracking via
SENTRY_RELEASEenv; Workers Tracing handles I/O spans
Workers Tracing (Tier 1 + 2) / GA4 + GTM (Tier 2) / AI Gateway (Tier 3)
- Workers Tracing:
[observability] enabled = trueinwrangler.jsonc— zero-config OTel I/O tracing; export to Axiom, Honeycomb, Grafana, Datadog via@opentelemetry/exporter-trace-otlp-http - GA4 + GTM: container snippet (head script + noscript iframe); CSP:
googletagmanager.com+google-analytics.com+analytics.google.com+region1.google-analytics.com; server-side tagging for EU; custom dimensions over custom events - AI Gateway:
env.AI.run()auto-routes; direct Anthropic:https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages; caching + rate-limit + fallback + per-call logging
Stripe (SaaS billing only — per rules/payments-routing.md)
- Webhook-first w/ idempotent processing (D1 dedupe table
payment_events(event_id, source, processed_at)UNIQUE) Stripe-SignatureHMAC + 5-min replay window- Mint products + prices via MCP (idempotent via
lookup_key); subscription state machine in D1 STRIPE_WEBHOOK_SECRETviaPOST /v1/webhook_endpoints
Square (accept-money default — per rules/payments-routing.md)
- Square Web Payments SDK card form + Apple Pay + Google Pay + Cash App Pay
Square-SignatureHMAC-SHA256 w/ 6-hr replay window;idempotency_keyUUID per request (24-hr dedupe)- Nonprofit verified-501c3 discount (2.6%+10¢ vs 3.5%+15¢)
Listmonk (newsletter — self-hosted on Coolify)
- Amazon SES SMTP relay (
LISTMONK_FROM_EMAIL);listmonkSendTx(env, { templateAlias, ... })via KV-cached alias→id map - Templates in
emails/*.htmlsynced viascripts/listmonk-sync.mjs - Auth:
Authorization: token <user>:<key>(Listmonk 3.x API-user pattern)
PLG 7-Layer Framework
- Discovery — SEO + AI search + word-of-mouth + paid
- Sign-up — passwordless preferred (Clerk M2M JWT)
- Activation — first-value-in-X-min metric (aha moment per
rules/feature-flags.mdinstrumentation) - Engagement — DAU/MAU ratio, session depth
- Retention — D1/D7/D30 cohort
- Revenue — upgrade trigger, expansion
- Referral — viral coefficient, two-sided rewards
Programmatic SEO (5 page types)
/integrations/{tool}·/compare/{a}-vs-{b}·/for/{audience}·/templates/{type}·/{city}-{service}- Each: unique H1 + meta desc + 800+ unique words + 1 unique image + 3+ internal links + 1+ outbound citation. Cap 200 pages per axis.
- Per
rules/copy-writing.md§ pSEO +rules/thin-source-amplification.md.
GEO / AI search
- Quotable answer blocks 40-60 words (LLM citation magnet); FAQPage schema highest AI-citation rate (ChatGPT / Perplexity / Google AI Overviews)
- JSON-LD facts MUST also appear as visible HTML body text; lead paragraphs answer query in <40 words
- EEAT: author bio +
Personschema +sameAs+ dated revision + ownership statement llms.txtat site root (DX-only, <0.3% adoption — not build gate)
Local-business conversions + CRO
Track per local-conversions.md: phone_click (tel:) · direction_click (Google Maps) · form_submit · booking_click (Calendly/Cal.com) · chat_click · review_click (Google Business/Yelp). Each fires PostHog + Sentry breadcrumb + (Tier 2) GA4 conversion event.
CRO: sticky CTA bar on mobile · scroll-progress bar (subtle) · exit-intent modal (cart/pricing only, not blogs) · social proof near every CTA · urgency without dark patterns ("3 spots left this week" if true) · trust strip above fold · single primary CTA per surface.
Incident auto-remediation
Sentry → Inngest pipeline: (1) Sentry webhook on event.alert.triggered → (2) Inngest dispatches incident-responder agent → (3) agent reads event, traces to source file, proposes fix, opens PR via gh MCP → (4) PR auto-merges if all CI gates pass per rules/ai-seniority.md.
See submodules: posthog, ga4-gtm, sentry, stripe-billing, square-payments, listmonk, plg-framework, programmatic-seo, incident-remediation, geo-ai-search, local-conversions.
Files (claude-skills)
-
activation-funnel.md 11.1 KB
--- name: "Activation Funnel" description: "PostHog event taxonomy for PLG SaaS funnels: first-magic-moment definition, 5-stage activation funnel (visit→signup→core-action→second-session→paid), cohort SQL, milestone tracking, in-app upgrade prompts without dark patterns." updated: "2026-06-18" always-load: false --- # Activation Funnel PLG lives and dies on activation. Define ONE north-star activation metric — the moment a user experiences undeniable value — then instrument everything upstream and downstream of it. ## Magic-Moment Definition The magic moment = the single action after which paid conversion probability jumps >3× above baseline. Pick it empirically: 1. Pull cohorts of users who converted to paid in the last 90 days. 2. Run `correlation_score` across every `feature_activated` event against `subscription_started` within 14 days. 3. The event with highest correlation AND >30% frequency IS your magic moment (e.g. `project_first_published`, `integration_connected`, `report_generated`). 4. Hard-code as `MAGIC_MOMENT_EVENT = 'your_event'` in `worker/activation.ts`; never change without repeating this analysis. ## 5-Stage Funnel ``` visit → signup → core_action → second_session → paid ``` | Stage | Definition | Target Conversion | |-------|-----------|------------------| | `visit` | `page_viewed` on any marketing page | — | | `signup` | `signup_completed` (account created) | ≥3% visit→signup | | `core_action` | Magic-moment event fired ≤24h after signup | ≥40% signup→core | | `second_session` | Session started ≥24h after signup | ≥30% core→second | | `paid` | `subscription_started` or `purchase_completed` | ≥8% second→paid | If any stage is below target, that stage owns the sprint — not the one after it. ## PostHog Event Taxonomy ### Acquisition Events ```typescript // Every marketing page load posthog.capture('page_viewed', { path: window.location.pathname, title: document.title, referrer: document.referrer, utm_source: params.get('utm_source'), utm_campaign: params.get('utm_campaign'), }) // CTA engagement posthog.capture('cta_clicked', { cta_text: el.innerText, cta_location: 'hero' | 'pricing' | 'nav' | 'footer', destination: el.href, }) ``` ### Activation Events ```typescript // Account created posthog.capture('signup_completed', { method: 'google' | 'email' | 'github', plan: 'free', referral_code: code ?? null, }) // First-run milestone (fire once, idempotently) posthog.capture('onboarding_step_completed', { step: 'profile' | 'invite' | 'integration' | 'first_action', step_index: 1 | 2 | 3 | 4, time_since_signup_s: Math.floor((Date.now() - signupTs) / 1000), }) // Magic moment posthog.capture(MAGIC_MOMENT_EVENT, { context: 'onboarding' | 'organic', time_since_signup_s: elapsed, }) // Identify at signup + enrich on magic moment posthog.identify(userId, { email: user.email, plan: 'free', signup_method: method, }) posthog.group('company', orgId, { name: org.name, plan: org.plan }) ``` ### Engagement Events ```typescript posthog.capture('feature_activated', { feature_name: 'csv_export', context: 'dashboard' }) posthog.capture('session_started', { session_number: n, days_since_signup: d }) posthog.capture('search_performed', { query_length: q.length, results_count: n }) posthog.capture('error_displayed', { error_type: 'validation' | 'server', page: path }) ``` ### Revenue Events ```typescript posthog.capture('upgrade_prompt_shown', { trigger: 'feature_gate' | 'usage_limit' | 'milestone', plan_suggested: 'pro' }) posthog.capture('pricing_page_viewed', { source: 'nav' | 'upgrade_prompt' | 'direct' }) posthog.capture('checkout_started', { plan: 'pro', billing: 'monthly' | 'annual' }) posthog.capture('subscription_started', { plan: 'pro', billing: 'annual', mrr: 29 }) posthog.capture('subscription_cancelled', { plan: 'pro', reason: surveyAnswer, days_active: n }) ``` ## PostHog Funnel Queries ### Full Activation Funnel (HogQL) ```sql SELECT countIf(event = 'page_viewed') AS visits, countIf(event = 'signup_completed') AS signups, countIf(event = 'project_first_published') AS magic_moment, countIf(event = 'session_started' AND properties.session_number >= 2) AS second_session, countIf(event = 'subscription_started') AS paid, round(countIf(event = 'signup_completed') * 100.0 / nullIf(countIf(event = 'page_viewed'), 0), 2) AS visit_to_signup_pct, round(countIf(event = 'project_first_published') * 100.0 / nullIf(countIf(event = 'signup_completed'), 0), 2) AS signup_to_magic_pct, round(countIf(event = 'subscription_started') * 100.0 / nullIf(countIf(event = 'session_started' AND properties.session_number >= 2), 0), 2) AS second_to_paid_pct FROM events WHERE timestamp >= now() - interval 30 day ``` ### Time-to-Activation Cohort (HogQL) ```sql -- Distribution of minutes to magic moment, segmented by signup method SELECT person.properties.signup_method AS method, histogram( toUInt32(dateDiff('minute', minIf(timestamp, event = 'signup_completed'), minIf(timestamp, event = 'project_first_published') )), 10 ) AS activation_time_histogram FROM events WHERE event IN ('signup_completed', 'project_first_published') AND timestamp >= now() - interval 90 day GROUP BY method ``` ### Weekly Activation Rate Trend ```sql SELECT toMonday(timestamp) AS week, countIf(event = 'signup_completed') AS new_signups, countIf(event = 'project_first_published') AS activated, round(countIf(event = 'project_first_published') * 100.0 / nullIf(countIf(event = 'signup_completed'), 0), 1) AS activation_rate_pct FROM events WHERE timestamp >= now() - interval 12 week GROUP BY week ORDER BY week ``` ### Users Who Signed Up But Never Hit Magic Moment (D1 equivalent for Workers) ```sql -- D1 query — find stale free users for nudge campaign SELECT u.id, u.email, u.created_at, MAX(e.occurred_at) AS last_event, julianday('now') - julianday(u.created_at) AS days_since_signup FROM users u LEFT JOIN events e ON e.user_id = u.id WHERE u.plan = 'free' AND u.magic_moment_at IS NULL AND julianday('now') - julianday(u.created_at) BETWEEN 1 AND 7 GROUP BY u.id ORDER BY days_since_signup DESC LIMIT 500 ``` ## Cohort Analysis: Retention by Activation Status In PostHog UI: Retention → breakdown by `$feature/activated` (custom property set on magic-moment). Workers-side: set a PostHog person property at magic-moment: ```typescript await posthog.groupIdentify({ groupType: 'company', groupKey: orgId, properties: { activated: true, activated_at: new Date().toISOString() } }) ``` Target: activated cohort retains 2× better at 7-day / 30-day. ## First-Run Experience (FRE) Patterns ### Onboarding Checklist (Worker-backed, D1-persisted) ```typescript // worker/onboarding.ts const STEPS: OnboardingStep[] = [ { key: 'profile', label: 'Complete your profile', required: true }, { key: 'integration', label: 'Connect your first tool', required: true }, { key: 'first_action', label: 'Create your first project', required: true }, { key: 'invite', label: 'Invite a teammate', required: false }, ] // Persist completion to D1 + fire PostHog event export async function markStep(env: Env, userId: string, step: string) { await env.DB.prepare( `INSERT OR IGNORE INTO onboarding_completions (user_id, step, completed_at) VALUES (?, ?, datetime('now'))` ).bind(userId, step).run() await posthog.capture({ distinctId: userId, event: 'onboarding_step_completed', properties: { step, completed_all: await allStepsDone(env, userId) } }) } ``` ### Empty States as Action Prompts Every empty state is an activation hook — never "No data yet": ```tsx // ✅ <EmptyState icon={<FolderIcon />} title="Create your first project" body="Projects are where your work lives. Most teams activate in under 2 minutes." cta={<Button onClick={createProject}>Create project</Button>} /> // ❌ never: <p>No projects yet.</p> ``` ### Progress Indicator Show milestone language: "2 of 4 steps to full access." Not a percentage bar (users abandon bars). ## In-App Upgrade Prompts (Anti-Dark-Pattern) ### Triggers (when to show) | Trigger | Event | Condition | |---------|-------|-----------| | Feature gate | `feature_gate_hit` | User clicks locked feature | | Usage limit | `usage_limit_approached` | ≥80% of free quota consumed | | Milestone | `team_member_invited` | Free plan hits seat limit | | Value moment | Magic-moment event | User just hit the magic moment (positive state) | Never show: - On first session before magic moment - More than once per 48h per trigger type - After a payment error - In the middle of a user flow ### Prompt Shape ```tsx <UpgradePrompt trigger="feature_gate" // logged to PostHog feature="csv_export" headline="Export your data" // value-forward, not fear body="CSV export is on Pro. All your data, your way." cta="Upgrade to Pro" dismissible // ALWAYS dismissible onUpgrade={() => posthog.capture('upgrade_prompt_accepted', { trigger, feature })} onDismiss={() => posthog.capture('upgrade_prompt_dismissed', { trigger, feature })} /> ``` ### Suppression Logic (Worker-side) ```typescript async function shouldShowUpgradePrompt(env: Env, userId: string, trigger: string): Promise<boolean> { const row = await env.DB.prepare( `SELECT shown_at FROM upgrade_prompts_shown WHERE user_id = ? AND trigger = ? ORDER BY shown_at DESC LIMIT 1` ).bind(userId, trigger).first<{ shown_at: string }>() if (!row) return true const hoursSince = (Date.now() - new Date(row.shown_at).getTime()) / 3_600_000 return hoursSince >= 48 } ``` ## Milestone Tracking (D1 Schema) ```sql CREATE TABLE IF NOT EXISTS user_milestones ( user_id TEXT NOT NULL, milestone TEXT NOT NULL, -- 'signup','magic_moment','second_session','paid','churned' achieved_at TEXT NOT NULL DEFAULT (datetime('now')), metadata TEXT, -- JSON blob PRIMARY KEY (user_id, milestone) ); CREATE TABLE IF NOT EXISTS upgrade_prompts_shown ( id INTEGER PRIMARY KEY AUTOINCREMENT, user_id TEXT NOT NULL, trigger TEXT NOT NULL, feature TEXT, shown_at TEXT NOT NULL DEFAULT (datetime('now')), dismissed INTEGER NOT NULL DEFAULT 0, converted INTEGER NOT NULL DEFAULT 0 ); ``` ## Activation Dashboard (PostHog) Build one insight per funnel stage + pin to "Activation" dashboard: 1. **Funnel chart** — 5 stages, weekly trend, segment by signup method 2. **Retention table** — activated vs. not, 7/14/30 day columns 3. **Time-to-activation histogram** — p50/p90 in minutes 4. **Upgrade prompt conversion rate** — by trigger type 5. **Magic-moment correlation heatmap** — which events precede paid conversion ## See - `analytics-configuration` — PostHog init + standard events - `feature-flags-and-experiments` — A/B test upgrade prompts - `conversion-optimization` — landing page + CTA patterns - `stripe-billing` / `square-payments` — subscription_started → revenue -
analytics-configuration.md 7.2 KB
--- name: "Analytics Configuration" description: "Canonical owner of GA4 setup (14-step automation), GTM container configuration, PostHog SDK integration, feature flags, A/B tests, event taxonomy, funnel definitions, session recording config, and scroll/engagement tracking. The intelligence layer that powers data-driven decisions." updated: "2026-04-23" always-load: false --- # Analytics Configuration ## Stack - **Tag Management** — GTM (Google, not self-hosted) - **Web Analytics** — GA4 (Google, not self-hosted) - **Product Analytics** — PostHog (self-hosted, posthog.megabyte.space) - **Feature Flags** — PostHog (self-hosted) - **A/B Testing** — PostHog Experiments (self-hosted) - **Session Recording** — PostHog with masked inputs (self-hosted) - **Error Tracking** — Sentry (***AUTO-PROVISION***, sentry.megabyte.space) ## Event Naming `object_action` (snake_case, past tense): `page_viewed`, `cta_clicked`, `form_submitted`, `signup_completed`, `subscription_started`, `error_displayed`. ## Standard Events - **`page_viewed`** — props: `path, title, referrer`; trigger: every page - **`scroll_depth_reached`** — props: `percent (25/50/75/100)`; trigger: scroll milestones - **`cta_clicked`** — props: `cta_text, cta_location, destination`; trigger: CTA click - **`form_submitted`** — props: `form_name, success, error_type`; trigger: form submit - **`signup_completed`** — props: `method, plan`; trigger: account created - **`purchase_completed`** — props: `amount, currency, plan`; trigger: checkout success - **`feature_activated`** — props: `feature_name, context`; trigger: first use - **`search_performed`** — props: `query, results_count`; trigger: site search - **`error_displayed`** — props: `error_type, page, message`; trigger: error shown ## Rules 1. GTM is ONLY script loader (except Sentry early init) 2. Never hardcode GA4/PostHog in HTML — load via GTM 3. Self-hosted PostHog preferred (no cookies, no GDPR banner) 4. Every action: GA4 event + PostHog event + Sentry breadcrumb 5. Feature flags BEFORE building feature. Ship behind flag. Remove after 100% rollout. 6. A/B tests require hypothesis + min sample size + defined success metric 7. Session recording masks all inputs by default. PII never recorded. 8. DataLayer pushes happen before action completes (optimistic) 9. No double-counting events across tools 10. Scroll depth: 25/50/75/100% milestones only ## GTM Snippet ```html <script>(function(w,d,s,l,i){w[l]=w[l]||[];w[l].push({'gtm.start':new Date().getTime(),event:'gtm.js'});var f=d.getElementsByTagName(s)[0],j=d.createElement(s),dl=l!='dataLayer'?'&l='+l:'';j.async=true;j.src='https://www.googletagmanager.com/gtm.js?id='+i+dl;f.parentNode.insertBefore(j,f);})(window,document,'script','dataLayer','GTM-XXXXXXX');</script> <!-- noscript iframe after <body> --> ``` ## PostHog Init ```typescript posthog.init('phc_PROJECT_KEY', { api_host: 'https://posthog.megabyte.space', capture_pageview: true, capture_pageleave: true, autocapture: true, session_recording: { maskAllInputs: true, maskTextSelector: '.sensitive' }, persistence: 'memory', // No cookies loaded: (ph) => { if (import.meta.env.DEV) ph.opt_out_capturing(); }, }); ``` ## Feature Flags ```typescript function isFeatureEnabled(flag: string, defaultValue = false): boolean { return posthog?.isFeatureEnabled(flag) ?? defaultValue; } // With payload: posthog.getFeatureFlagPayload('hero-experiment') ``` ## A/B Testing Define: `name`, `hypothesis`, `variants`, `successMetric`, `minimumSampleSize`, `duration`. ```typescript const variant = posthog.getFeatureFlag('pricing-page-layout'); ``` Track conversion with experiment + variant properties. ## Scroll Depth Track 25/50/75/100% milestones. Push to `dataLayer` + `posthog.capture`. Use passive scroll listener. ## DataLayer Pattern ```typescript function trackEvent(event: string, props: Record<string, unknown> = {}) { window.dataLayer = window.dataLayer || []; window.dataLayer.push({ event, ...props }); posthog.capture(event, props); } ``` ## Funnels ```typescript const FUNNELS = { signupToActivation: { steps: ['page_viewed /signup', 'signup_completed', 'feature_activated'], window: '7d' }, visitToPurchase: { steps: ['page_viewed /', 'cta_clicked pricing', 'purchase_completed'], window: '30d' }, }; ``` ## CSP for Analytics ``` script-src: https://www.googletagmanager.com https://www.google-analytics.com https://*.posthog.com connect-src: https://www.google-analytics.com https://analytics.google.com https://*.posthog.com https://*.sentry.io ``` ## Full Analytics Auto-Provision (***MANDATORY — ALL THREE***) ### Sentry (Error Tracking + Performance) Every project gets Sentry from day one. Missing `@sentry/cloudflare` (Workers) or `@sentry/node` → install SDK + wrap entry point + create project via `mcp__sentry__create_project` (org: megabyte-labs, team: megabyte-labs, platform: javascript) → set `SENTRY_DSN` via `wrangler secret put`. Full-stack traces: `tracesSampleRate`, `app.onError()` → `captureException` with route + userId tags, breadcrumbs before risky ops, `SENTRY_RELEASE` for deploy tracking. ### PostHog (Product Analytics + Feature Flags + Session Recording) Every HTML page gets PostHog snippet. Config: - `persistence:'memory'` (cookie-free, no GDPR banner) - `capture_pageview:true` - `capture_pageleave:true` - `autocapture:true` CSP: script-src + connect-src for PostHog API host. PostHog project key stored as env var `POSTHOG_KEY` or inline for static HTML. Self-hosted preferred (posthog.megabyte.space) but `us.i.posthog.com` acceptable for cloud. Events: `page_viewed`, `cta_clicked`, `form_submitted`, `donate_click`, `newsletter_signup`, `scroll_depth`. ### GA4/GTM (Marketing Analytics + Tag Management) Every HTML page gets GTM container (head script + noscript iframe after body). GTM container ID: `get-secret GTM_CONTAINER_ID` (Megabyte Labs). CSP: - `script-src` — googletagmanager.com + google-analytics.com - `connect-src` — analytics.google.com + region1.google-analytics.com - `img-src` — googletagmanager.com + google-analytics.com GA4 configured inside GTM (never standalone). Custom dimensions over separate events. 14-month retention. Google Signals enabled. ### Enforcement Missing ANY of the three → add in same prompt. No page ships without all three firing. Every action: GA4 event + PostHog event + Sentry breadcrumb. CSP must allow all three domains. Verify in browser DevTools Network tab before marking deploy as done. ## Verification Checklist - GTM loads - GA4 fires (DebugView) - PostHog captures pageviews (Live Events) - Sentry DSN set + errors captured (Sentry Issues dashboard) - Custom events fire on interactions - Feature flags resolve - Session recording captures - No duplicate events - CSP allows all domains - Scroll depth fires at milestones - No cookies set (self-hosted PostHog) ## Ownership - **Owns** — GA4 config, GTM setup, PostHog SDK, feature flags, A/B tests, event taxonomy, funnels, session recording, scroll tracking, dataLayer architecture, cookie-free analytics, verification - **Never owns** — Error tracking config (→ 13 Sentry), payments (→ 18), email marketing (→ 13 Listmonk), growth strategy (→ 13), deployment (→ 08), SEO (→ 28) -
conversion-optimization.md 5 KB
--- name: "Conversion Optimization" description: "CRO patterns for SaaS signup flows, pricing pages, paywalls, and churn prevention" version: "2.0.0" updated: "2026-04-23" --- # Conversion Optimization ## Core Loop Measure → hypothesis → test → analyze → iterate. Never skip baseline measurement. A/B test ONE variable at a time via PostHog feature flags. **Statistical significance:** min 1000 visitors/variant, 95% confidence, 2-week minimum duration. ## Key Metrics - **Conversion Rate** — `signups / visitors` — target >3% SaaS, >5% free tier - **Trial-to-Paid** — `paid / trial starts` — target >25% - **Monthly Churn** — `churned / start MRR` — target <5% - **LTV** — `ARPU / churn rate` — target >3x CAC - **CAC** — `total spend / new customers` — target <LTV/3 - **Activation** — `users hitting aha moment / signups` — target >40% ## Pricing Page Patterns - 3-tier layout (Free | Pro $50/mo | Enterprise custom) - Highlight recommended tier with brand cyan `#00E5FF` border - Annual toggle prominent (20% discount = $480/yr vs $600) - Show savings amount, not just percentage - Feature comparison table with checkmarks - Social proof: "Join 2,000+ teams" with real numbers - FAQ section below tiers addressing objections - Mobile: stack vertically, recommended first ## Signup Flow - Reduce fields to email + password (name optional, collect later) - Social auth (Google, GitHub) above form - Progress indicator for multi-step - Inline validation on blur - Smart defaults for settings - Skip onboarding option - **Time-to-value** — user sees core feature within 60 seconds of signup ## Paywall Patterns - **Soft paywall** — show feature, blur/lock result, explain what Pro unlocks - **Usage-based triggers** — `"You've used 8 of 10 free reports. Upgrade for unlimited."` - **Trial** — 14-day default, no credit card required upfront - **Email sequence** — day 1 welcome, day 7 value, day 12 urgency - Never hard-lock features without showing value first ## Churn Prevention - **Cancellation flow** — survey (too expensive | missing feature | switching | other) → offer alternatives (pause, downgrade, discount) → confirm - **Win-back** — automated email at 7/30/90 days via Resend - **Usage decline** — PostHog event tracking, alert when weekly active drops 50% - **Dunning** — 3 retries over 7 days, Stripe Smart Retries enabled ## Form Optimization - Inline validation (not on submit) - Autofill attributes on all fields - Smart defaults reduce decisions - **Error messages** — specific + actionable (`"Email already registered. Sign in?"` not `"Error 409"`) - **Success** — brief + next (`"Account created. Let's set up your first project →"`) - **Loading** — contextual (`"Creating your workspace..."` not `"Loading..."`) ## Anti-Patterns Never: - Dark patterns (hidden costs, forced continuity, misdirection) - Fake urgency - Guilt-tripping cancel copy - Hiding unsubscribe - Pre-checked upsells - Bait-and-switch pricing **ADA/WCAG** — all conversion elements keyboard accessible, screen reader compatible, sufficient contrast. ## Local Business Conversions (***NOT SAAS***) Local businesses don't have trial-to-paid funnels. Their conversions are physical-world actions. ### Event Taxonomy (PostHog + GA4 + Sentry breadcrumb) **Primary (highest intent):** - `phone_click` — `tel:` link clicked - `direction_click` — Google Maps directions clicked - `form_submit` — Contact/booking form submitted - `booking_click` — External booking CTA (OpenTable, Calendly, etc.) **Secondary:** - `email_click` — `mailto:` link clicked - `chat_open` — Live chat widget opened - `review_click` — "Leave a Review" CTA clicked **Micro:** - `menu_download` — PDF menu/brochure downloaded - `coupon_claim` — Special offer clicked - `social_click` — Social media profile link clicked ### GA4 Goals (auto-configure via GTM dataLayer) ```javascript // Inject in every local business site build document.querySelectorAll('a[href^="tel:"]').forEach(el => el.addEventListener('click', () => gtag('event', 'phone_click', { phone: el.href })) ); document.querySelectorAll('a[href*="maps.google"]').forEach(el => el.addEventListener('click', () => gtag('event', 'direction_click')) ); ``` ### Local Funnel (PostHog) `Visit → Engagement (scroll 50%+) → Micro (menu/gallery) → Macro (call/directions/form/booking)` Track weekly conversion rate. Alert if rate drops >20% WoW. Typical local business: 3-8% macro conversion rate (vs 1-3% SaaS). ### Call Tracking (optional premium) - Google forwarding number (free with Google Ads) OR CallRail ($45/mo) - Tracks: duration, caller location, recording, missed call alerts - Without call tracking: `tel:` click count as proxy ## Tools - **PostHog** — funnel analysis + feature flags + session replay - **Stripe** — billing portal, proration, Smart Retries, revenue recovery - **Resend** — transactional + win-back emails - **Inngest** — automated lifecycle workflows (trial → paid → churn sequences) -
email-marketing-and-listmonk.md 8.1 KB
--- name: "Email Marketing and Listmonk" description: "Listmonk on Coolify for newsletters and campaigns. REST API for subscriber management and campaign creation, Go template system, webhook integration with Hono for subscription events, double opt-in flow, unsubscribe handling, and Neon PostgreSQL backend." updated: "2026-04-23" --- # Email Marketing and Listmonk ## Coolify Deployment ```yaml # Listmonk on Coolify: Docker compose # Image: listmonk/listmonk:latest # Database: Neon PostgreSQL (external) # Domain: listmonk.megabyte.space (proxied via CF) # Env vars: # LISTMONK_app__address: "0.0.0.0:9000" # LISTMONK_app__admin_username: (from Coolify secrets) # LISTMONK_app__admin_password: (from Coolify secrets) # LISTMONK_db__host: (Neon host) # LISTMONK_db__port: 5432 # LISTMONK_db__user: (Neon user) # LISTMONK_db__password: (Neon password) # LISTMONK_db__database: listmonk # LISTMONK_db__ssl_mode: require ``` ## Hono Worker Proxy (CF Worker → Listmonk) ```typescript // src/routes/newsletter.ts import { Hono } from 'hono'; import { zValidator } from '@hono/zod-validator'; import { z } from 'zod'; const newsletter = new Hono<{ Bindings: Env }>(); const listmonkFetch = async (env: Env, path: string, options?: RequestInit) => { const auth = btoa(`${env.LISTMONK_USER}:${env.LISTMONK_PASS}`); return fetch(`${env.LISTMONK_URL}/api${path}`, { ...options, headers: { ...options?.headers, Authorization: `Basic ${auth}`, 'Content-Type': 'application/json', }, }); }; // Subscribe (public endpoint — Turnstile protected) newsletter.post('/subscribe', zValidator('json', z.object({ email: z.string().email(), name: z.string().min(1).max(100).optional(), listIds: z.array(z.number()).default([1]), // default newsletter list turnstileToken: z.string(), })), async (c) => { const { email, name, listIds, turnstileToken } = c.req.valid('json'); // Verify Turnstile const turnstileRes = await fetch('https://challenges.cloudflare.com/turnstile/v0/siteverify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ secret: c.env.TURNSTILE_SECRET, response: turnstileToken }), }); const turnstile = await turnstileRes.json() as { success: boolean }; if (!turnstile.success) return c.json({ error: 'Verification failed' }, 400); // Create subscriber (double opt-in) const res = await listmonkFetch(c.env, '/subscribers', { method: 'POST', body: JSON.stringify({ email, name: name || email.split('@')[0], status: 'enabled', lists: listIds, preconfirm_subscriptions: false, // requires double opt-in confirmation }), }); if (!res.ok) { const err = await res.json() as { message: string }; if (err.message?.includes('already exists')) { return c.json({ message: 'Already subscribed' }); } return c.json({ error: 'Subscription failed' }, 500); } return c.json({ message: 'Check your email to confirm' }); }); // Unsubscribe newsletter.post('/unsubscribe', zValidator('json', z.object({ email: z.string().email(), })), async (c) => { const { email } = c.req.valid('json'); // Find subscriber const search = await listmonkFetch(c.env, `/subscribers?query=subscribers.email='${encodeURIComponent(email)}'`); const { data } = await search.json() as { data: { results: Array<{ id: number }> } }; if (!data.results?.length) return c.json({ message: 'Unsubscribed' }); // Blocklist subscriber await listmonkFetch(c.env, `/subscribers/${data.results[0].id}/blocklist`, { method: 'PUT' }); return c.json({ message: 'Unsubscribed' }); }); // List available newsletter lists (public) newsletter.get('/lists', async (c) => { const res = await listmonkFetch(c.env, '/lists?page=1&per_page=50'); const { data } = await res.json() as { data: { results: Array<{ id: number; name: string; description: string }> } }; return c.json({ lists: data.results.map(({ id, name, description }) => ({ id, name, description })) }); }); export { newsletter }; ``` ## Campaign Creation via API ```typescript // Create and send a campaign programmatically async function createCampaign(env: Env, opts: { name: string; subject: string; body: string; listIds: number[]; templateId?: number; sendAt?: string; // ISO date for scheduled send }): Promise<number> { const res = await listmonkFetch(env, '/campaigns', { method: 'POST', body: JSON.stringify({ name: opts.name, subject: opts.subject, body: opts.body, content_type: 'richtext', lists: opts.listIds, template_id: opts.templateId || 1, type: 'regular', tags: ['automated'], send_at: opts.sendAt, }), }); const { data } = await res.json() as { data: { id: number } }; // Start the campaign await listmonkFetch(env, `/campaigns/${data.id}/status`, { method: 'PUT', body: JSON.stringify({ status: 'running' }), }); return data.id; } ``` ## Go Template System (Listmonk) ```html <!-- Listmonk uses Go's text/template syntax --> <!-- Available variables: .Subscriber, .Campaign, .UnsubscribeURL, .TrackLink --> <!-- Campaign template --> <h1>{{ .Campaign.Subject }}</h1> <p>Hey {{ .Subscriber.FirstName }},</p> {{ .Campaign.Body }} <!-- Conditional content by subscriber attribute --> {{ if eq .Subscriber.Attribs.plan "pro" }} <p>As a Pro member, you get early access.</p> {{ else }} <p><a href="{{ TrackLink "https://example.com/upgrade" }}">Upgrade to Pro</a> for early access.</p> {{ end }} <!-- Tracked link --> <a href="{{ TrackLink "https://example.com/feature" }}">Check out our new feature</a> <!-- Unsubscribe (REQUIRED) --> <p><a href="{{ .UnsubscribeURL }}">Unsubscribe</a></p> <!-- Custom template with brand styling --> <!-- Base template wraps all campaigns with header/footer/brand colors --> ``` ## Webhook Integration (Listmonk → Hono) Listmonk webhook events: - `subscriber.created`, `subscriber.modified`, `subscriber.deleted` - `campaign.sent`, `campaign.updated` ```typescript // src/routes/webhooks/listmonk.ts const listmonkWebhook = new Hono<{ Bindings: Env }>(); listmonkWebhook.post('/listmonk', async (c) => { // Verify webhook secret const secret = c.req.header('X-Listmonk-Secret'); if (secret !== c.env.LISTMONK_WEBHOOK_SECRET) { return c.json({ error: 'Invalid secret' }, 401); } const event = await c.req.json(); switch (event.event) { case 'subscriber.created': // Sync to CRM, PostHog, etc. // posthog.capture('newsletter_signup', { email: event.data.email }); break; case 'subscriber.modified': if (event.data.status === 'blocklisted') { // Track unsubscribe // posthog.capture('newsletter_unsubscribe', { email: event.data.email }); } break; case 'campaign.sent': // Track campaign completion break; } return c.json({ received: true }); }); export { listmonkWebhook }; ``` ## Double Opt-In Flow 1. User submits email → `POST /api/newsletter/subscribe` 2. Listmonk creates subscriber (status: `unconfirmed`) 3. Listmonk sends confirmation email with unique link 4. User clicks confirmation link → status: `confirmed` 5. Subscriber receives future campaigns 6. No confirmation within 72h → auto-cleanup (Listmonk setting) ## Footer Newsletter Component ```html <!-- Minimal newsletter signup for site footer --> <form (submit)="subscribe($event)" class="newsletter-form"> <input type="email" [(ngModel)]="email" placeholder="your@email.com" required /> <button type="submit" [disabled]="submitting">Subscribe</button> <cf-turnstile [siteKey]="turnstileSiteKey" (resolved)="onTurnstile($event)" /> @if (message) { <p class="feedback">{{ message }}</p> } </form> ``` ## Listmonk API Quick Reference - `GET /api/subscribers?query=...&page=1&per_page=50` — search subscribers - `POST /api/subscribers` — create subscriber - `PUT /api/subscribers/:id` — update subscriber - `PUT /api/subscribers/:id/blocklist` — blocklist (unsubscribe) - `GET /api/lists` — list all lists - `POST /api/campaigns` — create campaign - `PUT /api/campaigns/:id/status` — start/pause/cancel campaign - `GET /api/campaigns/:id` — campaign details + stats - `POST /api/tx` — send transactional email -
feature-flags-and-experiments.md 6.6 KB
--- name: "Feature Flags and Experiments" description: "PostHog feature flags and A/B testing. Server-side flag evaluation in Hono middleware, Angular directive for client-side flags, experiment setup with control/variants and statistical significance, gradual rollout patterns, and kill switches." updated: "2026-04-23" --- # Feature Flags and Experiments ## PostHog Server-Side Flags (Hono Middleware) ```typescript // src/middleware/feature-flags.ts import { PostHog } from 'posthog-node'; import type { Context, Next } from 'hono'; function createPostHog(env: Env): PostHog { return new PostHog(env.POSTHOG_API_KEY, { host: 'https://posthog.megabyte.space', flushAt: 1, flushInterval: 0 }); } // Middleware: evaluate flags for authenticated user async function featureFlagMiddleware(c: Context<{ Bindings: Env }>, next: Next): Promise<void> { const userId = c.get('userId'); if (!userId) { await next(); return; } const posthog = createPostHog(c.env); const flags = await posthog.getAllFlags(userId, { personProperties: { email: c.get('userEmail'), plan: c.get('userPlan') }, }); c.set('featureFlags', flags); await posthog.shutdown(); await next(); } // Route-level flag check async function requireFlag(flagName: string, c: Context): Promise<boolean> { const flags = c.get('featureFlags') as Record<string, boolean | string>; return !!flags[flagName]; } // Usage in route app.get('/api/new-feature', featureFlagMiddleware, async (c) => { if (!await requireFlag('new-dashboard', c)) { return c.json({ error: 'Feature not available' }, 404); } // Feature implementation return c.json({ data: 'new feature response' }); }); ``` ## Angular Feature Flag Directive ```typescript // feature-flag.directive.ts import { Directive, Input, TemplateRef, ViewContainerRef, OnInit, DestroyRef, inject } from '@angular/core'; import { FeatureFlagService } from './feature-flag.service'; @Directive({ selector: '[appFeatureFlag]', standalone: true }) export class FeatureFlagDirective implements OnInit { @Input('appFeatureFlag') flagName = ''; @Input('appFeatureFlagElse') elseTemplate?: TemplateRef<unknown>; private destroyRef = inject(DestroyRef); constructor( private templateRef: TemplateRef<unknown>, private viewContainer: ViewContainerRef, private flagService: FeatureFlagService, ) {} ngOnInit(): void { this.flagService.isEnabled(this.flagName).subscribe((enabled) => { this.viewContainer.clear(); if (enabled) { this.viewContainer.createEmbeddedView(this.templateRef); } else if (this.elseTemplate) { this.viewContainer.createEmbeddedView(this.elseTemplate); } }); } } // feature-flag.service.ts import { Injectable, signal } from '@angular/core'; import { Observable, from, of } from 'rxjs'; import { map, shareReplay } from 'rxjs/operators'; import posthog from 'posthog-js'; @Injectable({ providedIn: 'root' }) export class FeatureFlagService { private cache = new Map<string, Observable<boolean>>(); isEnabled(flag: string): Observable<boolean> { if (!this.cache.has(flag)) { this.cache.set(flag, of(posthog.isFeatureEnabled(flag) ?? false).pipe(shareReplay(1))); } return this.cache.get(flag)!; } getVariant(flag: string): string | boolean | undefined { return posthog.getFeatureFlag(flag); } /** Force reload all flags (after login/plan change) */ reload(): void { posthog.reloadFeatureFlags(); this.cache.clear(); } } // Template usage: // <div *appFeatureFlag="'new-dashboard'; else oldDashboard">New UI</div> // <ng-template #oldDashboard>Old UI</ng-template> ``` ## A/B Experiment Setup PostHog Dashboard: **Experiments → New Experiment** 1. **Name** — `"pricing-page-redesign"` 2. **Feature flag key** — `"pricing-page-variant"` 3. **Variants** — control (50%), test-a (25%), test-b (25%) 4. **Goal metric** — `upgrade_click` event 5. **Secondary** — `time_on_page` property 6. **Minimum sample** — 1000 per variant 7. **Significance threshold** — 95% Client implementation: ```typescript const variant = posthog.getFeatureFlag('pricing-page-variant'); // variant: 'control' | 'test-a' | 'test-b' switch (variant) { case 'test-a': showRedesignedPricing(); break; case 'test-b': showMinimalPricing(); break; default: showOriginalPricing(); } // Track conversion (automatically attributed to variant) posthog.capture('upgrade_click', { plan: 'pro', source: 'pricing-page' }); ``` ## Gradual Rollout Pattern PostHog Dashboard: **Feature Flags → New Flag** ### Release conditions 1. **Start** — 5% of users → monitor errors/feedback 2. **Day 2** — 25% → check metrics 3. **Day 5** — 50% → validate at scale 4. **Day 7** — 100% → full release ### Targeting - **By property** — `plan = 'pro'` (beta users first) - **By cohort** — `"internal-team"` → 100%, everyone else → rollout % - **By email** — `*@megabyte.space` → always on (internal testing) ### Rollout automation via API ```typescript async function updateRollout(flagKey: string, percentage: number, env: Env): Promise<void> { await fetch(`https://posthog.megabyte.space/api/projects/${env.POSTHOG_PROJECT_ID}/feature_flags`, { method: 'PATCH', headers: { Authorization: `Bearer ${env.POSTHOG_PERSONAL_API_KEY}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ key: flagKey, filters: { groups: [{ rollout_percentage: percentage }] }, }), }); } ``` ## Kill Switch Pattern Critical flags that disable features instantly. Name convention: `kill-{feature}` (inverted logic). - `kill-payments` → true = payments **DISABLED** - `kill-signups` → true = signups **DISABLED** ```typescript async function checkKillSwitch(feature: string, c: Context): Promise<boolean> { const flags = c.get('featureFlags') as Record<string, boolean>; return flags[`kill-${feature}`] === true; } // In route app.post('/api/checkout', async (c) => { if (await checkKillSwitch('payments', c)) { return c.json({ error: 'Payments temporarily unavailable', code: 'MAINTENANCE' }, 503); } // Normal checkout flow }); ``` PostHog: toggle `kill-payments` to true → instant disable. No deploy needed. Rolls back in <1 second. ## Experiment Analysis Checklist 1. Minimum 100 conversions per variant (not just impressions) 2. Run for full week minimum (captures day-of-week effects) 3. Check for novelty effect (flat after initial spike = novelty) 4. Statistical significance ≥ 95% before calling winner 5. Check segments: does variant win across all plans/devices? 6. Document: hypothesis, variants, metric, result, decision 7. Archive losing variant code. Ship winner. Clean up flag. -
mcp-server-registry.md 15.1 KB
--- name: "MCP Server Registry" description: "Health monitoring + drift detection for Brian's 15+ MCP servers (CF, GitHub, Stripe, Bitwarden, Gmail, Slack, etc.). Registry JSON shape, daily healthcheck Worker, tool-list drift detection, Slack/email alert on degradation, secret rotation reminders." updated: "2026-06-18" always-load: false --- # MCP Server Registry Every MCP server is a dependency. Treat it like one: registry, healthchecks, drift detection, rotation schedule. A silently-broken MCP is worse than a missing one — Claude calls the tool and gets garbage instead of an error. ## Registry Schema Canonical registry lives at `~/.claude/mcp-registry.json`. One object per server: ```jsonc // ~/.claude/mcp-registry.json { "servers": [ { "id": "cloudflare", "name": "Cloudflare Developer Platform", "package": "@cloudflare/mcp-server-cloudflare", "transport": "stdio", "secret_keys": ["CLOUDFLARE_API_TOKEN"], "secret_rotation_days": 90, "tier": "load-bearing", // load-bearing | supporting | experimental "healthcheck_url": null, // null for stdio; URL for SSE/HTTP servers "known_tools": [ // snapshot of advertised tools — drift-checked daily "d1_database_query", "kv_namespace_get", "workers_list", "r2_bucket_create", "search_cloudflare_documentation" ], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy", // healthy | degraded | unreachable | unknown "notes": "CF API token scope: D1, KV, R2, Workers, SSL for SaaS" }, { "id": "github", "name": "GitHub", "package": "@modelcontextprotocol/server-github", "transport": "stdio", "secret_keys": ["GITHUB_TOKEN"], "secret_rotation_days": 365, "tier": "load-bearing", "known_tools": [ "create_pull_request", "get_file_contents", "push_files", "search_code", "list_commits", "create_issue", "merge_pull_request" ], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "stripe", "name": "Stripe", "package": "stripe-mcp", "transport": "stdio", "secret_keys": ["STRIPE_API_KEY"], "secret_rotation_days": 180, "tier": "load-bearing", "known_tools": [ "stripe_api_read", "stripe_api_write", "fetch_stripe_resources", "search_stripe_resources", "create_refund" ], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "bitwarden", "name": "Bitwarden", "package": "@bitwarden/mcp-server", "transport": "stdio", "secret_keys": ["BW_CLIENTID", "BW_CLIENTSECRET", "BW_PASSWORD"], "secret_rotation_days": 180, "tier": "load-bearing", "known_tools": ["list", "get", "create_item", "edit_item", "generate", "status"], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "gmail", "name": "Gmail", "package": "@modelcontextprotocol/server-gmail", "transport": "stdio", "secret_keys": ["GOOGLE_CLIENT_ID", "GOOGLE_CLIENT_SECRET", "GOOGLE_REFRESH_TOKEN"], "secret_rotation_days": 60, // OAuth refresh tokens expire; rotate aggressively "tier": "supporting", "known_tools": ["search_threads", "get_thread", "create_draft", "list_labels"], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "slack", "name": "Slack", "package": "@modelcontextprotocol/server-slack", "transport": "stdio", "secret_keys": ["SLACK_BOT_TOKEN", "SLACK_TEAM_ID"], "secret_rotation_days": 365, "tier": "supporting", "known_tools": [ "slack_send_message", "slack_read_channel", "slack_search_public", "slack_read_user_profile", "slack_search_channels" ], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "posthog", "name": "PostHog", "package": "@posthog/mcp-server", "transport": "stdio", "secret_keys": ["POSTHOG_API_KEY"], "secret_rotation_days": 365, "tier": "supporting", "known_tools": ["exec", "query", "list_insights", "list_feature_flags"], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "neon", "name": "Neon", "package": "@neondatabase/mcp-server-neon", "transport": "stdio", "secret_keys": ["NEON_API_KEY"], "secret_rotation_days": 365, "tier": "supporting", "known_tools": ["run_sql", "create_branch", "list_projects", "describe_table_schema"], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "resend", "name": "Resend", "package": "@resend/mcp-server", "transport": "stdio", "secret_keys": ["RESEND_API_KEY"], "secret_rotation_days": 365, "tier": "supporting", "known_tools": ["send-email", "create-contact", "list-audiences", "get-domain"], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "playwright", "name": "Playwright", "package": "@playwright/mcp", "transport": "stdio", "secret_keys": [], "secret_rotation_days": null, "tier": "load-bearing", "known_tools": [ "browser_navigate", "browser_click", "browser_snapshot", "browser_take_screenshot", "browser_fill", "browser_evaluate", "browser_network_requests" ], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "sentry", "name": "Sentry", "package": "@sentry/mcp-server", "transport": "stdio", "secret_keys": ["SENTRY_AUTH_TOKEN"], "secret_rotation_days": 180, "tier": "supporting", "known_tools": ["search_issues", "get_sentry_resource", "update_issue", "find_organizations"], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" }, { "id": "upstash", "name": "Upstash", "package": "@upstash/mcp-server", "transport": "stdio", "secret_keys": ["UPSTASH_EMAIL", "UPSTASH_API_KEY"], "secret_rotation_days": 365, "tier": "supporting", "known_tools": [ "redis_database_run_redis_commands", "redis_database_list_databases", "qstash_publish_message", "qstash_schedules_list" ], "last_healthy": "2026-06-18T00:00:00Z", "last_checked": "2026-06-18T00:00:00Z", "status": "healthy" } ] } ``` ## Vendor Tiers - **load-bearing** — Claude blocks on these for core dev tasks (CF, GitHub, Playwright, Stripe, Bitwarden). SLA: fix within 4h if broken. Never allow >1 load-bearing server degraded simultaneously. - **supporting** — adds value but work continues without them (Slack, Gmail, PostHog, Neon, Resend). SLA: fix within 48h. - **experimental** — new integrations under evaluation. No SLA. ## Daily Healthcheck Worker Deploy at `workers.megabyte.space/mcp-healthcheck` — runs as a Cron Trigger at `0 7 * * *` (7am UTC): ```typescript // workers/mcp-healthcheck/index.ts import registry from '../../mcp-registry.json' interface McpStatus { id: string; status: 'healthy' | 'degraded' | 'unreachable'; latency_ms: number; error?: string } export default { async scheduled(_event: ScheduledEvent, env: Env, ctx: ExecutionContext) { ctx.waitUntil(runChecks(env)) }, async fetch(req: Request, env: Env) { // GET /status — returns last-known state from KV const state = await env.MCP_HEALTH_KV.get('state', 'json') as Record<string, McpStatus> return Response.json(state ?? {}) } } async function runChecks(env: Env) { const results: McpStatus[] = await Promise.all( registry.servers.map(server => checkServer(server, env)) ) const state = Object.fromEntries(results.map(r => [r.id, r])) await env.MCP_HEALTH_KV.put('state', JSON.stringify(state)) const degraded = results.filter(r => r.status !== 'healthy') if (degraded.length > 0) await sendAlerts(env, degraded) await logToR2(env, results) } async function checkServer(server: typeof registry.servers[0], env: Env): Promise<McpStatus> { const t0 = Date.now() try { if (server.healthcheck_url) { // HTTP/SSE transport — hit the health endpoint const res = await fetch(server.healthcheck_url, { signal: AbortSignal.timeout(5000) }) return { id: server.id, status: res.ok ? 'healthy' : 'degraded', latency_ms: Date.now() - t0, error: res.ok ? undefined : `HTTP ${res.status}` } } // stdio transport — verify package exists in npm registry const res = await fetch(`https://registry.npmjs.org/${server.package}/latest`, { signal: AbortSignal.timeout(5000) }) return { id: server.id, status: res.ok ? 'healthy' : 'degraded', latency_ms: Date.now() - t0, error: res.ok ? undefined : `npm registry: HTTP ${res.status}` } } catch (e) { return { id: server.id, status: 'unreachable', latency_ms: Date.now() - t0, error: String(e) } } } ``` ## Drift Detection Drift = the set of tools a server advertises today differs from `known_tools` in the registry. New tools may unlock capability; removed tools will break prompts. ```typescript // workers/mcp-healthcheck/drift.ts // Run weekly via a separate Cron Trigger: "0 8 * * 1" (Monday 8am UTC) export async function detectDrift(env: Env) { // Fetch live tool manifests by calling each server's list-tools endpoint // For npm-hosted servers: pull README + CHANGELOG from GitHub, parse tool names const drifts: DriftReport[] = [] for (const server of registry.servers) { const liveTools = await fetchLiveToolList(server, env) if (!liveTools) continue const known = new Set(server.known_tools) const live = new Set(liveTools) const added = liveTools.filter(t => !known.has(t)) const removed = server.known_tools.filter(t => !live.has(t)) if (added.length > 0 || removed.length > 0) { drifts.push({ serverId: server.id, added, removed }) } } if (drifts.length > 0) await sendDriftAlerts(env, drifts) return drifts } async function fetchLiveToolList(server: typeof registry.servers[0], env: Env): Promise<string[] | null> { // Heuristic: parse GitHub README for a tools table // Packages follow pattern: github.com/org/mcp-server-${name} try { const pkg = await fetch(`https://registry.npmjs.org/${server.package}/latest`).then(r => r.json<any>()) const repoUrl = pkg.repository?.url?.replace(/^git\+/, '').replace(/\.git$/, '') if (!repoUrl) return null const [, owner, repo] = repoUrl.match(/github\.com\/([^/]+)\/([^/]+)/) ?? [] if (!owner) return null const readme = await fetch( `https://raw.githubusercontent.com/${owner}/${repo}/main/README.md` ).then(r => r.text()) // Extract tool names: lines containing backtick-wrapped identifiers in a tools table return [...readme.matchAll(/`([a-z][a-z0-9_]{2,50})`/g)] .map(m => m[1]) .filter(t => !['bash', 'json', 'yaml', 'typescript'].includes(t)) } catch { return null } } ``` ## Secret Rotation Reminders Check rotation schedule daily; alert 14 days before expiry: ```typescript // workers/mcp-healthcheck/rotation.ts export async function checkRotationDue(env: Env) { const due: RotationAlert[] = [] const now = Date.now() for (const server of registry.servers) { if (!server.secret_rotation_days || !server.last_healthy) continue const lastRotated = new Date(server.last_healthy).getTime() // proxy: use last_healthy const rotationDue = lastRotated + server.secret_rotation_days * 86_400_000 const daysUntilDue = Math.floor((rotationDue - now) / 86_400_000) if (daysUntilDue <= 14) { due.push({ serverId: server.id, secretKeys: server.secret_keys, daysUntilDue, tier: server.tier, }) } } if (due.length > 0) await sendRotationReminders(env, due) } ``` ## Alert Delivery ```typescript async function sendAlerts(env: Env, degraded: McpStatus[]) { const lines = degraded.map(s => `• *${s.id}* (${registry.servers.find(r => r.id === s.id)?.tier}) — ${s.status}: ${s.error ?? 'no error'}` ).join('\n') // Slack await fetch(env.SLACK_WEBHOOK_URL, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ text: `🔴 MCP Health Alert — ${degraded.length} server(s) degraded`, blocks: [ { type: 'section', text: { type: 'mrkdwn', text: `*MCP Server Degradation*\n${lines}` } }, { type: 'section', text: { type: 'mrkdwn', text: `Check: https://workers.megabyte.space/mcp-healthcheck/status` } }, ], }), }) // Email via Resend (load-bearing degradation only) const loadBearingDown = degraded.filter(s => registry.servers.find(r => r.id === s.id)?.tier === 'load-bearing' ) if (loadBearingDown.length > 0) { await fetch('https://api.resend.com/emails', { method: 'POST', headers: { Authorization: `Bearer ${env.RESEND_API_KEY}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ from: 'alerts@megabyte.space', to: 'brian@megabyte.space', subject: `URGENT: Load-bearing MCP servers down (${loadBearingDown.map(s => s.id).join(', ')})`, html: `<pre>${lines}</pre>`, }), }) } } ``` ## R2 Audit Log ```typescript async function logToR2(env: Env, results: McpStatus[]) { const date = new Date().toISOString().slice(0, 10) await env.MCP_AUDIT_R2.put( `mcp-health/${date}.ndjson`, results.map(r => JSON.stringify({ ...r, ts: new Date().toISOString() })).join('\n'), { httpMetadata: { contentType: 'application/x-ndjson' } } ) } ``` ## wrangler.toml for Healthcheck Worker ```toml name = "mcp-healthcheck" main = "workers/mcp-healthcheck/index.ts" compatibility_date = "2025-03-01" [[kv_namespaces]] binding = "MCP_HEALTH_KV" id = "YOUR_KV_ID" [[r2_buckets]] binding = "MCP_AUDIT_R2" bucket_name = "mcp-audit-logs" [vars] SLACK_WEBHOOK_URL = "https://hooks.slack.com/services/..." [[triggers.crons]] crons = ["0 7 * * *", "0 8 * * 1"] # daily health, weekly drift ``` ## Updating the Registry When you add a new MCP server or a server's tool list changes: 1. Run `npx @modelcontextprotocol/inspector <server-cmd>` to get the live tool list. 2. Update `known_tools` in `mcp-registry.json`. 3. Bump `last_healthy` to today. 4. Commit — the registry is source-of-truth, not a generated artifact. ## See - `secret-auto-provisioning` — `get-secret` + Bitwarden integration - `secret-provisioning` — rotation cadence by secret class - `drift-detection` — broader drift detection doctrine - `cloudflare-lock-in-is-leverage` — why health Worker lives on CF - `workers-tracing-otlp` — add OTLP traces to healthcheck runs -
sentry-alert-rules.md 8.8 KB
--- name: "Sentry Alert Rules" description: "Auto-configure Sentry alert rules at first deploy. Error spike detection, new issue alerts, unhandled rejection tracking, P95 latency monitoring, Slack integration, per-project rules, and deploy silence windows. All via Sentry API." updated: "2026-04-23" --- # Sentry Alert Rules Every deployed project gets four alert rules automatically at first deploy. Per-project (not global) — each domain gets its own rules. All alerts route to Slack `#emdash-alerts`. ## Alert Rule Set ### 1. Error Spike (Critical) Fires when error rate exceeds 5x the normal baseline within a 5-minute window. ```json { "name": "[{project}] Error Spike", "actionMatch": "all", "filterMatch": "all", "frequency": 300, "conditions": [{ "id": "sentry.rules.conditions.event_frequency.EventFrequencyPercentCondition", "interval": "5m", "value": 500, "comparisonType": "percent", "comparisonInterval": "1h" }], "actions": [{ "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "workspace": "{slack_workspace_id}", "channel": "#emdash-alerts", "tags": "environment,release,url" }], "environment": "production" } ``` ### 2. New Issue (Low Priority) First occurrence of any new error. Grouped so repeat triggers don't spam. ```json { "name": "[{project}] New Issue", "actionMatch": "all", "filterMatch": "all", "frequency": 1800, "conditions": [{ "id": "sentry.rules.conditions.first_seen_event.FirstSeenEventCondition" }], "filters": [{ "id": "sentry.rules.filters.level.LevelFilter", "match": "gte", "level": "warning" }], "actions": [{ "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "workspace": "{slack_workspace_id}", "channel": "#emdash-alerts", "tags": "environment,browser,os" }], "environment": "production" } ``` ### 3. Unhandled Rejection (High Priority) Any unhandled promise rejection or uncaught exception. Immediate notification. ```json { "name": "[{project}] Unhandled Rejection", "actionMatch": "all", "filterMatch": "all", "frequency": 60, "conditions": [{ "id": "sentry.rules.conditions.every_event.EveryEventCondition" }], "filters": [{ "id": "sentry.rules.filters.tagged_event.TaggedEventFilter", "key": "mechanism", "match": "eq", "value": "unhandledrejection" }], "actions": [{ "id": "sentry.integrations.slack.notify_action.SlackNotifyServiceAction", "workspace": "{slack_workspace_id}", "channel": "#emdash-alerts", "tags": "environment,url,release" }], "environment": "production" } ``` ### 4. P95 Latency (Warning) P95 response time exceeds 500ms for 10 consecutive minutes. ```json { "name": "[{project}] P95 Latency", "dataset": "transactions", "aggregate": "p95(transaction.duration)", "query": "", "timeWindow": 10, "triggers": [{ "label": "critical", "alertThreshold": 500, "actions": [{ "type": "slack", "targetIdentifier": "#emdash-alerts", "integrationId": "{slack_integration_id}" }] }, { "label": "resolved", "alertThreshold": 300, "actions": [{ "type": "slack", "targetIdentifier": "#emdash-alerts", "integrationId": "{slack_integration_id}" }] }], "environment": "production" } ``` ## API Configuration All rules created via Sentry API. Base URL: `https://sentry.megabyte.space` (self-hosted). ### Create Issue Alert ```bash curl -X POST "https://sentry.megabyte.space/api/0/projects/{org}/{project}/rules/" \ -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{...rule JSON...}' ``` ### Create Metric Alert (P95 Latency) ```bash curl -X POST "https://sentry.megabyte.space/api/0/projects/{org}/{project}/alert-rules/" \ -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" \ -H "Content-Type: application/json" \ -d '{...metric alert JSON...}' ``` Issue alerts → `/rules/`. Metric alerts → `/alert-rules/`. Different endpoints. ## Slack Integration Setup ```bash # 1. Get integration ID (one-time, reuse across projects) curl "https://sentry.megabyte.space/api/0/organizations/{org}/integrations/?provider_key=slack" \ -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" # 2. Extract integrationId and workspace ID from response # 3. Use in alert rule actions ``` Slack app must be installed in workspace first via **Sentry Settings → Integrations → Slack**. ## Orchestration Function ```typescript async function configureSentryAlerts(project: string, org: string = 'emdash'): Promise<void> { const baseUrl = 'https://sentry.megabyte.space/api/0'; const headers = { 'Authorization': `Bearer ${env.SENTRY_AUTH_TOKEN}`, 'Content-Type': 'application/json', }; // Get Slack integration ID (cached after first call) const integrations = await fetch(`${baseUrl}/organizations/${org}/integrations/?provider_key=slack`, { headers }); const slackIntegration = (await integrations.json())[0]; const slackId = slackIntegration.id; const workspaceId = slackIntegration.externalId; // Create all 4 rules in parallel const issueRulesUrl = `${baseUrl}/projects/${org}/${project}/rules/`; const metricRulesUrl = `${baseUrl}/projects/${org}/${project}/alert-rules/`; await Promise.all([ fetch(issueRulesUrl, { method: 'POST', headers, body: JSON.stringify(errorSpikeRule(project, workspaceId)) }), fetch(issueRulesUrl, { method: 'POST', headers, body: JSON.stringify(newIssueRule(project, workspaceId)) }), fetch(issueRulesUrl, { method: 'POST', headers, body: JSON.stringify(unhandledRejectionRule(project, workspaceId)) }), fetch(metricRulesUrl, { method: 'POST', headers, body: JSON.stringify(p95LatencyRule(project, slackId)) }), ]); } ``` ## Deploy Silence Windows Suppress alerts during deploys to avoid false positives from cold starts and transient errors. ```typescript async function silenceDuringDeploy(project: string, durationMinutes: number = 10): Promise<string> { const baseUrl = 'https://sentry.megabyte.space/api/0'; const now = new Date(); const until = new Date(now.getTime() + durationMinutes * 60000); // Mute all alert rules for the project during deploy window const rules = await fetch(`${baseUrl}/projects/emdash/${project}/rules/`, { headers: { 'Authorization': `Bearer ${env.SENTRY_AUTH_TOKEN}` }, }); const ruleList = await rules.json(); for (const rule of ruleList) { await fetch(`${baseUrl}/projects/emdash/${project}/rules/${rule.id}/snooze/`, { method: 'POST', headers: { 'Authorization': `Bearer ${env.SENTRY_AUTH_TOKEN}`, 'Content-Type': 'application/json' }, body: JSON.stringify({ target: 'everyone', until: until.toISOString() }), }); } return until.toISOString(); } ``` ### Deploy flow integration 1. `silenceDuringDeploy(project, 10)` — mute alerts 2. `wrangler deploy` — deploy Worker 3. Purge CDN cache — clear stale responses 4. E2E tests on prod — verify 5. Alerts auto-unmute after window — resume monitoring ## Per-Project Isolation Every project gets its own alert rule set. Never use organization-wide alert rules — they cause noise from unrelated projects. **Naming convention** — `[{project}] {Alert Name}` — square brackets make filtering easy in Slack. When a project is decommissioned, delete its alert rules: ```bash # List and delete all rules for a project curl "https://sentry.megabyte.space/api/0/projects/{org}/{project}/rules/" \ -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" | \ jq '.[].id' | xargs -I{} curl -X DELETE \ "https://sentry.megabyte.space/api/0/projects/{org}/{project}/rules/{}/" \ -H "Authorization: Bearer $SENTRY_AUTH_TOKEN" ``` ## Sentry SDK Setup (v10.50+ — Hono + Bun) ```typescript // src/instrument.ts — dedicated @sentry/hono/bun entry point (Apr 2026) import * as Sentry from '@sentry/hono/bun'; Sentry.init({ dsn: 'https://KEY@sentry.megabyte.space/ID', environment: 'production', tracesSampleRate: 0.1, replaysOnErrorSampleRate: 1.0, release: env.VERSION, integrations: [ Sentry.viewHierarchyIntegration(), // v10.50: DOM capture on error ], }); // Apply to Hono app import { sentryMiddleware } from '@sentry/hono'; app.use('*', sentryMiddleware()); ``` ### DO Alarm Tracing (v10.49) ```typescript // Durable Object alarms auto-traced by Sentry v10.49+ // No extra config — alarm() calls get spans + error capture ``` ### WorkerEntrypoint Support (v10.48) ```typescript // Service bindings auto-instrumented via WorkerEntrypoint class // import { WorkerEntrypoint } from 'cloudflare:workers'; // Sentry traces cross-worker calls automatically ``` Minimum version: `@sentry/hono@10.50.0` + `@sentry/bun@10.50.0`. Replaces `toucan-js` for new projects. ## Integration with 08-deploy Skill After every first deploy, auto-run `configureSentryAlerts(projectSlug)`. The deploy skill checks if alert rules exist — if not, creates them. Subsequent deploys only trigger silence windows. -
SKILL.md 6.5 KB
--- name: "observability-and-growth" description: "Full instrumentation from day one. PostHog consolidates product analytics + feature flags + error tracking (one platform, one bill). GA4 via GTM (14-step automation, custom dimensions over events, server-side tagging). Sentry (deep error tracking + performance). Stripe (webhook-first with idempotent processing). Listmonk on Coolify (newsletters via Amazon SES SMTP relay). PLG 7-layer framework. Programmatic SEO (5 page types). Incident auto-remediation via Sentry→Inngest pipeline. AI search (GEO) awareness. Local business conversions (phone_click, direction_click, form_submit, booking_click) with CRO patterns for both SaaS and local." metadata: version: "2.1.0" updated: "2026-05-03" effort: "high" model: "sonnet" license: "Rutgers" compatibility: claude-code: ">=2.0.0" agentskills: ">=1.0.0" submodules: - analytics-configuration.md - conversion-optimization.md - email-marketing-and-listmonk.md - feature-flags-and-experiments.md - sentry-alert-rules.md - square-payments.md - stripe-billing.md - user-feedback-collection.md - workers-tracing-otlp.md priority: 2 pack: "backend" stage: stable triggers: - "posthog" - "sentry" - "analytics" - "stripe" - "growth" paths: - "concern:observability" --- # 13 — Observability and Growth Tiered PostHog+Sentry+GA4 stacks wired from day one; feature flags, PLG patterns, incident auto-remediation. ## Instrumentation tiers Per `_kernel/standards.md#integrations`: - **Tier 1 (solo)** — PostHog + Workers Tracing OTLP (2 vendors max, cookie-free, free tier covers <10k MAU) - **Tier 2 (enterprise)** — + Sentry @sentry/cloudflare v9 + GA4/GTM + Axiom - **Tier 3 (LLM-heavy >10k calls/mo)** — + AI Gateway ## PostHog (Tier 1 cornerstone) - Snippet on every HTML page w/ `persistence:'memory'` (cookie-free); `capture_pageview` + `capture_pageleave` + `autocapture:true` - Unified platform: product analytics + feature flags + session replay + error tracking - CSP: `script-src` + `connect-src` for posthog domain - Per-feature event naming: `<feature>:<action>` (`signup:complete`, `editor:save`, `share:copy`) ## Sentry (Tier 2) - `@sentry/cloudflare` v9 + `withSentry` wrapper; project via `mcp__sentry__create_project` (org:`megabyte-labs`) - `SENTRY_DSN` via `wrangler secret put` - Pattern: `withSentry(env => ({ dsn, tracesSampleRate: 1.0, sendDefaultPii: false }), worker)` - Breadcrumbs before risky ops; capture exception w/ context tags (`worker` | `route` | `userId`) - Release tracking via `SENTRY_RELEASE` env; Workers Tracing handles I/O spans ## Workers Tracing (Tier 1 + 2) / GA4 + GTM (Tier 2) / AI Gateway (Tier 3) - Workers Tracing: `[observability] enabled = true` in `wrangler.jsonc` — zero-config OTel I/O tracing; export to Axiom, Honeycomb, Grafana, Datadog via `@opentelemetry/exporter-trace-otlp-http` - GA4 + GTM: container snippet (head script + noscript iframe); CSP: `googletagmanager.com` + `google-analytics.com` + `analytics.google.com` + `region1.google-analytics.com`; server-side tagging for EU; custom dimensions over custom events - AI Gateway: `env.AI.run()` auto-routes; direct Anthropic: `https://gateway.ai.cloudflare.com/v1/{account}/{gateway}/anthropic/v1/messages`; caching + rate-limit + fallback + per-call logging ## Stripe (SaaS billing only — per `rules/payments-routing.md`) - Webhook-first w/ idempotent processing (D1 dedupe table `payment_events(event_id, source, processed_at)` UNIQUE) - `Stripe-Signature` HMAC + 5-min replay window - Mint products + prices via MCP (idempotent via `lookup_key`); subscription state machine in D1 - `STRIPE_WEBHOOK_SECRET` via `POST /v1/webhook_endpoints` ## Square (accept-money default — per `rules/payments-routing.md`) - Square Web Payments SDK card form + Apple Pay + Google Pay + Cash App Pay - `Square-Signature` HMAC-SHA256 w/ 6-hr replay window; `idempotency_key` UUID per request (24-hr dedupe) - Nonprofit verified-501c3 discount (2.6%+10¢ vs 3.5%+15¢) ## Listmonk (newsletter — self-hosted on Coolify) - Amazon SES SMTP relay (`LISTMONK_FROM_EMAIL`); `listmonkSendTx(env, { templateAlias, ... })` via KV-cached alias→id map - Templates in `emails/*.html` synced via `scripts/listmonk-sync.mjs` - Auth: `Authorization: token <user>:<key>` (Listmonk 3.x API-user pattern) ## PLG 7-Layer Framework 1. **Discovery** — SEO + AI search + word-of-mouth + paid 2. **Sign-up** — passwordless preferred (Clerk M2M JWT) 3. **Activation** — first-value-in-X-min metric (aha moment per `rules/feature-flags.md` instrumentation) 4. **Engagement** — DAU/MAU ratio, session depth 5. **Retention** — D1/D7/D30 cohort 6. **Revenue** — upgrade trigger, expansion 7. **Referral** — viral coefficient, two-sided rewards ## Programmatic SEO (5 page types) - `/integrations/{tool}` · `/compare/{a}-vs-{b}` · `/for/{audience}` · `/templates/{type}` · `/{city}-{service}` - Each: unique H1 + meta desc + 800+ unique words + 1 unique image + 3+ internal links + 1+ outbound citation. Cap 200 pages per axis. - Per `rules/copy-writing.md` § pSEO + `rules/thin-source-amplification.md`. ## GEO / AI search - Quotable answer blocks 40-60 words (LLM citation magnet); FAQPage schema highest AI-citation rate (ChatGPT / Perplexity / Google AI Overviews) - JSON-LD facts MUST also appear as visible HTML body text; lead paragraphs answer query in <40 words - EEAT: author bio + `Person` schema + `sameAs` + dated revision + ownership statement - `llms.txt` at site root (DX-only, <0.3% adoption — not build gate) ## Local-business conversions + CRO Track per `local-conversions.md`: `phone_click` (`tel:`) · `direction_click` (Google Maps) · `form_submit` · `booking_click` (Calendly/Cal.com) · `chat_click` · `review_click` (Google Business/Yelp). Each fires PostHog + Sentry breadcrumb + (Tier 2) GA4 conversion event. CRO: sticky CTA bar on mobile · scroll-progress bar (subtle) · exit-intent modal (cart/pricing only, not blogs) · social proof near every CTA · urgency without dark patterns ("3 spots left this week" if true) · trust strip above fold · single primary CTA per surface. ## Incident auto-remediation Sentry → Inngest pipeline: (1) Sentry webhook on `event.alert.triggered` → (2) Inngest dispatches `incident-responder` agent → (3) agent reads event, traces to source file, proposes fix, opens PR via gh MCP → (4) PR auto-merges if all CI gates pass per `rules/ai-seniority.md`. ## See submodules: posthog, ga4-gtm, sentry, stripe-billing, square-payments, listmonk, plg-framework, programmatic-seo, incident-remediation, geo-ai-search, local-conversions. -
square-payments.md 13.2 KB
--- name: "Square Payments" description: "Default payment processor for nonprofits, donations, one-time charges, in-person POS, and small-business e-commerce. Square Web Payments SDK (card + Apple Pay + Cash App Pay), Payments API for server-side charges, idempotency-keyed REST. Cheaper effective rates than Stripe for sub-$100 donations and nonprofit-discount accounts. Use Stripe instead when SaaS subscriptions / metered usage / Entitlements / Connect marketplaces are involved." updated: "2026-05-10" --- # Square Payments ## When Square is the Default (***FIRST DECISION — EVERY PAYMENT FEATURE***) ### Square defaults - Nonprofits + donations (one-time OR recurring via Square Subscriptions) - Small-business e-commerce - Restaurant / retail / salon / medical with in-person POS need - Event ticketing - Sub-$100 average ticket where Stripe's $0.30 fixed is punishing - Any client already on Square POS (single source of truth for in-person + online) - NJ/NY local-business rebuilds where Square Retail penetration is high ### Stripe overrides - SaaS subscriptions with seat-based / tiered / usage-based billing - Metered billing (Stripe Billing Meters API) - Entitlements feature gating - Stripe Connect (marketplaces, split payments) - Stripe Tax (40+ country auto-collection) - Stripe Atlas formation flows - Embedded checkout for agentic commerce (ACP launch partners) - Enterprise contracts requiring net-30 invoicing If ANY of these apply → Stripe; else → Square. Hybrid is allowed: Square for donations + Stripe for SaaS subscription on the same site. Each integration owns its own webhook + DB tables — never cross-pollinate idempotency keys. ## Default Pricing Models ### Nonprofit / Donation (Square default) - **$10** — "Feeds a family for a day" - **$25** — "Covers supplies for a week" - **$50** — "Powers the hotline for a month" - **$100** — "Sponsors a full program" - **$250** — "Transforms a life" - **$500** — "Makes a lasting impact" - **Custom** — "Choose your amount" - One-time as default (Square Subscriptions adds setup complexity) - Monthly recurring opt-in via Square Subscriptions API + Plans — only when client has staff to manage - Impact labels mandatory — specific + concrete, never vague - Progress bar showing goal achievement (optional, requires Durable Object aggregation) ### Square Nonprofit Discount 501(c)(3) orgs qualify for **2.2% + $0.30** vs standard **2.6% + $0.10**. Apply at `squareup.com/help/us/en/article/6358` with EIN + IRS determination letter. For $25 average donation: nonprofit fee = $0.85 vs Stripe $1.03 (~10% per-transaction savings). ## Web Payments SDK (Browser — Tokenize Card Client-Side) ```html <!-- production --> <script src="https://web.squarecdn.com/v1/square.js"></script> <!-- sandbox --> <script src="https://sandbox.web.squarecdn.com/v1/square.js"></script> ``` ```typescript const payments = window.Square.payments(applicationId, locationId); const card = await payments.card(); await card.attach('#square-card-container'); // On submit: const { token, status } = await card.tokenize(); if (status !== 'OK') throw new Error('Tokenization failed'); // POST token + amount to your worker — NEVER charge from the browser await fetch('/api/donate', { method: 'POST', body: JSON.stringify({ sourceId: token, amountCents: 5000 }) }); ``` Optional payment methods: - `payments.applePay(paymentRequest)` - `payments.cashAppPay(paymentRequest, { redirectURL })` - `payments.googlePay(paymentRequest)` - `payments.ach({ accountHolderName, plaidLinkToken })` ## Server-Side Charge (Cloudflare Worker — vanilla fetch) ```typescript const SQUARE_API_VERSION = '2024-12-18'; function squareApiBase(env: Env) { return env.SQUARE_ENVIRONMENT === 'production' ? 'https://connect.squareup.com' : 'https://connect.squareupsandbox.com'; } async function chargeOnce(env: Env, sourceId: string, amountCents: number, email: string, note: string) { const res = await fetch(`${squareApiBase(env)}/v2/payments`, { method: 'POST', headers: { 'Square-Version': SQUARE_API_VERSION, 'Authorization': `Bearer ${env.SQUARE_ACCESS_TOKEN}`, 'Content-Type': 'application/json', }, body: JSON.stringify({ source_id: sourceId, idempotency_key: crypto.randomUUID(), amount_money: { amount: amountCents, currency: 'USD' }, location_id: env.SQUARE_LOCATION_ID, autocomplete: true, buyer_email_address: email, note, reference_id: `donation-${Date.now()}`, }), }); const body = await res.json(); if (!res.ok) throw new Error(body.errors?.[0]?.detail ?? 'Square charge failed'); return { paymentId: body.payment.id, receiptUrl: body.payment.receipt_url }; } ``` Every charge MUST include a unique `idempotency_key` (UUID) — retrying with the same key returns the original payment, never double-charges. ## Webhook Handler (Cloudflare Worker) ```typescript // Verify signature via X-Square-HmacSha256-Signature header async function verifySquareSignature(req: Request, env: Env): Promise<boolean> { const sig = req.headers.get('x-square-hmacsha256-signature'); if (!sig) return false; const body = await req.text(); const url = new URL(req.url).toString(); const key = await crypto.subtle.importKey( 'raw', new TextEncoder().encode(env.SQUARE_WEBHOOK_SIGNATURE_KEY), { name: 'HMAC', hash: 'SHA-256' }, false, ['sign'] ); const computed = await crypto.subtle.sign('HMAC', key, new TextEncoder().encode(url + body)); const computedB64 = btoa(String.fromCharCode(...new Uint8Array(computed))); return computedB64 === sig; } app.post('/api/webhooks/square', async (req, env) => { if (!(await verifySquareSignature(req, env))) return new Response('Forbidden', { status: 403 }); const event = await req.json(); switch (event.type) { case 'payment.created': /* record + send receipt via Resend */ break; case 'payment.updated': /* status changed APPROVED → COMPLETED */ break; case 'refund.created': /* notify donor + update DB */ break; case 'subscription.created': /* recurring donor activated */ break; case 'subscription.canceled': /* retention email */ break; } return new Response('OK'); }); ``` ## Subscriptions (Recurring Donations) Square Subscriptions requires a Plan + Plan Variation first. Two-step setup: ```typescript // 1. Create catalog plan (once, via wrangler or admin tool) await fetch(`${apiBase}/v2/catalog/object`, { method: 'POST', headers, body: JSON.stringify({ idempotency_key: crypto.randomUUID(), object: { type: 'SUBSCRIPTION_PLAN', id: '#monthly-25', subscription_plan_data: { name: 'Monthly $25 Donor' }, }, }), }); // 2. Create subscription (per donor) await fetch(`${apiBase}/v2/subscriptions`, { method: 'POST', headers, body: JSON.stringify({ idempotency_key: crypto.randomUUID(), location_id: env.SQUARE_LOCATION_ID, plan_variation_id: 'plan_variation_id_from_step_1', customer_id, // create via /v2/customers first card_id, // saved card token from cards.create }), }); ``` For small nonprofits without staff to manage Plans, default to one-time charges + "donate again" CTA in receipt email. ## Environment Toggle - `SQUARE_ACCESS_TOKEN` — sandbox or production - `SQUARE_LOCATION_ID` — receiving location - `SQUARE_APPLICATION_ID` — Web Payments SDK app ID - `SQUARE_ENVIRONMENT` — `"sandbox"` (default) or `"production"` - `SQUARE_WEBHOOK_SIGNATURE_KEY` — for webhook HMAC verification Sandbox creds: `developer.squareup.com → Sandbox`; production: `developer.squareup.com → Production` (requires verified business). Test card: `4111 1111 1111 1111` / any future date / any CVV / any ZIP. ## CSP Allowances (every site using Square Web Payments SDK) ``` script-src 'self' https://web.squarecdn.com https://sandbox.web.squarecdn.com 'unsafe-inline' style-src 'self' https://web.squarecdn.com https://sandbox.web.squarecdn.com 'unsafe-inline' frame-src https://*.squarecdn.com https://*.squareup.com connect-src 'self' https://*.squarecdn.com https://*.squareup.com https://*.squareupsandbox.com https://pci-connect.squareup.com https://pci-connect.squareupsandbox.com img-src 'self' https://*.squarecdn.com https://*.squareup.com data: ``` ## Donation Page Design (givedirectly.org energy) - Hero with cause story + impact stat counter (rAF roll-in, IntersectionObserver-gated) - Amount selector grid (6 presets + custom input) - Frequency toggle (one-time | monthly) — default one-time unless client has subscription infrastructure - Donor form (name, email, optional address for tax receipt) - Tribute-gift section (optional collapsed accordion: "in honor of" / "in memory of") - Square card container `<div id="square-card-container">` - Apple Pay / Cash App Pay buttons above card form (when enabled) - Trust badge: "Square Secured · PCI-DSS Level 1 · 256-bit TLS" - Turnstile invisible widget ### Post-Donation Flow 1. Success page with confetti (06/easter-eggs) + animated impact statement 2. Receipt email via Resend: thank you + tax-deductible language + Square receipt URL + "donate again" CTA 3. PostHog event: `donation_complete` with `{ amountCents, frequency, paymentMethod }` 4. Sentry breadcrumb: `donation.completed` with `paymentId` tag 5. GA4 event: `purchase` with full e-commerce schema ## Auto-Detect Square Account - `GET /v2/locations`: single location → auto-use as `SQUARE_LOCATION_ID`; multi-location → ask which is receiving; no account → prompt signup at `squareup.com/signup` ## Square API Coverage - **Payments** — `/v2/payments` — one-time charges - **Customers** — `/v2/customers` — donor records, repeat-donor lookup - **Cards (on file)** — `/v2/cards` — save card for recurring without storing CC - **Subscriptions** — `/v2/subscriptions` — recurring donations (requires Plans) - **Catalog** — `/v2/catalog/object` — Plan + Plan Variation setup - **Refunds** — `/v2/refunds` — donor-requested refund within 60 days - **Disputes** — `/v2/disputes` — chargeback handling - **Locations** — `/v2/locations` — multi-location accounts - **Orders** — `/v2/orders` — e-commerce line items (when not donation) - **Inventory** — `/v2/inventory` — retail-tier sites only - **Loyalty** — `/v2/loyalty` — repeat-donor rewards (rare for nonprofits) - **Gift Cards** — `/v2/gift-cards` — holiday gift-of-meals campaigns ## Square vs Stripe Decision Cheat Sheet - **Nonprofit donation page** → **Square** (nonprofit-rate discount, simpler UX) - **Small biz one-time checkout** → **Square** (cheaper for sub-$100 tickets) - **Restaurant / salon / retail with POS** → **Square** (single source of truth for in-person + online) - **SaaS subscriptions with seats/tiers** → **Stripe** (Billing API, Entitlements, prorations) - **Metered/usage-based AI billing** → **Stripe** (Billing Meters API, Metronome) - **Marketplace with split payments** → **Stripe Connect** - **Embedded checkout for AI agents** → **Stripe** (ACP launch partner with OpenAI/Perplexity) - **Multi-country tax automation** → **Stripe Tax** - **Net-30 enterprise invoicing** → **Stripe** (Invoicing API) - **Donor wants to use Cash App** → **Square** (native Cash App Pay) - **Recurring donations, no subscription complexity** → **Square** one-time + "donate again" email - **Recurring donations, full retention machine** → either — Stripe Customer Portal is more polished, Square Subscriptions is cheaper ## Conversion Optimization (Research-Backed) - Donation page conversion median: 17% (NextAfter 2024); top 10%: 30%+ - Suggested amount with social proof lifts AOV 12%: `"$25 — most common gift this month"` - One-time default for new donors converts higher than monthly (lowers commitment friction) - Cash App Pay adds 8-15% to total donations on Gen Z / Millennial audiences - Apple Pay reduces mobile abandonment 30-40% (Source: Apple Pay merchant data) - Inline card form (Square Web Payments SDK) converts higher than redirect (Square Checkout) - Show `"Powered by Square"` security badge; progress indicator for multi-step forms - Guest checkout default; show total prominently before final submit ## Receipt Email Template (Resend) ``` Subject: Thank you, {{firstName}} — your donation to {{orgName}} confirmed Hi {{firstName}}, We received your gift of {{amountFormatted}} on {{date}}. {{#if tribute}} This gift was made in {{tributeType}} of {{tributeName}}. {{/if}} Your contribution is tax-deductible. {{orgName}} is a 501(c)(3) nonprofit organization, EIN {{ein}}. Official Square receipt: {{squareReceiptUrl}} Print-friendly version: {{printableReceiptUrl}} {{#if recurring}} Manage your recurring donation: {{donorPortalUrl}} {{else}} Make another gift: {{donatePageUrl}} {{/if}} With gratitude, {{orgName}} Team {{orgAddress}} ``` ## Ownership - **Owns** — Square Web Payments SDK integration, Payments API (server-side charge), Subscriptions for recurring donations, webhook signature verification, idempotency-key patterns, nonprofit-rate enrollment, Square POS sync for hybrid online + in-person clients, Cash App Pay + Apple Pay enablement - **Never owns** — SaaS subscriptions / metered billing / Entitlements (→ 13/stripe-billing), email send (→ 09/email-templates), donor analytics (→ 13/analytics-configuration), goal-progress aggregation (→ 05/heartbeat-polling + Durable Objects) -
stripe-billing.md 9.9 KB
--- name: "Stripe Billing" description: "SaaS subscriptions + enterprise billing default. Tiered pricing, seat-based billing, Stripe Tax, Billing Meters (metered usage), Entitlements (feature gating), Connect (marketplaces), Atlas, Agentic Commerce (ACP). Use Stripe ONLY when SaaS subscriptions / metered usage / Entitlements / Connect marketplaces / enterprise invoicing are involved — for nonprofits, donations, and small-business one-time charges use 13/square-payments instead." updated: "2026-05-10" --- > **Routing rule (***FIRST DECISION***):** If the feature is nonprofit donations, small-business one-time checkout, restaurant/retail POS, or sub-$100 average ticket → use [13/square-payments](square-payments.md). Stripe is reserved for SaaS subscriptions, metered usage, Entitlements feature gating, Connect marketplaces, and enterprise invoicing. Hybrid is allowed (Square for donations + Stripe for SaaS subs on same site). # Stripe Billing ## Default Pricing Models ### SaaS Products - **Free** — $0/mo — limited features, no payment required - **Pro** — $50/mo — all features, priority support, cancel anytime - **Enterprise** — custom — SSO, SLA, dedicated support, volume pricing **Seat-Based Billing** — `quantity` on subscription items: ```typescript stripe.subscriptions.update(subId, { items: [{ id: itemId, quantity: seatCount }] }) ``` - Auto-adjust on member add/remove via Clerk org webhook; prorate mid-cycle - Display: `"$12/seat/mo × 8 seats = $96/mo"` **Stripe Tax** — automatic tax calculation: ```typescript stripe.subscriptions.create({ automatic_tax: { enabled: true } }) ``` - Requires `tax_behavior: 'exclusive'|'inclusive'` on each Price - Register jurisdictions: `stripe.tax.registrations.create({ country, state, type })` - Display tax line on invoices; no tax on $0 free tiers - Annual billing default (20% discount) — $480/year vs $600 - Stripe Link enabled for one-click checkout (7%+ conversion lift) - Show monthly price on annual plan: `"$40/mo billed annually"` ### Nonprofit / Donation - **$10** — "Feeds a family for a day" - **$25** — "Covers supplies for a week" - **$50** — "Powers the hotline for a month" - **$100** — "Sponsors a full program" - **$250** — "Transforms a life" - **$500** — "Makes a lasting impact" - **Custom** — "Choose your amount" - Monthly recurring as default; one-time as option - Impact labels on every amount — specific, concrete, not vague - Progress bar showing goal achievement ## Auto-Setup Workflow ### 1. Create Stripe Product + Prices ```typescript const stripe = new Stripe(env.STRIPE_SECRET_KEY); // Create product const product = await stripe.products.create({ name: 'Project Name Pro', description: 'All features, priority support', metadata: { project: 'domain.com' }, }); // Create prices const monthlyPrice = await stripe.prices.create({ product: product.id, unit_amount: 5000, // $50.00 currency: 'usd', recurring: { interval: 'month' }, }); const annualPrice = await stripe.prices.create({ product: product.id, unit_amount: 48000, // $480.00 (20% off) currency: 'usd', recurring: { interval: 'year' }, }); ``` ### 2. Create Checkout Endpoint ```typescript app.post('/api/checkout', async (c) => { const { priceId, successUrl, cancelUrl } = await c.req.json(); const session = await stripe.checkout.sessions.create({ mode: 'subscription', // or 'payment' for one-time payment_method_types: ['card', 'link'], // Stripe Link for one-click line_items: [{ price: priceId, quantity: 1 }], success_url: successUrl || `${c.req.url.origin}/success?session_id={CHECKOUT_SESSION_ID}`, cancel_url: cancelUrl || `${c.req.url.origin}/pricing`, allow_promotion_codes: true, billing_address_collection: 'auto', customer_creation: 'always', }); return c.json({ url: session.url }); }); ``` ### 3. Brand the Checkout ```typescript // Set Stripe account branding to match site await stripe.accounts.update('acct_...', { settings: { branding: { icon: 'https://domain.com/favicon-32x32.png', logo: 'https://domain.com/logo.png', primary_color: '#00E5FF', secondary_color: '#060610', } } }); ``` ### 4. Webhook Handler ```typescript app.post('/api/webhooks/stripe', async (c) => { const sig = c.req.header('stripe-signature')!; const body = await c.req.text(); const event = stripe.webhooks.constructEvent(body, sig, env.STRIPE_WEBHOOK_SECRET); switch (event.type) { case 'checkout.session.completed': // Activate subscription, send welcome email via Resend break; case 'customer.subscription.deleted': // Deactivate subscription, send retention email break; case 'invoice.payment_failed': // Notify user, retry logic break; } return c.json({ received: true }); }); ``` ## Donation Page Design (givedirectly.org style) - Full-screen split: left 60% cause imagery + impact story + real numbers; right 40% amount selector + checkout form + progress bar ### Donation Goal + Progress Bar ```typescript // Real-time via Durable Objects + Stripe webhooks app.get('/api/donation-progress', async (c) => { const goal = 10000; // $10,000 goal const raised = await getDonationTotal(c.env.DO); // from Durable Object return c.json({ goal, raised, percentage: Math.min(100, (raised / goal) * 100) }); }); ``` ### Post-Donation Flow 1. Success page with confetti animation (06/easter-eggs energy) 2. Auto-email via Resend: thank you + tax receipt + ask to share 3. Auto-email all participants when goal is met 4. PostHog event: `donation_complete` with amount and method ## Conversion Optimization (Research-Backed) - 3 tiers maximum (paradox of choice — 3 converts better than 5) - Highlight middle tier as "Most Popular" (anchoring effect) - Show annual savings prominently (`"Save $120/year"`) - Include FAQ below pricing; feature comparison table - Trust signals near CTA: `"Trusted by X users"`, security badge - Stripe Link reduces checkout time 7× (one-click for returning users) - Show `"Powered by Stripe"` security badge near payment form - Guest checkout default; show total prominently before final submit ## Psychology of Pricing (04/wisdom-and-human-psychology) - **Anchoring (Kahneman)** — show annual price first; $480/year makes $50/month feel reasonable - **Loss Aversion (Kahneman & Tversky)** — `"Don't lose your progress"` > `"Keep your progress"` - **Reciprocity (Cialdini)** — generous free tier creates internal drive to convert or refer - **Social Proof (Cialdini)** — `"Join 1,200 supporters"` near every pricing CTA - **Peak-End Rule (Kahneman)** — confetti + warm email + later impact update = memory of joy ### The Ethical Line - Real scarcity only (actual limited spots, actual deadline) - No confirmshaming (`"No, I don't want to help"` = dark pattern) - Easy cancellation — as easy as signup; no hidden fees at checkout ## Billing Meter API v2 (GA 2026) Required for metered/usage-based pricing. `POST /v1/billing/meters` creates meter; `POST /v1/billing/meter_events` streams events. Track usage in KV → batch flush via `ctx.waitUntil()`. ```typescript // Create meter (once) const meter = await stripe.billing.meters.create({ display_name: 'API Calls', event_name: 'api_call', default_aggregation: { formula: 'sum' }, }); // Stream events (per request) await stripe.billing.meterEvents.create({ event_name: 'api_call', payload: { stripe_customer_id: customerId, value: '1' }, }); ``` ## Entitlements API (GA 2026) Define Features (`POST /v1/entitlements/features`), attach to Products; check at API boundary: ```typescript const { data } = await stripe.entitlements.activeEntitlements.list({ customer: customerId }); const hasFeature = data.some(e => e.feature.lookup_key === 'advanced_analytics'); if (!hasFeature) return c.json({ error: 'Upgrade required', code: 'ENTITLEMENT_MISSING' }, 403); ``` - Use for plan-tier feature gating; auto-updates when plan changes — no webhook needed for feature access ## Stripe Agentic Commerce (ACP — 2026) Agent calls `stripe.checkout.sessions.create()` with `ui_mode: 'embedded'` → user confirms in Stripe-hosted UI → agent receives confirmation. No agent handles raw card data. ```typescript // Agent-initiated checkout (user confirms in Stripe UI) const session = await stripe.checkout.sessions.create({ mode: 'payment', ui_mode: 'embedded', line_items: [{ price: agentSelectedPriceId, quantity: 1 }], return_url: `${origin}/agent-purchase-complete?session_id={CHECKOUT_SESSION_ID}`, }); // Return session.client_secret to agent for user confirmation ``` ## Metronome (Acquired Jan 2026) Multidimensional metering for AI/usage-based billing at scale: per-token, per-seat, per-API-call combos. Supersedes Billing Meter API for complex scenarios. ## Key Locations - **Stripe API key** — shared key pool (05/shared-api-pool) - **Webhook secret** — `wrangler secret` per project - **Test mode** — always use `sk_test_` before going live ## SaaS Business Strategy (Source: Stripe Atlas Guides + YC 2026) ### Two Core SaaS Models - **Low-Touch SaaS** — self-serve; optimize for onboarding speed + activation rate; target monthly churn <5% (ideally <3%); growth via PLG + SEO - **High-Touch SaaS** — enterprise sales; optimize for demo + integration support; target Net Revenue Retention >120%; growth via outbound + partnerships ### Critical SaaS Metrics (Monitor From Day 1) - **MRR** — growing month-over-month (primary health indicator) - **Churn Rate** — <5% monthly SMB, <1% enterprise - **LTV:CAC Ratio** — >3:1 - **Time to Value** — <60 seconds for free tier - **Activation Rate** — 20-40% of signups ### Pricing Strategy (YC + Stripe Consensus) 1. Generous free tier → higher paid conversion via reciprocity 2. Annual billing default + 20% discount → lower churn, better cash flow 3. Price anchoring — show enterprise/annual first 4. Three tiers maximum (Hick's Law) 5. AI-native baseline — every 2026 SaaS delivers more value than static software -
user-feedback-collection.md 8.9 KB
--- name: "User Feedback Collection" description: "In-app feedback widget: 5-star rating + text comment, stored in D1, reviewed at /admin/feedback. NPS survey at day 7 and day 30 via PostHog. Testimonial collection at /feedback for moderated display. Tracks satisfaction trends over time." updated: "2026-04-23" --- # User Feedback Collection ## Feedback Widget (In-App) ### HTML ```html <button id="feedbackBtn" class="feedback-trigger" aria-label="Give feedback"> 💬 </button> <div id="feedbackModal" class="feedback-modal" hidden> <form id="feedbackForm"> <h3>How's your experience?</h3> <div class="rating" role="radiogroup" aria-label="Rating"> ${[1,2,3,4,5].map(n => ` <button type="button" class="star" data-value="${n}" aria-label="${n} stars"> ${'★'.repeat(n)}${'☆'.repeat(5-n)} </button> `).join('')} </div> <textarea name="comment" placeholder="Tell us more (optional)" rows="3" maxlength="1000"></textarea> <button type="submit">Send Feedback</button> </form> </div> ``` ### Backend ```typescript const FeedbackSchema = z.object({ rating: z.number().min(1).max(5), comment: z.string().max(1000).optional(), page: z.string().optional(), }); app.post('/api/feedback', async (c) => { const body = await c.req.json(); const parsed = FeedbackSchema.parse(body); await c.env.DB.prepare( 'INSERT INTO feedback (id, rating, comment, page, created_at) VALUES (?, ?, ?, ?, ?)' ).bind(ulid(), parsed.rating, parsed.comment || null, parsed.page || c.req.header('referer'), new Date().toISOString()).run(); posthog.capture('feedback_submitted', { rating: parsed.rating }); return c.json({ success: true }); }); ``` ### D1 Schema ```sql CREATE TABLE feedback ( id TEXT PRIMARY KEY, rating INTEGER NOT NULL CHECK(rating BETWEEN 1 AND 5), comment TEXT, page TEXT, status TEXT DEFAULT 'pending', -- pending, approved, rejected created_at TEXT NOT NULL ); ``` ## NPS Survey (PostHog) ### Day 7 + Day 30 Surveys ```javascript // Trigger via PostHog surveys feature posthog.init('PROJECT_KEY', { api_host: 'https://posthog.megabyte.space', surveys: true, // Enable surveys }); ``` Set up in PostHog dashboard: - **Day 7 survey** — `"How likely are you to recommend [Product]? (0-10)"` - **Day 30 survey** — Same question + `"What could we improve?"` - Target: users who signed up 7/30 days ago ### NPS Scoring - **9-10** — Promoter - **7-8** — Passive - **0-6** — Detractor - **NPS** — `% Promoters - % Detractors` (target: >30) ## Testimonial Collection (`/feedback`) Public-facing page for collecting testimonials: ```typescript app.get('/feedback', (c) => c.html(renderFeedbackPage())); app.post('/api/testimonial', async (c) => { const body = await c.req.json(); const parsed = z.object({ name: z.string().min(1).max(100), role: z.string().max(100).optional(), quote: z.string().min(10).max(500), rating: z.number().min(1).max(5), }).parse(body); await c.env.DB.prepare( 'INSERT INTO testimonials (id, name, role, quote, rating, status, created_at) VALUES (?, ?, ?, ?, ?, ?, ?)' ).bind(ulid(), parsed.name, parsed.role, parsed.quote, parsed.rating, 'pending', new Date().toISOString()).run(); return c.json({ success: true, message: 'Thanks! Your testimonial will be reviewed.' }); }); ``` **Moderation** — testimonials are `pending` until manually approved. Display approved ones on the homepage as social proof (skill 09). ## Admin View (`/admin/feedback`) Simple table showing all feedback with approve/reject buttons. Protected by auth or basic admin password. ## MCP Tools Available ### Notion MCP (`mcp__notion__*`) — for feedback storage and triage - `mcp__notion__API-post-page` — create a feedback entry in Notion database - `mcp__notion__API-query-data-source` — query feedback database with filters (rating, status, date) - `mcp__notion__API-patch-page` — update feedback status (pending → reviewed → actioned) - `mcp__notion__API-post-search` — search feedback by keyword - `mcp__notion__API-retrieve-a-database` — get feedback database schema - `mcp__notion__API-create-a-comment` — add internal notes to feedback entries ### Notion Feedback Database Schema ``` Database: "User Feedback" Properties: - Rating (Number, 1-5) - Comment (Rich Text) - Page URL (URL) - Status (Select: Pending, Reviewed, Actioned, Dismissed) - Source (Select: Widget, NPS, Testimonial, Email) - User Email (Email, optional) - Created (Created Time) - Priority (Select: Low, Medium, High, Critical) ``` ### PostHog MCP (`mcp__posthog__*`) — for feedback analytics - `mcp__posthog__authenticate` — connect to PostHog instance PostHog integration points: - Track `feedback_submitted` event with rating + page properties - Track `nps_response` event with score + segment (promoter/passive/detractor) - Track `testimonial_submitted` event - Create PostHog insight: "Average Rating Over Time" (line chart, weekly) - Create PostHog insight: "NPS Score Trend" (line chart, monthly) - Feature flag: `show_feedback_widget` — control which pages show the widget ### Playwright MCP (`mcp__playwright__*`) — for widget testing - `mcp__playwright__browser_navigate` — navigate to page with feedback widget - `mcp__playwright__browser_click` — open feedback modal, click stars, submit - `mcp__playwright__browser_take_screenshot` — screenshot widget at each state (closed, open, submitted) - `mcp__playwright__browser_snapshot` — verify ARIA roles on rating component ## Feedback Widget Implementation Details ### Angular Component (Standalone) ```typescript @Component({ selector: 'app-feedback-widget', standalone: true, imports: [CommonModule, ReactiveFormsModule], template: ` <button (click)="isOpen.set(true)" class="feedback-trigger" aria-label="Give feedback" data-testid="feedback-trigger"> <span aria-hidden="true">💬</span> </button> @if (isOpen()) { <div class="feedback-modal" role="dialog" aria-label="Feedback form" data-testid="feedback-modal"> <form [formGroup]="form" (ngSubmit)="submit()"> <h3>How's your experience?</h3> <div role="radiogroup" aria-label="Rating"> @for (n of [1,2,3,4,5]; track n) { <button type="button" [attr.aria-label]="n + ' stars'" [class.active]="form.get('rating')?.value === n" (click)="form.patchValue({ rating: n })" data-testid="star-{{n}}"> {{ n <= (form.get('rating')?.value || 0) ? '★' : '☆' }} </button> } </div> <textarea formControlName="comment" placeholder="Tell us more (optional)" rows="3" maxlength="1000" data-testid="feedback-comment"></textarea> <button type="submit" [disabled]="!form.get('rating')?.value" data-testid="feedback-submit">Send Feedback</button> </form> </div> } ` }) export class FeedbackWidgetComponent { isOpen = signal(false); form = new FormGroup({ rating: new FormControl<number | null>(null), comment: new FormControl(''), }); private http = inject(HttpClient); submit() { this.http.post('/api/feedback', { rating: this.form.value.rating, comment: this.form.value.comment, page: window.location.pathname, }).subscribe(() => { this.isOpen.set(false); this.form.reset(); }); } } ``` ## Computer Use Integration Use `mcp__computer-use__*` for feedback triage workflows: 1. **Notion dashboard review** — screenshot the Notion feedback database view to see pending feedback at a glance 2. **PostHog dashboards** — screenshot the NPS trend and rating distribution insights for weekly review 3. **Widget visual testing** — screenshot the feedback widget at 375px and 1280px to verify it doesn't obstruct content ## Acceptance Criteria 1. **Feedback widget visible on all pages** — Playwright: `data-testid="feedback-trigger"` visible on homepage + 2 inner pages 2. **Rating submission works** — POST `/api/feedback` with rating 1-5 returns `{ success: true }` 3. **Invalid ratings rejected** — POST with rating 0 or 6 returns 400 error 4. **Comment length enforced** — POST with >1000 char comment returns 400 5. **Feedback stored in D1** — query D1 `feedback` table; new row exists with correct rating + page 6. **Feedback synced to Notion** — `mcp__notion__API-query-data-source` returns matching entry 7. **PostHog event fires** — check PostHog for `feedback_submitted` event with correct properties 8. **NPS survey triggers at day 7** — user created 7 days ago sees NPS survey (PostHog survey targeting) 9. **Testimonials require moderation** — new testimonial has `status: 'pending'`, not shown on site until approved 10. **Widget accessible** — axe-core: 0 violations on feedback modal; keyboard navigable; ARIA roles correct 11. **Admin view shows all feedback** — `/admin/feedback` renders table with rating, comment, page, status, date -
workers-tracing-otlp.md 3 KB
# Workers Tracing + OTLP Export — Observability 2026 Cloudflare Workers automatic tracing is in open beta. Zero-config OpenTelemetry instrumentation for every I/O: KV, R2, D1, DO, fetch, AI binding, Vectorize, Queue. ## Enable `wrangler.jsonc`: ```jsonc { "observability": { "enabled": true } } ``` Free until **March 1, 2026** — then billed. ## Export to External Backends ### Axiom (Cloudflare partner — cheapest at edge volumes) ```ts import { setupAxiomOtel } from '@axiomhq/cloudflare-workers'; setupAxiomOtel({ dataset: env.AXIOM_DATASET, token: env.AXIOM_TOKEN, }); ``` ### Honeycomb (best for trace exploration + BubbleUp anomaly detection) ```ts import { OTLPTraceExporter } from '@opentelemetry/exporter-trace-otlp-http'; const exporter = new OTLPTraceExporter({ url: 'https://api.honeycomb.io/v1/traces', headers: { 'x-honeycomb-team': env.HONEYCOMB_API_KEY }, }); ``` ### Grafana / Datadog Same OTLP pattern — change URL + auth headers. ## Pillar Roles (avoid duplication) - **Sentry** (`@sentry/cloudflare` v9) — **exceptions** only. Use `withSentry` wrapper. Drop the manual breadcrumb scaffolding for I/O; Workers Tracing covers that. - **Workers Tracing (OTLP)** — **spans** for every I/O, perf timing, "where did time go" debugging. - **PostHog** — product analytics + session replay + feature flags + frontend error tracking (one platform). New projects can go PostHog-only for frontend, Sentry-only for backend. - **GA4 / GTM** — marketing + acquisition + conversion funnels. - **AI Gateway** — every LLM call. Logging + caching + rate-limit + fallback in one binding. ## Sentry v9 + Cloudflare Pattern ```ts import { withSentry } from '@sentry/cloudflare'; export default withSentry( (env) => ({ dsn: env.SENTRY_DSN, tracesSampleRate: 1.0, sendDefaultPii: false, release: env.SENTRY_RELEASE, environment: env.ENVIRONMENT, }), { async fetch(request, env, ctx) { // your worker }, }, ); ``` Use **OIDC** to mint `CLOUDFLARE_API_TOKEN` in GitHub Actions — never long-lived secret in env. ## PostHog Unified (Frontend) ```ts posthog.init(env.POSTHOG_TOKEN, { api_host: 'https://us.i.posthog.com', persistence: 'memory', // cookie-free autocapture: true, capture_pageview: true, capture_pageleave: true, enable_recording_console_log: true, // ties errors → session replay }); ``` CSP additions: ``` script-src 'self' https://us.i.posthog.com; connect-src 'self' https://us.i.posthog.com https://us-assets.i.posthog.com; ``` ## CI/CD via GitHub Actions ```yaml - uses: actions/checkout@v5 - uses: cloudflare/wrangler-action@v3 with: apiToken: ${{ secrets.CLOUDFLARE_API_TOKEN }} # Or OIDC: # accountId: ${{ vars.CLOUDFLARE_ACCOUNT_ID }} ``` Or use **Workers Builds** (native CF CI, simpler for Worker-only projects, auto-detects framework, preview URL per branch via `wrangler versions upload`). ## Pin versions - `actions/checkout@v5` - `actions/upload-artifact@v4` - `actions/setup-node@v4` - `cloudflare/wrangler-action@v3` (Wrangler 4 default)
Comments (0)
Sign in to join the conversation.
Reviews (0)
No reviews yet.
No comments yet.