fix: feat: basic analytics funnel tracking for launch readiness (#1101)
Add self-hosted Umami analytics to replace the third-party cloud.umami.is tracker. Creates @harb/analytics package with typed event helpers and instruments the conversion funnel: CTA clicks (landing), wallet connect, swap initiated, and stake created (web-app). - Add Umami Docker service sharing existing postgres (separate DB) - Add Caddy /analytics route to proxy Umami dashboard - Configure via VITE_UMAMI_URL and VITE_UMAMI_WEBSITE_ID env vars - Document setup and funnel events in docs/ENVIRONMENT.md Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
8d67e61c17
commit
ca2bc03567
17 changed files with 195 additions and 6 deletions
|
|
@ -18,5 +18,9 @@
|
|||
uri strip_prefix /api/txn
|
||||
reverse_proxy txn-bot:43069
|
||||
}
|
||||
route /analytics* {
|
||||
uri strip_prefix /analytics
|
||||
reverse_proxy umami:3000
|
||||
}
|
||||
reverse_proxy landing:5174
|
||||
}
|
||||
|
|
|
|||
16
containers/init-umami-db.sh
Executable file
16
containers/init-umami-db.sh
Executable file
|
|
@ -0,0 +1,16 @@
|
|||
#!/bin/bash
|
||||
# Creates the umami database and user if they don't already exist.
|
||||
# Mounted as a postgres init script via docker-compose volumes.
|
||||
set -e
|
||||
|
||||
psql -v ON_ERROR_STOP=1 --username "$POSTGRES_USER" --dbname "$POSTGRES_DB" <<-EOSQL
|
||||
DO \$\$
|
||||
BEGIN
|
||||
IF NOT EXISTS (SELECT FROM pg_catalog.pg_roles WHERE rolname = 'umami') THEN
|
||||
CREATE ROLE umami WITH LOGIN PASSWORD 'umami_local';
|
||||
END IF;
|
||||
END
|
||||
\$\$;
|
||||
SELECT 'CREATE DATABASE umami OWNER umami'
|
||||
WHERE NOT EXISTS (SELECT FROM pg_database WHERE datname = 'umami')\gexec
|
||||
EOSQL
|
||||
|
|
@ -26,4 +26,7 @@ if [[ -f "$CONTRACTS_ENV" ]]; then
|
|||
echo "[landing-entrypoint] Contract addresses loaded: KRK=${KRAIKEN:-unset} STAKE=${STAKE:-unset}"
|
||||
fi
|
||||
|
||||
export VITE_UMAMI_URL="${VITE_UMAMI_URL:-}"
|
||||
export VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}"
|
||||
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5174
|
||||
|
|
|
|||
|
|
@ -34,6 +34,8 @@ if [[ "${CI:-}" == "true" ]]; then
|
|||
export VITE_SWAP_ROUTER=${VITE_SWAP_ROUTER:-0x94cC0AaC535CCDB3C01d6787D6413C739ae12bc4}
|
||||
export VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK=${VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK:-/api/graphql}
|
||||
export VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK=${VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK:-/api/txn}
|
||||
export VITE_UMAMI_URL="${VITE_UMAMI_URL:-}"
|
||||
export VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}"
|
||||
|
||||
echo "[webapp-ci] Environment configured:"
|
||||
echo " VITE_KRAIKEN_ADDRESS: ${VITE_KRAIKEN_ADDRESS}"
|
||||
|
|
@ -81,5 +83,7 @@ export VITE_SWAP_ROUTER=$SWAP_ROUTER
|
|||
export VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK=${VITE_PONDER_BASE_SEPOLIA_LOCAL_FORK:-/api/graphql}
|
||||
export VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK=${VITE_TXNBOT_BASE_SEPOLIA_LOCAL_FORK:-/api/txn}
|
||||
export CHOKIDAR_USEPOLLING=${CHOKIDAR_USEPOLLING:-1}
|
||||
export VITE_UMAMI_URL="${VITE_UMAMI_URL:-}"
|
||||
export VITE_UMAMI_WEBSITE_ID="${VITE_UMAMI_WEBSITE_ID:-}"
|
||||
|
||||
exec npm run dev -- --host 0.0.0.0 --port 5173 --base /app/
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ services:
|
|||
- POSTGRES_DB=ponder_local
|
||||
volumes:
|
||||
- postgres-data:/var/lib/postgresql/data
|
||||
- ./containers/init-umami-db.sh:/docker-entrypoint-initdb.d/init-umami-db.sh:ro,z
|
||||
expose:
|
||||
- "5432"
|
||||
restart: unless-stopped
|
||||
|
|
@ -133,6 +134,8 @@ services:
|
|||
- CHOKIDAR_USEPOLLING=1
|
||||
- GIT_BRANCH=${GIT_BRANCH:-}
|
||||
- VITE_ENABLE_LOCAL_SWAP=true
|
||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||
expose:
|
||||
- "5173"
|
||||
ports:
|
||||
|
|
@ -167,6 +170,8 @@ services:
|
|||
- CHOKIDAR_USEPOLLING=1
|
||||
- GIT_BRANCH=${GIT_BRANCH:-}
|
||||
- VITE_APP_URL=http://localhost:5173/app
|
||||
- VITE_UMAMI_URL=${VITE_UMAMI_URL:-}
|
||||
- VITE_UMAMI_WEBSITE_ID=${VITE_UMAMI_WEBSITE_ID:-}
|
||||
expose:
|
||||
- "5174"
|
||||
restart: unless-stopped
|
||||
|
|
@ -228,6 +233,30 @@ services:
|
|||
retries: 3
|
||||
start_period: 2s
|
||||
|
||||
umami:
|
||||
image: ghcr.io/umami-software/umami:postgresql-latest
|
||||
environment:
|
||||
- DATABASE_URL=postgresql://umami:umami_local@postgres:5432/umami
|
||||
- APP_SECRET=${UMAMI_APP_SECRET:-harb-analytics-secret}
|
||||
- DISABLE_TELEMETRY=1
|
||||
expose:
|
||||
- "3000"
|
||||
ports:
|
||||
- "127.0.0.1:3000:3000"
|
||||
restart: unless-stopped
|
||||
networks:
|
||||
- harb-network
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
logging: *default-logging
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "--spider", "-q", "http://127.0.0.1:3000/api/heartbeat"]
|
||||
interval: 5s
|
||||
timeout: 3s
|
||||
retries: 10
|
||||
start_period: 15s
|
||||
|
||||
otterscan:
|
||||
image: otterscan/otterscan:v2.6.0
|
||||
environment:
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ Docker Compose services (in startup order):
|
|||
| **webapp** | Staking app (Vue 3) | 5173 | HTTP response |
|
||||
| **txn-bot** | Automated `recenter()` and `payTax()` upkeep ([services/txnBot/](../services/txnBot/)) | 43069 | Process alive |
|
||||
| **caddy** | Reverse proxy / TLS | 80/443 | — |
|
||||
| **umami** | Self-hosted analytics (Umami) | 3000 | HTTP /api/heartbeat |
|
||||
| **otterscan** | Block explorer | 5100 | — |
|
||||
|
||||
## txnBot Service
|
||||
|
|
@ -119,6 +120,7 @@ docker inspect landing --format '{{.NetworkSettings.Networks.harb_harb-network.I
|
|||
- **Ponder GraphQL:** `http://localhost:42069/graphql`
|
||||
- **Anvil RPC:** `http://localhost:8545`
|
||||
- **txnBot status:** `http://localhost:43069/status`
|
||||
- **Umami analytics:** `http://localhost:3000` (default login: `admin` / `umami`)
|
||||
|
||||
## Resource Notes
|
||||
|
||||
|
|
@ -140,6 +142,39 @@ For testing login: `lobsterDao`, `test123`, `lobster-x010syqe?412!`
|
|||
| `VITE_KRAIKEN_ADDRESS` | from `deployments-local.json` | via `contracts.env` + entrypoint | Override KRK token address. |
|
||||
| `VITE_STAKE_ADDRESS` | from `deployments-local.json` | via `contracts.env` + entrypoint | Override Stake contract address. |
|
||||
| `VITE_DEFAULT_CHAIN_ID` | auto-detected (31337 on localhost) | — | Force the default chain. |
|
||||
| `VITE_UMAMI_URL` | unset | via env | Full URL to Umami `script.js` (e.g. `https://analytics.kraiken.org/script.js`). Omit to disable analytics. |
|
||||
| `VITE_UMAMI_WEBSITE_ID` | unset | via env | Umami website ID (UUID). Required alongside `VITE_UMAMI_URL`. |
|
||||
|
||||
## Analytics (Umami)
|
||||
|
||||
Self-hosted [Umami](https://umami.is/) provides privacy-respecting funnel analytics with no third-party tracking. The `umami` Docker service shares the `postgres` instance (separate `umami` database created by `containers/init-umami-db.sh`).
|
||||
|
||||
### Setup
|
||||
|
||||
1. Start the stack — Umami comes up automatically.
|
||||
2. Open `http://localhost:3000` and log in (default: `admin` / `umami`). Change the password on first login.
|
||||
3. Add a website in Umami and copy the **Website ID** (UUID).
|
||||
4. Set the env vars before starting landing/webapp:
|
||||
```bash
|
||||
export VITE_UMAMI_URL=http://localhost:3000/script.js
|
||||
export VITE_UMAMI_WEBSITE_ID=<your-website-id>
|
||||
```
|
||||
For staging/production behind Caddy, use the `/analytics/script.js` path instead.
|
||||
|
||||
### Tracked funnel events
|
||||
|
||||
| Event | App | Trigger |
|
||||
|-------|-----|---------|
|
||||
| `cta_click` | landing | User clicks a CTA button (label in event data) |
|
||||
| `wallet_connect` | web-app | Wallet connected for the first time |
|
||||
| `swap_initiated` | web-app | User submits a buy or sell swap (direction in event data) |
|
||||
| `stake_created` | web-app | Stake position successfully created |
|
||||
|
||||
Page views are tracked automatically by the Umami script on every route change.
|
||||
|
||||
### Production deployment
|
||||
|
||||
On `harb-staging`, set `VITE_UMAMI_URL` and `VITE_UMAMI_WEBSITE_ID` in the environment and configure `UMAMI_APP_SECRET` to a strong random value. The Caddy route `/analytics*` proxies to the Umami container.
|
||||
|
||||
## Contract Addresses
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@
|
|||
<link rel="icon" href="/favicon.ico">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>KrAIken</title>
|
||||
<script defer src="https://cloud.umami.is/script.js" data-website-id="6e2869f2-02a5-4346-9b3e-3c27e24ea5d3"></script>
|
||||
</head>
|
||||
<body>
|
||||
<div id="app"></div>
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@
|
|||
"prepare": "husky"
|
||||
},
|
||||
"dependencies": {
|
||||
"@harb/analytics": "*",
|
||||
"@harb/web3": "*",
|
||||
"@harb/ui-shared": "*",
|
||||
"@tanstack/vue-query": "^5.92.9",
|
||||
|
|
|
|||
|
|
@ -5,9 +5,12 @@ import { WagmiPlugin } from '@wagmi/vue';
|
|||
// that Wagmi uses internally for its reactive data hooks (useAccount, useConnect, etc.).
|
||||
import { VueQueryPlugin } from '@tanstack/vue-query';
|
||||
import { createHarbConfig } from '@harb/web3';
|
||||
import { initAnalytics } from '@harb/analytics';
|
||||
import App from './App.vue';
|
||||
import router from './router';
|
||||
|
||||
initAnalytics(import.meta.env.VITE_UMAMI_URL, import.meta.env.VITE_UMAMI_WEBSITE_ID);
|
||||
|
||||
const rpcUrl = import.meta.env.VITE_LOCAL_RPC_URL ?? '/api/rpc';
|
||||
const config = createHarbConfig({ rpcUrl });
|
||||
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@
|
|||
$KRK has a price floor backed by real ETH. The protocol adapts automatically. You just hold.
|
||||
</div>
|
||||
<div class="header-cta">
|
||||
<KButton @click="router.push('/app/get-krk')">Get $KRK</KButton>
|
||||
<KButton @click="navigateCta('/app/get-krk', 'hero_get_krk')">Get $KRK</KButton>
|
||||
</div>
|
||||
<div class="blur-effect"></div>
|
||||
</div>
|
||||
|
|
@ -38,7 +38,7 @@
|
|||
</div>
|
||||
</div>
|
||||
<div class="centered-cta">
|
||||
<KButton @click="router.push('/app/get-krk')">Get $KRK</KButton>
|
||||
<KButton @click="navigateCta('/app/get-krk', 'how_it_works_get_krk')">Get $KRK</KButton>
|
||||
</div>
|
||||
</section>
|
||||
<section class="protocol-health-section">
|
||||
|
|
@ -51,7 +51,7 @@
|
|||
<p>
|
||||
Watch the protocol in real time. Supply dynamics, ETH reserves, position history — all live, no wallet needed.
|
||||
</p>
|
||||
<KButton @click="router.push('/app')">View Protocol</KButton>
|
||||
<KButton @click="navigateCta('/app', 'view_protocol')">View Protocol</KButton>
|
||||
</template>
|
||||
</LeftRightComponent>
|
||||
</section>
|
||||
|
|
@ -68,8 +68,8 @@
|
|||
<li>Hold. The protocol does the rest.</li>
|
||||
</ol>
|
||||
<div class="button-group">
|
||||
<KButton @click="router.push('/app/get-krk')">Get $KRK</KButton>
|
||||
<KButton @click="router.push('/docs/how-it-works')">How It Works →</KButton>
|
||||
<KButton @click="navigateCta('/app/get-krk', 'getting_started_get_krk')">Get $KRK</KButton>
|
||||
<KButton @click="navigateCta('/docs/how-it-works', 'how_it_works_docs')">How It Works →</KButton>
|
||||
</div>
|
||||
</template>
|
||||
</LeftRightComponent>
|
||||
|
|
@ -93,11 +93,17 @@ import LiveStats from '@/components/LiveStats.vue';
|
|||
import SecurityInfo from '@/components/SecurityInfo.vue';
|
||||
import WalletCard from '@/components/WalletCard.vue';
|
||||
import { useMobile } from '@/composables/useMobile';
|
||||
import { trackCtaClick } from '@harb/analytics';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
const isMobile = useMobile();
|
||||
const router = useRouter();
|
||||
|
||||
const navigateCta = (path: string, label: string) => {
|
||||
trackCtaClick(label);
|
||||
router.push(path);
|
||||
};
|
||||
|
||||
const openExternal = (url: string) => {
|
||||
window.open(url, '_blank', 'noopener');
|
||||
};
|
||||
|
|
|
|||
8
packages/analytics/package.json
Normal file
8
packages/analytics/package.json
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"name": "@harb/analytics",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"main": "src/index.ts",
|
||||
"types": "src/index.ts"
|
||||
}
|
||||
69
packages/analytics/src/index.ts
Normal file
69
packages/analytics/src/index.ts
Normal file
|
|
@ -0,0 +1,69 @@
|
|||
/**
|
||||
* Lightweight analytics wrapper for self-hosted Umami.
|
||||
*
|
||||
* Call `initAnalytics()` once at app startup to inject the tracker
|
||||
* script. All other helpers are safe to call at any time — if the
|
||||
* tracker hasn't loaded (ad-blocker, missing config, dev without
|
||||
* Umami running) every call is a silent no-op.
|
||||
*/
|
||||
|
||||
interface UmamiTracker {
|
||||
track: (name: string, data?: Record<string, string | number | boolean>) => void;
|
||||
}
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
umami?: UmamiTracker;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Inject the Umami tracker script.
|
||||
* @param scriptUrl — full URL to `script.js`, e.g. `https://analytics.kraiken.org/script.js`
|
||||
* @param websiteId — Umami website ID (UUID)
|
||||
*/
|
||||
export function initAnalytics(scriptUrl: string | undefined, websiteId: string | undefined): void {
|
||||
if (!scriptUrl || !websiteId) return;
|
||||
if (typeof document === 'undefined') return;
|
||||
if (document.querySelector(`script[data-website-id="${websiteId}"]`)) return;
|
||||
|
||||
const el = document.createElement('script');
|
||||
el.defer = true;
|
||||
el.src = scriptUrl;
|
||||
el.dataset.websiteId = websiteId;
|
||||
document.head.appendChild(el);
|
||||
}
|
||||
|
||||
function getTracker(): UmamiTracker | undefined {
|
||||
return typeof window !== 'undefined' ? window.umami : undefined;
|
||||
}
|
||||
|
||||
/** Fire a named event with optional properties. */
|
||||
export function trackEvent(
|
||||
name: string,
|
||||
data?: Record<string, string | number | boolean>,
|
||||
): void {
|
||||
getTracker()?.track(name, data);
|
||||
}
|
||||
|
||||
/* ── Funnel events ─────────────────────────────────────────────── */
|
||||
|
||||
/** Landing page CTA clicked (e.g. "Get $KRK"). */
|
||||
export function trackCtaClick(label: string): void {
|
||||
trackEvent('cta_click', { label });
|
||||
}
|
||||
|
||||
/** Wallet successfully connected. */
|
||||
export function trackWalletConnect(): void {
|
||||
trackEvent('wallet_connect');
|
||||
}
|
||||
|
||||
/** Swap (buy/sell) initiated — user submitted the transaction. */
|
||||
export function trackSwapInitiated(direction: 'buy' | 'sell'): void {
|
||||
trackEvent('swap_initiated', { direction });
|
||||
}
|
||||
|
||||
/** Stake position created (snatch completed). */
|
||||
export function trackStakeCreated(): void {
|
||||
trackEvent('stake_created');
|
||||
}
|
||||
|
|
@ -33,6 +33,7 @@
|
|||
"vue-router": "^4.2.5",
|
||||
"vue-tippy": "^6.6.0",
|
||||
"vue-toastification": "^2.0.0-rc.5",
|
||||
"@harb/analytics": "*",
|
||||
"@harb/web3": "*",
|
||||
"@harb/utils": "*"
|
||||
},
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ import { createPermitObject, getSignatureRSV } from '@/utils/blockchain';
|
|||
import { weiToNumber, compactNumber } from 'kraiken-lib/format';
|
||||
import { getTaxRateOptionByIndex } from '@/composables/useAdjustTaxRates';
|
||||
import { useContractToast } from './useContractToast';
|
||||
import { trackStakeCreated } from '@harb/analytics';
|
||||
const wallet = useWallet();
|
||||
const contractToast = useContractToast();
|
||||
const wagmiConfig: Config = config;
|
||||
|
|
@ -129,6 +130,7 @@ export function useStake() {
|
|||
const eventArgs: PositionCreatedArgs = topics.args;
|
||||
|
||||
const amount = compactNumber(weiToNumber(eventArgs.harbergDeposit, wallet.balance.decimals));
|
||||
trackStakeCreated();
|
||||
contractToast.showSuccessToast(amount, 'Success!', 'You Staked', 'Check your positions on the<br /> Staker Dashboard', '$KRK');
|
||||
|
||||
waiting.value = false;
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import { config as wagmiConfig } from '@/wagmi';
|
|||
import { getChain, DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { useWallet } from '@/composables/useWallet';
|
||||
import { getErrorMessage, ensureAddress } from '@harb/utils';
|
||||
import { trackSwapInitiated } from '@harb/analytics';
|
||||
|
||||
export const WETH_ABI = [
|
||||
{
|
||||
|
|
@ -217,6 +218,7 @@ export function useSwapKrk() {
|
|||
});
|
||||
}
|
||||
|
||||
trackSwapInitiated('buy');
|
||||
await writeContract(wagmiConfig, {
|
||||
abi: SWAP_ROUTER_ABI,
|
||||
address: router,
|
||||
|
|
@ -327,6 +329,7 @@ export function useSwapKrk() {
|
|||
}
|
||||
|
||||
sellPhase.value = 'selling';
|
||||
trackSwapInitiated('sell');
|
||||
await writeContract(wagmiConfig, {
|
||||
abi: SWAP_ROUTER_ABI,
|
||||
address: router,
|
||||
|
|
|
|||
|
|
@ -9,6 +9,7 @@ import { setStakeContract } from '@/contracts/stake';
|
|||
import { chainsData, DEFAULT_CHAIN_ID } from '@/config';
|
||||
import { createPublicClient, custom, formatUnits, type EIP1193Provider, type PublicClient, type Transport, type WalletClient } from 'viem';
|
||||
import { getWalletPublicClient, setWalletPublicClient } from '@/services/walletRpc';
|
||||
import { trackWalletConnect } from '@harb/analytics';
|
||||
|
||||
const balance = ref<GetBalanceReturnType>({
|
||||
value: 0n,
|
||||
|
|
@ -118,6 +119,7 @@ export function useWallet() {
|
|||
};
|
||||
} else if (account.value.address !== data.address || account.value.chainId !== data.chainId) {
|
||||
logger.info(`Account changed!:`, data.address);
|
||||
if (!account.value.address && data.address) trackWalletConnect();
|
||||
account.value = data;
|
||||
await syncWalletPublicClient(data);
|
||||
await loadBalance();
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import { WagmiPlugin } from '@wagmi/vue';
|
|||
import { QueryClient, VueQueryPlugin } from '@tanstack/vue-query';
|
||||
import { createApp } from 'vue';
|
||||
import { config } from './wagmi';
|
||||
import { initAnalytics } from '@harb/analytics';
|
||||
import ClickOutSide from '@/directives/ClickOutsideDirective';
|
||||
import router from './router';
|
||||
|
||||
|
|
@ -9,6 +10,9 @@ import App from './App.vue';
|
|||
import './assets/styles/main.sass';
|
||||
import Toast from 'vue-toastification';
|
||||
import 'vue-toastification/dist/index.css';
|
||||
|
||||
initAnalytics(import.meta.env.VITE_UMAMI_URL, import.meta.env.VITE_UMAMI_WEBSITE_ID);
|
||||
|
||||
const queryClient = new QueryClient();
|
||||
|
||||
const app = createApp(App);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue