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
1 change: 1 addition & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pnpm build # vite build using the Cloudflare adapter
- `pnpm build` verifies the Cloudflare Workers output.
- Production (cryptly.dev) is served by Vercel: `frontend/vercel.json` builds `apps/web` with `adapter-vercel` (selected when `VERCEL=1`).
- Public env is inlined at build time; production values live in `apps/web/.env.production`.
- Analytics: Logdash, served first-party by `apps/web/src/routes/_ld`. Use `track` / `identify` from `$lib/analytics/logdash`. Never put emails, names, user IDs or other personal data in event names or props; add new dynamic routes to `PATH_TEMPLATES` in `_ld/events/+server.ts`.

### Backend SDK (`packages/backend-sdk/`)

Expand Down
1 change: 1 addition & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ pnpm build # vite build using the Cloudflare adapter
- `pnpm build` verifies the Cloudflare Workers output.
- Production (cryptly.dev) is served by Vercel: `frontend/vercel.json` builds `apps/web` with `adapter-vercel` (selected when `VERCEL=1`).
- Public env is inlined at build time; production values live in `apps/web/.env.production`.
- Analytics: Logdash, served first-party by `apps/web/src/routes/_ld`. Use `track` / `identify` from `$lib/analytics/logdash`. Never put emails, names, user IDs or other personal data in event names or props; add new dynamic routes to `PATH_TEMPLATES` in `_ld/events/+server.ts`.

### Backend SDK (`packages/backend-sdk/`)

Expand Down
65 changes: 65 additions & 0 deletions apps/web/e2e/logdash-proxy.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import { expect, test } from "@playwright/test";

// Requests the /_ld proxy must reject before anything reaches Logdash.
const APP = process.env.SVELTE_URL ?? "http://127.0.0.1:9090";
const EVENTS = `${APP}/_ld/events`;
const SITE_ID = "6acbf37df07d59985c0f7a20";

const event = { name: "pageview", path: "/" };
const json = { "content-type": "application/json" };

test("serves the tracker script as JavaScript", async ({ request }) => {
const res = await request.get(`${APP}/_ld/script.js`);
expect(res.status()).toBe(200);
expect(res.headers()["content-type"]).toContain("application/javascript");
expect(res.headers()["cache-control"]).toContain("public");
});

test("rejects non-JSON bodies", async ({ request }) => {
const res = await request.post(EVENTS, {
headers: { "content-type": "text/plain" },
data: JSON.stringify({ siteId: SITE_ID, events: [event] }),
});
expect(res.status()).toBe(415);
});

test("rejects bodies over 32 KB", async ({ request }) => {
const res = await request.post(EVENTS, {
headers: json,
data: JSON.stringify({
siteId: SITE_ID,
events: [{ ...event, pad: "x".repeat(33 * 1024) }],
}),
});
expect(res.status()).toBe(413);
});

test("rejects batches over 20 events", async ({ request }) => {
const res = await request.post(EVENTS, {
headers: json,
data: JSON.stringify({
siteId: SITE_ID,
events: Array.from({ length: 21 }, () => event),
}),
});
expect(res.status()).toBe(413);
});

test("rejects other site IDs", async ({ request }) => {
const res = await request.post(EVENTS, {
headers: json,
data: JSON.stringify({ siteId: "0".repeat(24), events: [event] }),
});
expect(res.status()).toBe(403);
});

test("rejects malformed JSON and empty batches", async ({ request }) => {
const malformed = await request.post(EVENTS, { headers: json, data: "{" });
expect(malformed.status()).toBe(400);
const empty = await request.post(EVENTS, {
headers: json,
data: JSON.stringify({ siteId: SITE_ID, events: [] }),
});
expect(empty.status()).toBe(400);
expect(empty.headers()["cache-control"]).toBe("no-store");
});
8 changes: 8 additions & 0 deletions apps/web/src/app.html
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,14 @@
content="https://i.ibb.co/gbGXNw6J/cryptly-og.png"
/>

<!-- Logdash web analytics, served first-party by src/routes/_ld -->
<script
defer
src="/_ld/script.js"
data-site="6acbf37df07d59985c0f7a20"
data-endpoint="/_ld/events"
></script>

