Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 17 additions & 8 deletions src/lib/actions/analytics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,8 +20,6 @@ type Payload = {
};

const plausible = (domain: string): AnalyticsPlugin => {
if (!browser) return { name: 'analytics-plugin-plausible' };

const instance = Plausible({
domain
});
Expand Down Expand Up @@ -51,15 +49,26 @@ const plausible = (domain: string): AnalyticsPlugin => {
};
};

const analytics = Analytics({
app: 'appwrite',
plugins: [plausible('appwrite.io')]
});
/** Lazily created in the browser only — avoids `Analytics()` (and any internal `fetch`) during SSR. */
let analyticsClient: ReturnType<typeof Analytics> | null = null;

function getAnalyticsClient(): ReturnType<typeof Analytics> | null {
if (!browser) return null;
if (!analyticsClient) {
analyticsClient = Analytics({
app: 'appwrite',
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Unhandled rejection on .then() with no arguments

getAnalyticsClient()?.track(name, data).then() attaches .then() solely to suppress the floating-promise lint warning, but any rejection from track() becomes an unhandled promise rejection. Prefer void casting instead:

Suggested change
app: 'appwrite',
void getAnalyticsClient()?.track(name, data);

plugins: [plausible('appwrite.io')]
});
}
return analyticsClient;
}

export type TrackEventArgs = { name: string; data?: object };

