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
7 changes: 7 additions & 0 deletions app/pages/cli/authorize.vue
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,8 @@ definePageMeta({ layout: 'auth', middleware: ['session'] })

const route = useRoute()
const { loggedIn } = useUserSession()
const recordSignup = useSignupEvents()
const signupEntry = computed(() => userCode.value ? 'device' as const : 'loopback' as const)

const status = ref<'loading' | 'ready' | 'done' | 'error'>('loading')
const error = ref('')
Expand Down Expand Up @@ -44,6 +46,7 @@ async function initAuthorize() {
return

try {
recordSignup({ stage: 'cli', outcome: 'viewed', entry: signupEntry.value })
if (userCode.value) {
device.value = await apiFetch(lookupUrl, {
query: { user_code: userCode.value },
Expand All @@ -65,9 +68,11 @@ async function initAuthorize() {
v: version.value,
},
})
recordSignup({ stage: 'cli', outcome: 'authorized', entry: 'loopback' })
window.location.replace(response.redirect)
}
catch (err) {
recordSignup({ stage: 'cli', outcome: 'failed', entry: signupEntry.value })
status.value = 'error'
error.value = err instanceof Error ? err.message : 'Authorization failed'
}
Expand All @@ -84,8 +89,10 @@ async function authorizeDevice() {
body: { user_code: device.value.user_code },
})
status.value = 'done'
recordSignup({ stage: 'cli', outcome: 'authorized', entry: 'device' })
}
catch (err) {
recordSignup({ stage: 'cli', outcome: 'failed', entry: 'device' })
status.value = 'error'
error.value = err instanceof Error ? err.message : 'Device authorization failed'
}
Expand Down
6 changes: 4 additions & 2 deletions checks/_helpers/collectors.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { readdir, readFile } from 'node:fs/promises'
import { basename, join } from 'node:path'
import { readBoundedResponseText, runCheckCommand } from '@harlan-zw/nuxt-checkin/external'
import { ANALYTICS_ACCOUNT_TAG, buildCopyQuery, buildCopyTotalsQuery, buildWorkersQuery, collectWorkflowRuns, parseWorkflowName, runListArgs, summarizeCopies, summarizeWorkflowRuns } from './observability.mjs'
import { buildSignupQuery, summarizeSignupEvents } from './signup-analytics.ts'

const resources = Object.fromEntries(['github-auth', 'production-ref', 'git', 'deploy', 'ci', 'd1', 'workers', 'analytics'].map(key => [key, {}]))