%sveltekit.head%
</head>
<body
Expand Down
42 changes: 42 additions & 0 deletions apps/web/src/lib/analytics/logdash.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { browser } from "$app/environment";

// Logdash web analytics. The tracker is loaded in app.html through the /_ld proxy routes.
// Never pass emails, names, user IDs or other personal data as event names or props.

type EventProps = Record<string, string | number | boolean>;

interface Logdash {
track(name: string, props?: EventProps): void;
identify(userId: string | null): void;
}

declare global {
interface Window {
logdash?: Logdash;
}
}

/** Runs now if the deferred tracker is ready, otherwise once its script has loaded. */
function whenReady(fn: (logdash: Logdash) => void): void {
if (!browser) return;
if (window.logdash) {
fn(window.logdash);
return;
}
document.querySelector('script[src="/_ld/script.js"]')?.addEventListener(
"load",
() => {
if (window.logdash) fn(window.logdash);
},
{ once: true },
);
}

/** Opaque account ID only; the tracker hashes it in the browser. `null` on sign-out. */
export function identify(userId: string | null): void {
whenReady((logdash) => logdash.identify(userId));
}

export function track(name: string, props?: EventProps): void {
whenReady((logdash) => logdash.track(name, props));
}
2 changes: 2 additions & 0 deletions apps/web/src/lib/auth/ui/CliAuthorizePage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { Check, CornerDownLeft, Terminal } from "lucide-svelte";
import { onMount, tick } from "svelte";
import { animate } from "motion";
import { track } from "$lib/analytics/logdash";
import { ApiResponseError } from "$lib/api/backend";
import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto";
import {
Expand Down Expand Up @@ -158,6 +159,7 @@
wrappedKey,
encryptedPrivateKey,
});
track("cli_authorized");

approveState = "done";
} catch (e: unknown) {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/auth/ui/LocalLoginForm.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
<script lang="ts">
import { IconArrowRight } from "@tabler/icons-svelte";
import { onMount, tick } from "svelte";
import { track } from "$lib/analytics/logdash";
import { gotoAfterLogin } from "$lib/auth/after-login";
import { loadUserData, loginLocal } from "$lib/stores/auth.svelte";

Expand All @@ -20,6 +21,7 @@
async function loginWithEmail(loginEmail: string) {
await loginLocal(loginEmail);
await loadUserData();
track("login_completed", { method: "local" });
await gotoAfterLogin();
}

Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/auth/ui/OAuthCallbackPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { onMount } from "svelte";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { track } from "$lib/analytics/logdash";
import { gotoAfterLogin } from "$lib/auth/after-login";
import { consumeOAuthState } from "$lib/auth/browser-state";
import {
Expand Down Expand Up @@ -33,6 +34,7 @@
await exchangeGithubCodeForJwt(code);
}
await loadUserData();
track("login_completed", { method });
exchangeSucceeded = true;
} catch {
await goto(resolve("/app/login"));
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/invitations/ui/AcceptInvitationPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
IconKey,
IconUsers,
} from "@tabler/icons-svelte";
import { track } from "$lib/analytics/logdash";
import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto";
import { persistInviteIdForAfterLogin } from "$lib/auth/after-login";
import { SymmetricCrypto } from "$lib/auth/symmetric-crypto";
Expand Down Expand Up @@ -78,6 +79,7 @@
await InvitationsApi.acceptInvitation(jwt, invitation.id, {
newSecretsKey: reEncryptedProjectKey,
});
track("invitation_accepted", { kind: "link" });
await goto(resolve(`/app/project/${invitation.projectId}`));
} catch {
isError = true;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { untrack } from "svelte";
import { Check, X } from "lucide-svelte";
import { toast } from "svelte-sonner";
import { track } from "$lib/analytics/logdash";
import {
InvitationsApi,
type PersonalInvitation,
Expand Down Expand Up @@ -47,6 +48,7 @@
actingId = invitation.id;
try {
await InvitationsApi.acceptPersonalInvitation(jwt, invitation.id);
track("invitation_accepted", { kind: "direct" });
invitations = invitations.filter((item) => item.id !== invitation.id);
toast.success("Invitation accepted");
await goto(resolve(`/app/project/${invitation.projectId}`));
Expand Down
4 changes: 4 additions & 0 deletions apps/web/src/lib/projects/ui/ProjectPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { browser } from "$app/environment";
import { goto } from "$app/navigation";
import { resolve } from "$app/paths";
import { track } from "$lib/analytics/logdash";
import { createPatch } from "diff";
import { onDestroy, onMount, tick } from "svelte";
import { cubicOut } from "svelte/easing";
Expand Down Expand Up @@ -1623,6 +1624,7 @@
installationEntityId: repo.installationEntityId,
});
}
track("project_created", { github_repo: Boolean(repo) });
return created;
}