export const trackEvent = (eventArgs?: string | TrackEventArgs): void => {
if (!eventArgs || ENV.TEST) return;
/** All vendors below use `fetch`; they must not run during SSR. */
if (!browser) return;

const path = page.url.pathname;
const route =
Expand All @@ -70,7 +79,7 @@ export const trackEvent = (eventArgs?: string | TrackEventArgs): void => {
const data =
typeof eventArgs === 'string' ? { path, route } : { ...eventArgs.data, path, route };

if (browser && shouldForwardAnalyticsToStatsig(name, path)) {
if (shouldForwardAnalyticsToStatsig(name, path)) {
logStatsigCtaEvent(name, data as Record<string, unknown>);
}

Expand All @@ -80,5 +89,5 @@ export const trackEvent = (eventArgs?: string | TrackEventArgs): void => {
}

posthogEvent.capture(name, data);
analytics.track(name, data).then();
getAnalyticsClient()?.track(name, data).then();
};
55 changes: 47 additions & 8 deletions src/lib/components/homepage-variations/custom-hero.svelte
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
<script lang="ts">
import { building } from '$app/environment';
import { browser, building } from '$app/environment';
import { page } from '$app/state';
import { PUBLIC_APPWRITE_DASHBOARD } from '$env/static/public';
import { onMount } from 'svelte';
import { trackEvent } from '$lib/actions/analytics';
import { DEFAULT_HERO_SUBTITLE, DEFAULT_HERO_TITLE } from '$lib/statsig/constants';
import { resolveHeroQueryOverrides } from '$lib/statsig/hero-query-overrides';
import { initStatsig, whenStatsigReady } from '$lib/statsig/client';
import { readMarketingHeroExperimentsForExposure } from '$lib/statsig/experiments/marketing-hero-client';
import {
hasHeroExperimentQueryOverrides,
resolveHeroQueryOverrides
} from '$lib/statsig/hero-query-overrides';
import type { HeroLayoutVariant } from '$lib/statsig/hero-layout';
import { ENV } from '$lib/system';
import GradientText from '$lib/components/fancy/gradient-text.svelte';
import { Button } from '$lib/components/ui';
import { cn } from '$lib/utils/cn';
Expand All @@ -27,14 +34,46 @@
heroLayout?: HeroLayoutVariant;
}>();

/** Same client hydration path as marketing `hero.svelte` (Statsig + query overrides). */
let clientHeroLayout = $state<HeroLayoutVariant | undefined>(undefined);
let clientHeroSubtitle = $state<string | undefined>(undefined);

const resolved = $derived(
resolveHeroQueryOverrides(building ? new URLSearchParams() : page.url.searchParams, {
heroLayout,
heroSubtitle: subtitle,
heroLayout: clientHeroLayout ?? heroLayout,
heroSubtitle: clientHeroSubtitle ?? subtitle,
heroTitle: title
})
);

onMount(() => {
if (!browser || ENV.TEST) return;
if (hasHeroExperimentQueryOverrides(page.url.searchParams)) return;

const data = page.data as {
statsigBootstrap?: string | null;
statsigStableUserId?: string | null;
statsigUserAgent?: string | null;
};
void initStatsig(
data.statsigBootstrap ?? null,
data.statsigStableUserId ?? null,
data.statsigUserAgent ?? null
);

void whenStatsigReady().then((client) => {
if (!client) return;

const { heroSubtitle, heroLayout: nextLayout } =
readMarketingHeroExperimentsForExposure(client, {
heroSubtitle: subtitle,
heroLayout
});
clientHeroSubtitle = heroSubtitle;
clientHeroLayout = nextLayout;
});
});

const layoutAside = $derived(resolved.heroLayout === 0);
const layoutBottomTwoLineTitle = $derived(resolved.heroLayout === 1);
</script>
Expand All @@ -43,8 +82,8 @@
class={cn(
'relative flex max-w-screen items-center overflow-hidden',
layoutAside
? 'py-12 md:py-0 lg:min-h-[700px]'
: 'pt-16 pb-8 md:pt-20 md:pb-10 lg:min-h-[600px] lg:pt-24'
? 'py-10 md:py-0 lg:min-h-[680px]'
: 'pt-10 pb-6 md:pt-14 md:pb-8 lg:min-h-[560px] lg:pt-16'
)}
>
<div
Expand All @@ -59,8 +98,8 @@
class={cn(
'relative container mx-auto h-full',
layoutAside
? 'grid grid-cols-1 place-items-center gap-24 md:grid-cols-2'
: 'flex w-full flex-col items-center gap-16 md:gap-20 lg:gap-24'
? 'grid grid-cols-1 place-items-center gap-16 md:grid-cols-2'
: 'flex w-full flex-col items-center gap-10 md:gap-14 lg:gap-16'
)}
>
<div
Expand Down
35 changes: 35 additions & 0 deletions src/lib/statsig/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
# Statsig layout

| Path | Role |
| -------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
| `server.ts` | Node `Statsig` singleton, `toStatsigUser`, bootstrap payload for the browser SDK. |
| `client.ts` | Browser init (`initStatsig`, `whenStatsigReady`), stable id cookie, plugins. |
| `experiment-eval.ts` | Shared readers for typed Statsig evaluations (e.g. layout param keys + `.value` fallback). |
| `experiments/marketing-hero-ids.ts` | Experiment id strings only (safe on **client** and server). |
| `experiments/marketing-hero-client.ts` | Browser-only: `readMarketingHeroExperimentsForExposure` (import from **Svelte** / client code). |
| `experiments/marketing-hero-server.ts` | Server-only: `evaluateHeroDescriptionExperiment`, `evaluateHeroLayoutExperiment` (never import from `.svelte`). |
| `hero-statsig.server.ts` | Thin re-export barrel (legacy import path for `+page.server.ts`). |
| `hero-query-overrides.ts` | URL query overrides for local QA (`?hero_layout=`, `?hero_subtitle=`, …). |
| `hero-layout.ts` | Pure `normalizeHeroLayout` + `HeroLayoutVariant` type (no network). |
| `cta-events.ts` | Which analytics events mirror into Statsig. |

## Adding a new experiment (marketing hero)

1. **Statsig console** — Create the experiment (or dynamic config). Note the **id** and each **parameter name** and type (string vs number default for `.get` matters).
2. **Ids + server + client (split on purpose)**
- Add the id to `experiments/marketing-hero-ids.ts` (`MARKETING_HERO_EXPERIMENTS`).
- In `experiments/marketing-hero-server.ts`, add `evaluateYourThingExperiment(...)` using `getStatsigServerClient()` + `toStatsigUser()` + `getExperiment(..., { disableExposureLogging: true })` for SSR without exposure.
- In `experiments/marketing-hero-client.ts`, extend `readMarketingHeroExperimentsForExposure` so the browser calls `getExperiment(...).get(...)` (exposure for Pulse / Results). Extend `MarketingHeroStatsigBaseline` if SSR + hydration share the field.
3. **`(marketing)/+page.server.ts`** — `Promise.all` your new evaluator next to the existing ones; pass the value into `resolveHeroQueryOverrides` baseline or add a new field on `data`.
4. **Hero Svelte** — Pass the new prop from `data`; merge into `resolveHeroQueryOverrides` if URL overrides should apply.
5. **URL overrides (optional)** — Add a query key + reader in `hero-query-overrides.ts` and document it in the table comment there.

Do **not** read experiment params only in `onMount` without also defining SSR in step 2–3 unless you intentionally want client-only assignment (e.g. prerender shell + client fill).

**Why two files (`*-client` / `*-server`)?** `@statsig/statsig-node-core` ships native `.node` binaries. If any module imported by a `.svelte` file pulls in `server.ts` or `marketing-hero-server.ts`, Vite tries to bundle that SDK for the browser and fails with “No loader is configured for `.node` files”.

## Adding experiments elsewhere (not marketing hero)

1. Put **server** evaluation next to the route’s `+page.server.ts` or in a small `experiments/<feature>.ts` that only imports `getStatsigServerClient` / `toStatsigUser` from `$lib/statsig/server`.
2. Put **browser** reads next to the component or in the same `experiments/<feature>.ts`, using `whenStatsigReady()` from `$lib/statsig/client`.
3. Reuse `experiment-eval.ts` if you need the same “try `.get`, then `.value`” pattern for typed params.
15 changes: 3 additions & 12 deletions src/lib/statsig/client.ts
Original file line number Diff line number Diff line change
@@ -1,21 +1,12 @@
import { browser } from '$app/environment';
import { ENV } from '$lib/system';
import {
STATSIG_STABLE_ID_KEY,
STATSIG_EXPERIMENT_BEST_DESCRIPTION,
STATSIG_EXPERIMENT_HERO_LAYOUT,
STATSIG_CLIENT_SDK_KEY
} from '$lib/statsig/constants';

export { STATSIG_EXPERIMENT_BEST_DESCRIPTION, STATSIG_EXPERIMENT_HERO_LAYOUT };
import { STATSIG_STABLE_ID_KEY, STATSIG_CLIENT_SDK_KEY } from '$lib/statsig/constants';
import type { StatsigClientEvalSource } from '$lib/statsig/experiment-eval';

/** Narrow surface we use from `@statsig/js-client` (avoids static `import type` from that package, which can break Vite named re-exports). */
export type StatsigBrowserClient = {
export type StatsigBrowserClient = StatsigClientEvalSource & {
initializeSync(options?: object): unknown;
initializeAsync(options?: object): Promise<unknown>;
getExperiment(name: string): {
get(key: string, defaultValue: string | number): string | number;
};
logEvent(eventOrName: string, value?: string | number, metadata?: Record<string, string>): void;
shutdown(): Promise<void>;
};
Expand Down
5 changes: 0 additions & 5 deletions src/lib/statsig/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,6 @@ export const STATSIG_STABLE_ID_KEY = 'statsig_stable_id';
/** Browser SDK key (public). Server uses it for `getClientInitializeResponse` / client bootstrap. */
export const STATSIG_CLIENT_SDK_KEY = 'client-TRp4ODQ3Yfsha0XwmRayqwb7WW0ujUbiGrNlB0pfhTH';

export const STATSIG_EXPERIMENT_BEST_DESCRIPTION = 'best_description';

/** Experiment param key: `layout` (values `0` | `1` | `2`). */
export const STATSIG_EXPERIMENT_HERO_LAYOUT = 'hero_layout';

export const DEFAULT_HERO_SUBTITLE =
'Appwrite is an open-source, all-in-one development platform. Use built-in backend infrastructure and web hosting, all from a single place.';

Expand Down
80 changes: 80 additions & 0 deletions src/lib/statsig/experiment-eval.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
import { normalizeHeroLayout, type HeroLayoutVariant } from './hero-layout';

/**
* Minimal shape shared by Statsig `Experiment` and `DynamicConfig` for reading typed params.
* Used by the browser client type and by server-side evaluation objects.
*/
export type StatsigParamEvaluation = {
get(key: string, defaultValue: string | number): string | number;
value?: Record<string, unknown>;
};

export type StatsigLayoutReadSource = 'none' | 'get' | 'value' | 'dynamic';

const LAYOUT_PARAM_KEYS = ['layout', 'hero_layout', 'heroLayout'] as const;

/**
* Numeric sentinel for `.get` — Statsig types `hero_layout` as number; a string default triggers SDK warnings.
*/
const LAYOUT_GET_SENTINEL = -999_999;

/**
* Reads a 0|1|2 layout from one experiment/dynamic-config evaluation: try `.get` for common param
* names, then `evaluation.value` (explicit-parameter / SDK quirks).
*/
export function readLayoutVariantFromStatsigEvaluation(
evaluation: StatsigParamEvaluation,
fallback: HeroLayoutVariant
): { layout: HeroLayoutVariant; source: StatsigLayoutReadSource } {
for (const key of LAYOUT_PARAM_KEYS) {
const raw = evaluation.get(key, LAYOUT_GET_SENTINEL);
if (raw !== LAYOUT_GET_SENTINEL) {
return { layout: normalizeHeroLayout(raw, fallback), source: 'get' };
}
}

const record = evaluation.value;
if (record && typeof record === 'object') {
const map = record as Record<string, unknown>;
for (const key of LAYOUT_PARAM_KEYS) {
if (key in map) {
const raw = map[key];
if (raw !== undefined && raw !== null) {
return { layout: normalizeHeroLayout(raw, fallback), source: 'value' };
}
}
}
}

return { layout: fallback, source: 'none' };
}

/** Browser Statsig client: `getExperiment` first, then optional `getDynamicConfig` with the same id. */
export type StatsigClientEvalSource = {
getExperiment(name: string): StatsigParamEvaluation;
getDynamicConfig?: (name: string, options?: object) => StatsigParamEvaluation;
};

export function readLayoutVariantFromStatsigClient(
client: StatsigClientEvalSource,
experimentOrConfigName: string,
fallback: HeroLayoutVariant
): { layout: HeroLayoutVariant; source: StatsigLayoutReadSource } {
let r = readLayoutVariantFromStatsigEvaluation(
client.getExperiment(experimentOrConfigName),
fallback
);
if (r.source !== 'none') {
return r;
}
if (typeof client.getDynamicConfig === 'function') {
r = readLayoutVariantFromStatsigEvaluation(
client.getDynamicConfig(experimentOrConfigName),
fallback
);
if (r.source !== 'none') {
return { layout: r.layout, source: 'dynamic' };
}
}
return { layout: fallback, source: 'none' };
}
63 changes: 63 additions & 0 deletions src/lib/statsig/experiments/marketing-hero-client.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
/**
* Browser-only marketing hero Statsig reads (`@statsig/js-client`).
* Do not import `marketing-hero-server.ts` or `server.ts` from Svelte/components — that pulls native `@statsig/statsig-node-core` into Vite client.
*
* @see ../README.md
*/
import type { StatsigBrowserClient } from '../client';
import { readLayoutVariantFromStatsigClient } from '../experiment-eval';
import type { HeroLayoutVariant } from '../hero-layout';
import { normalizeHeroSubtitle } from '../hero-query-overrides';
import { MARKETING_HERO_EXPERIMENTS } from './marketing-hero-ids';

export { MARKETING_HERO_EXPERIMENTS };

export type MarketingHeroStatsigBaseline = {
heroSubtitle: string;
heroLayout: HeroLayoutVariant;
};

export type MarketingHeroClientExposureDebug = Record<string, unknown>;

/**
* Read experiments once the JS client is ready — logs exposures for Pulse / Results.
* Pass the same baselines as SSR (or prerender defaults) so `.get` defaults stay consistent.
*/
export function readMarketingHeroExperimentsForExposure(
client: StatsigBrowserClient,
baseline: MarketingHeroStatsigBaseline
): {
heroSubtitle: string;
heroLayout: HeroLayoutVariant;
debug: MarketingHeroClientExposureDebug;
} {
const rawDescription = client
.getExperiment(MARKETING_HERO_EXPERIMENTS.bestDescription)
.get('description', baseline.heroSubtitle);

const layoutRead = readLayoutVariantFromStatsigClient(
client,
MARKETING_HERO_EXPERIMENTS.heroLayout,
baseline.heroLayout
);

const heroSubtitle = normalizeHeroSubtitle(rawDescription, baseline.heroSubtitle);

return {
heroSubtitle,
heroLayout: layoutRead.layout,
debug: {
[MARKETING_HERO_EXPERIMENTS.bestDescription]: {
raw: rawDescription,
rawType: typeof rawDescription,
normalizedLen: heroSubtitle.length,
ssrBaselineLen: baseline.heroSubtitle.length
},
[MARKETING_HERO_EXPERIMENTS.heroLayout]: {
normalized: layoutRead.layout,
readSource: layoutRead.source,
ssrBaselineLayout: baseline.heroLayout
}
}
};
}
12 changes: 12 additions & 0 deletions src/lib/statsig/experiments/marketing-hero-ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
/**
* Statsig experiment / config ids for the marketing hero only.
* Safe to import from **client** code (no Node Statsig SDK).
*/
export const MARKETING_HERO_EXPERIMENTS = {
/** Param: `description` (string). */
bestDescription: 'best_description',
/**
* Experiment or dynamic config id. Params tried: `layout`, `hero_layout`, `heroLayout`; then `.value`.
*/
heroLayout: 'hero_layout'
} as const;
Loading
Loading