Expand Down Expand Up @@ -343,10 +344,11 @@ export function collectAnalytics(context) {
throw new Error('Analytics Engine copy evidence is unavailable.')
return rows
}
const [totalsRows, topRows] = await Promise.all([
const [totalsRows, topRows, signupRows] = await Promise.all([
readRows(buildCopyTotalsQuery(context.since.toISOString(), context.now.toISOString())),
readRows(buildCopyQuery(context.since.toISOString(), context.now.toISOString())),
readRows(buildSignupQuery(context.since.toISOString(), context.now.toISOString())),
])
return { commandCopies: summarizeCopies(totalsRows, topRows) }
return { commandCopies: summarizeCopies(totalsRows, topRows), signup: summarizeSignupEvents(signupRows) }
})
}
4 changes: 2 additions & 2 deletions checks/_helpers/observability.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ export function analyticsTimestamp(iso) {
* so a busy day does not read as a quiet one.
*/
export function buildCopyQuery(sinceIso, nowIso) {
return `SELECT blob2 AS mode, blob3 AS kind, blob4 AS slug, sum(_sample_interval * double1) AS copies FROM ${COPY_DATASET} WHERE timestamp >= toDateTime('${analyticsTimestamp(sinceIso)}') AND timestamp < toDateTime('${analyticsTimestamp(nowIso)}') GROUP BY mode, kind, slug ORDER BY copies DESC LIMIT 50`
return `SELECT blob2 AS mode, blob3 AS kind, blob4 AS slug, sum(_sample_interval * double1) AS copies FROM ${COPY_DATASET} WHERE double1 > 0 AND timestamp >= toDateTime('${analyticsTimestamp(sinceIso)}') AND timestamp < toDateTime('${analyticsTimestamp(nowIso)}') GROUP BY mode, kind, slug ORDER BY copies DESC LIMIT 50`
}

/**
Expand All @@ -268,7 +268,7 @@ export function buildCopyQuery(sinceIso, nowIso) {
* still reports its full count.
*/
export function buildCopyTotalsQuery(sinceIso, nowIso) {
return `SELECT blob2 AS mode, sum(_sample_interval * double1) AS copies FROM ${COPY_DATASET} WHERE timestamp >= toDateTime('${analyticsTimestamp(sinceIso)}') AND timestamp < toDateTime('${analyticsTimestamp(nowIso)}') GROUP BY mode`
return `SELECT blob2 AS mode, sum(_sample_interval * double1) AS copies FROM ${COPY_DATASET} WHERE double1 > 0 AND timestamp >= toDateTime('${analyticsTimestamp(sinceIso)}') AND timestamp < toDateTime('${analyticsTimestamp(nowIso)}') GROUP BY mode`
}

/**
Expand Down
18 changes: 18 additions & 0 deletions checks/_helpers/signup-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { z } from 'zod'

const rowSchema = z.object({
stage: z.enum(['oauth', 'discover', 'email', 'cli']),
outcome: z.string(),
entry: z.string(),
choice: z.string(),
events: z.coerce.number().finite().nonnegative(),
})

export function buildSignupQuery(since: string, until: string): string {
const timestamp = (value: string) => new Date(value).toISOString().slice(0, 19).replace('T', ' ')
return `SELECT blob1 AS stage, blob6 AS outcome, blob4 AS entry, blob7 AS choice, sum(_sample_interval * double3) AS events FROM skilld_web_v1 WHERE blob3 = 'signup' AND timestamp >= toDateTime('${timestamp(since)}') AND timestamp < toDateTime('${timestamp(until)}') GROUP BY stage, outcome, entry, choice ORDER BY stage, entry, outcome, choice`
}

export function summarizeSignupEvents(rows: unknown) {
return { unit: 'events' as const, stages: z.array(rowSchema).parse(rows) }
}
13 changes: 13 additions & 0 deletions layers/identity/app/composables/useSignupEvents.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import type { SignupBrowserEvent } from '#shared/signup-analytics'

export function useSignupEvents() {
const { loggedIn } = useUserSession()
return (body: SignupBrowserEvent): void => {
if (!loggedIn.value)
return
// Keep the action independent of analytics and survive same-tab redirects.
void $fetch('/api/events/signup', { method: 'POST', body, retry: false, keepalive: true }).catch(() => {
console.warn('[signup-analytics] Could not record signup event')
})
}
}
14 changes: 13 additions & 1 deletion layers/identity/app/pages/me/index.vue
Original file line number Diff line number Diff line change
Expand Up @@ -8,13 +8,15 @@ import type {
IdentitySubscriptionRef,
} from '../../../shared/contracts/account'
import type { StarsSyncResponse } from '../../utils/sync-starred-repos'
import { signupEmailChoice } from '#shared/signup-analytics'
import { SKILL_VALIDATION_COPY } from '#shared/skill-validation-copy'
import { accountDeletionConfirmed } from '../../../shared/contracts/account'
import SkillgenRepositories from '../../components/_SkillgenRepositories.vue'
import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account'
import { syncStarredRepos } from '../../utils/sync-starred-repos'

definePageMeta({ layout: 'account', middleware: ['auth'] })
const recordSignup = useSignupEvents()

interface LikedSkill {
owner: string
Expand Down Expand Up @@ -147,7 +149,11 @@ const emailMissingAddress = computed(() =>
async function saveEmail() {
if (emailMissingAddress.value || saveEmailMutation.pending.value)
return
const saved = await saveEmailMutation.mutateSafe({ ...emailForm })
const submitted = { ...emailForm }
const saved = await saveEmailMutation.mutateSafe(submitted)
recordSignup(saved._tag === 'ok'
? { stage: 'email', outcome: 'saved', entry: 'dashboard', choice: signupEmailChoice(submitted.weekly_opt_in, submitted.email_opt_in) }
: { stage: 'email', outcome: 'failed', entry: 'dashboard' })
if (saved._tag === 'ok')
showEmail.value = false
}
Expand Down Expand Up @@ -181,6 +187,12 @@ async function clearWelcomeQuery() {
await navigateTo({ path: route.path, query: { ...route.query, welcome: undefined } }, { replace: true })
}
onMounted(() => {
if (view.value === 'email')
recordSignup({ stage: 'email', outcome: 'viewed', entry: 'dashboard' })
watch(view, (next) => {
if (next === 'email')
recordSignup({ stage: 'email', outcome: 'viewed', entry: 'dashboard' })
})
if (route.query.welcome === '1') {
toast.add({
title: 'You\'re all set',
Expand Down
10 changes: 8 additions & 2 deletions layers/identity/app/pages/onboarding/discover.vue
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { syncStarredRepos } from '../../utils/sync-starred-repos'
definePageMeta({ layout: 'auth', middleware: ['auth'] })

const { user } = useUserSession()
const recordSignup = useSignupEvents()

interface Skill {
name: string
Expand Down Expand Up @@ -61,8 +62,10 @@ async function syncStars() {
)
await refresh()
preselectAll()
recordSignup({ stage: 'discover', outcome: 'imported', entry: 'onboarding' })
}
catch (e) {
recordSignup({ stage: 'discover', outcome: 'import-failed', entry: 'onboarding' })
syncError.value = (e as { statusMessage?: string, message?: string }).statusMessage
?? (e as Error).message
?? 'Sync failed'
Expand All @@ -73,6 +76,7 @@ async function syncStars() {
}

onMounted(() => {
recordSignup({ stage: 'discover', outcome: 'viewed', entry: 'onboarding' })
if (data.value && !data.value.syncedAt) {
void syncStars()
return
Expand All @@ -88,6 +92,7 @@ async function watchSelected() {
return
actionFailed.clear('start watching those repos')
if (!selected.value.size) {
recordSignup({ stage: 'discover', outcome: 'continued', entry: 'onboarding' })
await navigateTo('/onboarding/email')
return
}
Expand All @@ -101,6 +106,7 @@ async function watchSelected() {
body: { source: 'star-import', repos },
}).catch(actionFailed('start watching those repos'))
submitting.value = false
recordSignup({ stage: 'discover', outcome: saved ? 'continued' : 'watch-failed', entry: 'onboarding' })
if (saved)
await navigateTo('/onboarding/email')
}
Expand Down Expand Up @@ -163,7 +169,7 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
<template v-else>
None of your starred repos with "skill" in the name are in the registry yet.
</template>
<NuxtLink to="/onboarding/email" class="inline-flex min-h-11 items-center underline">
<NuxtLink to="/onboarding/email" class="inline-flex min-h-11 items-center underline" @click="recordSignup({ stage: 'discover', outcome: 'skipped', entry: 'onboarding' })">
skip ahead
</NuxtLink>.
</div>
Expand Down Expand Up @@ -222,7 +228,7 @@ useSeoMeta({ title: 'Discover skills', robots: 'noindex' })
</template>

<div class="mt-8 flex items-center justify-between">
<NuxtLink to="/onboarding/email" class="inline-flex min-h-11 min-w-11 items-center font-mono text-sm text-muted hover:text-default underline">
<NuxtLink to="/onboarding/email" class="inline-flex min-h-11 min-w-11 items-center font-mono text-sm text-muted hover:text-default underline" @click="recordSignup({ stage: 'discover', outcome: 'skipped', entry: 'onboarding' })">
Skip
</NuxtLink>
<UButton
Expand Down
13 changes: 11 additions & 2 deletions layers/identity/app/pages/onboarding/email.vue
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
<script setup lang="ts">
import type { IdentityEmailPatchBody, IdentityMutationResponse } from '../../../shared/contracts/account'
import { signupEmailChoice } from '#shared/signup-analytics'
import { identityAccountQueries, identityAccountQueryOptions } from '../../queries/account'

definePageMeta({ layout: 'auth', middleware: ['auth'] })

const { data: me, error: accountError, status: accountStatus, refresh: retryAccount } = await useNuxtRpcQuery(identityAccountQueries.me(), identityAccountQueryOptions)
const { fetchSession } = useAuth()
const recordSignup = useSignupEvents()
onMounted(() => recordSignup({ stage: 'email', outcome: 'viewed', entry: 'onboarding' }))

const email = ref(me.value?.digest_email || me.value?.email || '')
// A stored address records a deliberate choice, including a previous opt-out.
Expand Down Expand Up @@ -51,12 +54,18 @@ async function finish() {
email_opt_in: optIn.value,
weekly_opt_in: weeklyOptIn.value,
})
if (saved._tag === 'err')
if (saved._tag === 'err') {
recordSignup({ stage: 'email', outcome: 'failed', entry: 'onboarding' })
return
}

const onboarded = await finishOnboardingMutation.mutateSafe()
if (onboarded._tag === 'err')
if (onboarded._tag === 'err') {
recordSignup({ stage: 'email', outcome: 'completion-failed', entry: 'onboarding' })
return
}

recordSignup({ stage: 'email', outcome: 'saved', entry: 'onboarding', choice: signupEmailChoice(weeklyOptIn.value, optIn.value) })

await fetchSession()
await navigateTo('/me?welcome=1')
Expand Down
5 changes: 4 additions & 1 deletion layers/identity/server/api/cli/device/poll.post.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { DevicePollInputSchema, DevicePollResponseSchema } from 'skilld-protocol/wire'
import { defineApiHandler } from '#shared/server/handler'
import { issueSession, presentTokenResponse } from '../../../utils/cli-tokens'
import { emitSignupEvent } from '../../../utils/signup-analytics'
import { getUserById } from '../../../utils/users'

interface DeviceRow {
Expand Down Expand Up @@ -49,6 +50,8 @@ export default defineApiHandler({
`UPDATE cli_device_sessions SET status = 'expired' WHERE device_code = ?1`,
).bind(row.device_code).run()

return { status: 'authorized' as const, tokens: presentTokenResponse(tokens, user.login) }
const response = { status: 'authorized' as const, tokens: presentTokenResponse(tokens, user.login) }
emitSignupEvent(event, { stage: 'cli', outcome: 'connected', entry: 'device' })
return response
},
})
5 changes: 4 additions & 1 deletion layers/identity/server/api/cli/oauth/token.post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { OauthTokenInputSchema, TokenResponseSchema } from 'skilld-protocol/wire
import { z } from 'zod'
import { defineApiHandler } from '#shared/server/handler'
import { issueSession, presentTokenResponse, sha256Base64Url } from '../../../utils/cli-tokens'
import { emitSignupEvent } from '../../../utils/signup-analytics'
import { getUserById } from '../../../utils/users'

interface AuthCodeRow {
Expand Down Expand Up @@ -55,6 +56,8 @@ export default defineApiHandler({
deviceLabel: body.device_label,
})

return presentTokenResponse(session, user.login)
const response = presentTokenResponse(session, user.login)
emitSignupEvent(event, { stage: 'cli', outcome: 'connected', entry: 'loopback' })
return response
},
})
13 changes: 13 additions & 0 deletions layers/identity/server/api/events/signup.post.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineApiHandler } from '#shared/server/handler'
import { SignupBrowserEvent } from '#shared/signup-analytics'
import { identityMutationResponseSchema } from '../../../shared/contracts/account'
import { authenticated } from '../../policies/authenticated'
import { emitSignupEvent } from '../../utils/signup-analytics'

export default defineApiHandler({
schema: SignupBrowserEvent,
policy: [authenticated],
response: identityMutationResponseSchema,
handler: ({ event, body }) => emitSignupEvent(event, body),
presenter: () => ({ ok: true as const }),
})
9 changes: 8 additions & 1 deletion layers/identity/server/routes/auth/github.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@ import type { H3Event } from 'h3'
import { useSession } from 'h3'
import { z } from 'zod'
import { parseReturnTo } from '#shared/return-to'
import { signupEntry } from '#shared/signup-analytics'
import { sendEmailWithEnv, signUnsubToken } from '../../utils/email'
import { fetchVerifiedPrimaryEmail } from '../../utils/github-emails'
import { ownedRepoScanWarning, scanOwnedRepos } from '../../utils/scan-owned-repos'
import { emitSignupEvent } from '../../utils/signup-analytics'
import { sendSkillValidationSummary } from '../../utils/skill-validation-email'
import { upsertUserFromGithub } from '../../utils/users'
import { handleWatchAction } from '../../utils/watch-actions'
Expand Down Expand Up @@ -132,6 +134,8 @@ const githubHandler = defineOAuthGitHubEventHandler({

await intent.clear()

emitSignupEvent(event, { stage: 'oauth', outcome: 'succeeded', entry: signupEntry(action, returnTo) })

if (returnTo)
return sendRedirect(event, returnTo)

Expand All @@ -148,6 +152,7 @@ async function loginFailed(event: H3Event) {
const intent = await loginIntentSession(event)
const params = new URLSearchParams({ error: 'oauth' })
const returnTo = parseReturnTo(intent.data.returnTo, '')
emitSignupEvent(event, { stage: 'oauth', outcome: 'failed', entry: signupEntry(intent.data.action ?? '', returnTo) })
if (returnTo)
params.set('return_to', returnTo)
if (typeof intent.data.action === 'string' && intent.data.action)
Expand All @@ -174,7 +179,9 @@ export default defineEventHandler(async (event) => {
const action = ['like-skill', 'watch-skill', 'watch-collection'].includes(String(query.action))
? String(query.action)
: ''
await intent.update({ returnTo: parseReturnTo(query.return_to, ''), action })
const returnTo = parseReturnTo(query.return_to, '')
await intent.update({ returnTo, action })
emitSignupEvent(event, { stage: 'oauth', outcome: 'started', entry: signupEntry(action, returnTo) })
}
// Provider network failures can throw before the OAuth error callback runs.
return githubHandler(event).catch(() => loginFailed(event))
Expand Down
25 changes: 25 additions & 0 deletions layers/identity/server/utils/signup-analytics.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import type { H3Event } from 'h3'
import type { SignupEvent } from '#shared/signup-analytics'
import { signupDataPoint } from '#shared/signup-analytics'

export function recordSignupEvent(
dataset: Pick<AnalyticsEngineDataset, 'writeDataPoint'> | undefined,
point: SignupEvent,
onFailure: (cause: unknown) => void,
): void {
// Analytics failure must never change sign-in or an account mutation.
try {
if (!dataset)
throw new Error('Signup analytics binding missing')
dataset.writeDataPoint(signupDataPoint(point))
}
catch (cause) {
onFailure(cause)
}
}

export function emitSignupEvent(event: H3Event, point: SignupEvent): void {
recordSignupEvent(event.context.platform?.env?.SKILLD_WEB_ANALYTICS, point, () => {
console.warn('[signup-analytics] Could not record signup event')
})
}
8 changes: 5 additions & 3 deletions layers/marketing/content/pages/privacy.md
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
---
title: Privacy
description: What skilld.dev stores, why it needs the data, how long it keeps it, and how you delete your account.
label: Last updated 8 October 2026
updatedAt: 2026-10-08
label: Last updated 11 October 2026
updatedAt: 2026-10-11
---

## In short

- You can browse skilld.dev and run Skills without an account.
- skilld.dev counts page views, demo views, command copies, and demo link copies. The counts hold no name, IP address, or cookie. It loads no advertising scripts.
- skilld.dev counts page views, demo views, command copies, demo link copies, and sign-in steps. The counts hold no name, IP address, or cookie. It loads no advertising scripts.
- An account is optional. You sign in with [GitHub](https://github.com) to like Skills, watch Repositories, get email, or connect the skilld CLI.
- You can delete your account from your dashboard at any time.
- The skilld CLI sends no telemetry.
Expand Down Expand Up @@ -50,6 +50,8 @@ Demo counts also record the displayed demo, the surface, and whether you copied

The skilld CLI sends no telemetry. skilld.dev accepts an anonymous run count from a CLI at the same address, under the same rules, and drops any account ID the request carries.

Sign-in counts record a fixed entry category, a setup step, and its outcome. Steps cover GitHub sign-in, star import, email choices, and browser authorization of the CLI. Email saves count which email types you chose, without storing your address. Counts contain no account ID, GitHub login, authorization code, return page, or query string. They use the same 90-day Analytics Engine storage. Repeat visits count again, so these counts measure attempts, not individual users. A blocked request or a closed tab can leave a count missing.

### Cookies

skilld.dev sets these cookies and no others:
Expand Down
Loading
Loading