Expand Down Expand Up @@ -1865,6 +1867,7 @@
temporarySecretsKey: encryptedProjectKey,
role,
});
track("invitation_created", { kind: "link", role });

invitePassphrase = passphrase;
lastCreatedInvitation = invitation;
Expand Down Expand Up @@ -1905,6 +1908,7 @@
invitedUserId: user.id,
role,
});
track("invitation_created", { kind: "direct", role });
addPeopleStep = "done";
void loadPendingInvitations(jwt, project);
} catch {
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/projects/ui/ProjectsPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
import { tick } from "svelte";
import { animate } from "motion";
import { ArrowRight } from "lucide-svelte";
import { track } from "$lib/analytics/logdash";
import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto";
import {
DEFAULT_PROJECT_SETTINGS,
Expand Down Expand Up @@ -131,6 +132,7 @@
encryptedSecretsKeys: { [user.id]: projectKeyEncrypted },
settings,
});
track("project_created", { github_repo: false });

await UserApi.updateMe(jwt, { projectCreationDefaults: settings });
await loadUserData();
Expand Down
3 changes: 3 additions & 0 deletions apps/web/src/lib/secrets/ui/SecretsEditorPage.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import { fade } from "svelte/transition";
import { toast } from "svelte-sonner";
import { ChevronRight, Command } from "lucide-svelte";
import { track } from "$lib/analytics/logdash";
import { ApiResponseError } from "$lib/api/backend";
import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto";
import {
Expand Down Expand Up @@ -302,6 +303,7 @@
encryptedSecrets: encrypted,
});
baseline = content;
track("secrets_saved");
ftuxUserSaved();
await onSaved?.();
return true;
Expand Down Expand Up @@ -510,6 +512,7 @@
try {
if (pushRevision !== keyAuth.revision || !keyAuth.hasMasterKey) return;
await IntegrationsApi.pushSecrets(jwt, integrations, content);
track("secrets_pushed", { integrations: integrations.length });
if (publicEnv.githubLocalMock) {
toast.success("Pushed to GitHub (local mock — no secrets sent)", {
richColors: true,
Expand Down
2 changes: 2 additions & 0 deletions apps/web/src/lib/stores/key.svelte.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { browser } from "$app/environment";
import { track } from "$lib/analytics/logdash";
import { AsymmetricCrypto } from "$lib/auth/asymmetric-crypto";
import { keystore } from "$lib/auth/keystore";
import { SymmetricCrypto } from "$lib/auth/symmetric-crypto";
Expand Down Expand Up @@ -57,6 +58,7 @@ export async function setUpPassphrase(passphrase: string) {
publicKey: keyPair.publicKey,
privateKeyEncrypted: encrypted,
});
track("signup_completed");

queueFTUX();
await loadUserData();
Expand Down
7 changes: 7 additions & 0 deletions apps/web/src/routes/+layout.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import { browser } from "$app/environment";
import { onMount } from "svelte";
import "../app.css";
import { identify } from "$lib/analytics/logdash";
import DeviceFlowApproverDialog from "$lib/device-flow/ui/DeviceFlowApproverDialog.svelte";
import { subscribeApproverStream } from "$lib/device-flow/device-flow-approver.svelte";
import { installAuthFetchInterceptor } from "$lib/auth/fetch-interceptor";
Expand All @@ -20,6 +21,12 @@
installAuthFetchInterceptor();
});

// Covers page load, sign-in and sign-out: userData is the single source of the signed-in user.
const analyticsUserId = $derived(auth.userData?.id ?? null);
$effect(() => {
identify(analyticsUserId);
});

$effect(() => {
if (!browser) return;
if (!auth.jwtToken || !keyAuth.hasMasterKey) return;
Expand Down
Loading
Loading