From 3d287ab14b315366d88f46a8e138a6ca378d6e92 Mon Sep 17 00:00:00 2001 From: Jonathan Gadea Harder Date: Fri, 8 May 2026 09:56:12 +0200 Subject: [PATCH 1/5] feat: add telemetry abstraction (#232) Introduces src/lib/telemetry.ts as a unified interface for analytics and error reporting. Backed by PostHog on both client and server with a no-op fallback when PUBLIC_POSTHOG_PROJECT_TOKEN is unset. - track(event, props?), pageview(route), captureError(err, ctx?) - identify(userId, traits?), reset(), shutdown() - hooks.server.ts: pageview tracking + error capture via abstraction - hooks.client.ts: error capture via abstraction - Graceful shutdown on SIGTERM --- src/hooks.client.ts | 5 ++- src/hooks.server.ts | 30 ++++++------- src/lib/telemetry.ts | 103 +++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 119 insertions(+), 19 deletions(-) create mode 100644 src/lib/telemetry.ts diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 70c4c21..18e8a41 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,6 +1,7 @@ +import { captureError } from '$lib/telemetry'; +import { PUBLIC_POSTHOG_HOST, PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public'; import type { HandleClientError } from '@sveltejs/kit'; import posthog from 'posthog-js'; -import { PUBLIC_POSTHOG_HOST, PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public'; export async function init() { posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, { @@ -13,6 +14,6 @@ export async function init() { } export const handleError: HandleClientError = async ({ error, status, message }) => { - posthog.captureException(error); + captureError(error, { status, message, source: 'client' }).catch(() => {}); return { message, status }; }; diff --git a/src/hooks.server.ts b/src/hooks.server.ts index 024f0ee..aa96896 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,6 @@ import type { Handle, HandleServerError } from '@sveltejs/kit'; import { PUBLIC_POSTHOG_HOST } from '$env/static/public'; -import { getPostHogClient } from '$lib/server/posthog'; +import { pageview, captureError, shutdown } from '$lib/telemetry'; export const handle: Handle = async ({ event, resolve }) => { const { pathname } = event.url; @@ -46,24 +46,20 @@ export const handle: Handle = async ({ event, resolve }) => { } } - return resolve(event); -}; + const response = await resolve(event); -export const handleError: HandleServerError = async ({ error, status, message, event }) => { - try { - const posthog = getPostHogClient(); - const distinctId = (event.locals as any)?.session?.user?.id ?? 'server'; + if (!pathname.startsWith('/api') && !pathname.startsWith('/_')) { + pageview(pathname).catch(() => {}); + } - posthog.capture({ - distinctId, - event: 'server_error', - properties: { - error: error instanceof Error ? error.message : String(error), - status, - message - } - }); - } catch {} + return response; +}; +export const handleError: HandleServerError = async ({ error, status, message }) => { + captureError(error, { status, message, source: 'server' }).catch(() => {}); return { message, status }; }; + +process.on('SIGTERM', () => { + shutdown().catch(() => {}); +}); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts new file mode 100644 index 0000000..e415230 --- /dev/null +++ b/src/lib/telemetry.ts @@ -0,0 +1,103 @@ +import { browser } from '$app/environment'; +import { PUBLIC_POSTHOG_HOST, PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public'; + +const enabled = !!PUBLIC_POSTHOG_PROJECT_TOKEN; + +export async function track(event: string, props?: Record) { + if (!enabled) return; + if (browser) { + const ph = await getClientPh(); + ph?.capture(event, props); + } else { + const ph = await getServerPh(); + ph?.capture({ distinctId: 'server', event, properties: props }); + } +} + +export async function pageview(route: string) { + if (!enabled) return; + if (browser) { + const ph = await getClientPh(); + ph?.capture('$pageview', { $current_url: route }); + } else { + const ph = await getServerPh(); + ph?.capture({ distinctId: 'server', event: '$pageview', properties: { $current_url: route } }); + } +} + +export async function captureError(err: unknown, ctx?: Record) { + if (!enabled) return; + if (browser) { + const ph = await getClientPh(); + ph?.captureException(err, ctx); + } else { + const ph = await getServerPh(); + ph?.capture({ + distinctId: 'server', + event: 'server_error', + properties: { + error: err instanceof Error ? err.message : String(err), + ...ctx + } + }); + } +} + +export async function identify(userId: string, traits?: Record) { + if (!enabled) return; + if (browser) { + const ph = await getClientPh(); + ph?.identify(userId, traits); + } else { + const ph = await getServerPh(); + ph?.identify({ distinctId: userId, properties: traits }); + } +} + +export async function reset() { + if (!enabled || !browser) return; + const ph = await getClientPh(); + ph?.reset(); +} + +export async function shutdown() { + if (!enabled || browser) return; + const ph = await getServerPh(); + await ph?.shutdown(); + serverPhInstance = null; +} + +let clientPhInstance: any = null; + +async function getClientPh() { + if (!browser) return null; + if (clientPhInstance) return clientPhInstance; + const mod = await import('posthog-js'); + clientPhInstance = mod.default; + if (!(clientPhInstance as any).__tilt_init) { + clientPhInstance.init(PUBLIC_POSTHOG_PROJECT_TOKEN, { + api_host: '/ingest', + ui_host: PUBLIC_POSTHOG_HOST, + defaults: '2026-01-30', + capture_exceptions: true, + person_profiles: 'identified_only' + }); + (clientPhInstance as any).__tilt_init = true; + } + return clientPhInstance; +} + +let serverPhInstance: any = null; + +async function getServerPh() { + if (browser) return null; + if (serverPhInstance) return serverPhInstance; + const { PostHog } = await import('posthog-node'); + serverPhInstance = new PostHog(PUBLIC_POSTHOG_PROJECT_TOKEN, { + host: PUBLIC_POSTHOG_HOST, + flushAt: 1, + flushInterval: 0, + personProfiles: 'identified_only' + }); + return serverPhInstance; +} From 580d4e6f1a59e1a19dce09a1bf04ee9fa0e26725 Mon Sep 17 00:00:00 2001 From: Jonathan Gadea Harder Date: Fri, 8 May 2026 12:47:15 +0200 Subject: [PATCH 2/5] fix: resolve lint errors and review feedback - Replace 'any' types with proper typed imports (clientPhInstance, serverPhInstance) - Fix import ordering in hooks.client.ts and hooks.server.ts (biome organizeImports) - Add capture_pageview: false to client PostHog config to prevent double-counting - Handle SIGINT alongside SIGTERM for graceful shutdown - Add error stack trace to server_error events - Guard shutdown() against initializing PostHog just to shut it down - Remove flushAt:1/flushInterval:0 to use default batching - Allow optional userId passthrough in track() --- .svelte-kit/ambient.d.ts | 25 ++++++++++++++---------- .svelte-kit/generated/client/app.js | 7 +++++-- .svelte-kit/generated/client/nodes/1.js | 2 +- .svelte-kit/generated/server/internal.js | 6 +++--- src/hooks.client.ts | 4 ++-- src/hooks.server.ts | 8 +++++--- src/lib/telemetry.ts | 19 +++++++++--------- 7 files changed, 41 insertions(+), 30 deletions(-) diff --git a/.svelte-kit/ambient.d.ts b/.svelte-kit/ambient.d.ts index 4e24073..dcc1835 100644 --- a/.svelte-kit/ambient.d.ts +++ b/.svelte-kit/ambient.d.ts @@ -81,7 +81,6 @@ declare module '$env/static/private' { export const OPENCODE_PROCESS_ROLE: string; export const SHLVL: string; export const HOME: string; - export const CI: string; export const HOMEBREW_PREFIX: string; export const FNM_DIR: string; export const LOGNAME: string; @@ -98,6 +97,13 @@ declare module '$env/static/private' { export const OPENCODE: string; export const COLORTERM: string; export const npm_node_execpath: string; + export const TEST: string; + export const VITEST: string; + export const NODE_ENV: string; + export const PROD: string; + export const DEV: string; + export const BASE_URL: string; + export const MODE: string; } /** @@ -134,10 +140,7 @@ declare module '$env/static/private' { * The above values will be the same _even if_ different values for `ENVIRONMENT` or `PUBLIC_BASE_URL` are set at runtime, as they are statically replaced in your code with their build time values. */ declare module '$env/static/public' { - export const PUBLIC_SUPABASE_ANON_KEY: string; - export const PUBLIC_SUPABASE_URL: string; - export const PUBLIC_POSTHOG_HOST: string; - export const PUBLIC_POSTHOG_PROJECT_TOKEN: string; + } /** @@ -230,7 +233,6 @@ declare module '$env/dynamic/private' { OPENCODE_PROCESS_ROLE: string; SHLVL: string; HOME: string; - CI: string; HOMEBREW_PREFIX: string; FNM_DIR: string; LOGNAME: string; @@ -247,6 +249,13 @@ declare module '$env/dynamic/private' { OPENCODE: string; COLORTERM: string; npm_node_execpath: string; + TEST: string; + VITEST: string; + NODE_ENV: string; + PROD: string; + DEV: string; + BASE_URL: string; + MODE: string; [key: `PUBLIC_${string}`]: undefined; [key: `${string}`]: string | undefined; } @@ -302,10 +311,6 @@ declare module '$env/dynamic/private' { */ declare module '$env/dynamic/public' { export const env: { - PUBLIC_SUPABASE_ANON_KEY: string; - PUBLIC_SUPABASE_URL: string; - PUBLIC_POSTHOG_HOST: string; - PUBLIC_POSTHOG_PROJECT_TOKEN: string; [key: `PUBLIC_${string}`]: string | undefined; } } diff --git a/.svelte-kit/generated/client/app.js b/.svelte-kit/generated/client/app.js index 83211c7..4f02e6d 100644 --- a/.svelte-kit/generated/client/app.js +++ b/.svelte-kit/generated/client/app.js @@ -1,3 +1,6 @@ +import * as client_hooks from '../../../src/hooks.client.ts'; + + export { matchers } from './matchers.js'; export const nodes = [ @@ -40,8 +43,8 @@ export const dictionary = { }; export const hooks = { - handleError: (({ error }) => { console.error(error) }), - + handleError: client_hooks.handleError || (({ error }) => { console.error(error) }), + init: client_hooks.init, reroute: (() => {}), transport: {} }; diff --git a/.svelte-kit/generated/client/nodes/1.js b/.svelte-kit/generated/client/nodes/1.js index 453cc99..7b65978 100644 --- a/.svelte-kit/generated/client/nodes/1.js +++ b/.svelte-kit/generated/client/nodes/1.js @@ -1 +1 @@ -export { default as component } from "../../../../node_modules/.pnpm/@sveltejs+kit@2.59.0_@sveltejs+vite-plugin-svelte@7.0.0_svelte@5.55.5_vite@8.0.10_@type_72ff1850efe0a9b28484ed03a4d12607/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file +export { default as component } from "../../../../node_modules/.pnpm/@sveltejs+kit@2.59.0_@opentelemetry+api@1.9.1_@sveltejs+vite-plugin-svelte@7.0.0_svelte_ffcaade9fdd02ff53867e36bd9218907/node_modules/@sveltejs/kit/src/runtime/components/svelte-5/error.svelte"; \ No newline at end of file diff --git a/.svelte-kit/generated/server/internal.js b/.svelte-kit/generated/server/internal.js index 560f4ba..717b6f2 100644 --- a/.svelte-kit/generated/server/internal.js +++ b/.svelte-kit/generated/server/internal.js @@ -3,7 +3,7 @@ import root from '../root.js'; import { set_building, set_prerendering } from '__sveltekit/environment'; import { set_assets } from '$app/paths/internal/server'; import { set_manifest, set_read_implementation } from '__sveltekit/server'; -import { set_private_env, set_public_env } from '../../../node_modules/.pnpm/@sveltejs+kit@2.59.0_@sveltejs+vite-plugin-svelte@7.0.0_svelte@5.55.5_vite@8.0.10_@type_72ff1850efe0a9b28484ed03a4d12607/node_modules/@sveltejs/kit/src/runtime/shared-server.js'; +import { set_private_env, set_public_env } from '../../../node_modules/.pnpm/@sveltejs+kit@2.59.0_@opentelemetry+api@1.9.1_@sveltejs+vite-plugin-svelte@7.0.0_svelte_ffcaade9fdd02ff53867e36bd9218907/node_modules/@sveltejs/kit/src/runtime/shared-server.js'; export const options = { app_template_contains_nonce: false, @@ -25,7 +25,7 @@ export const options = { app: ({ head, body, assets, nonce, env }) => "\n\n\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t\n\t\t" + head + "\n\t\n\t\n\t\t
" + body + "
\n\t\n\n", error: ({ status, message }) => "\n\n\t\n\t\t\n\t\t" + message + "\n\n\t\t\n\t\n\t\n\t\t
\n\t\t\t" + status + "\n\t\t\t
\n\t\t\t\t

" + message + "

\n\t\t\t
\n\t\t
\n\t\n\n" }, - version_hash: "t6rkup" + version_hash: "1yejf0e" }; export async function get_hooks() { @@ -34,7 +34,7 @@ export async function get_hooks() { let handleError; let handleValidationError; let init; - + ({ handle, handleFetch, handleError, handleValidationError, init } = await import("../../../src/hooks.server.ts")); let reroute; let transport; diff --git a/src/hooks.client.ts b/src/hooks.client.ts index 18e8a41..2d6c025 100644 --- a/src/hooks.client.ts +++ b/src/hooks.client.ts @@ -1,7 +1,7 @@ -import { captureError } from '$lib/telemetry'; -import { PUBLIC_POSTHOG_HOST, PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public'; import type { HandleClientError } from '@sveltejs/kit'; import posthog from 'posthog-js'; +import { PUBLIC_POSTHOG_HOST, PUBLIC_POSTHOG_PROJECT_TOKEN } from '$env/static/public'; +import { captureError } from '$lib/telemetry'; export async function init() { posthog.init(PUBLIC_POSTHOG_PROJECT_TOKEN, { diff --git a/src/hooks.server.ts b/src/hooks.server.ts index aa96896..e2fb0d6 100644 --- a/src/hooks.server.ts +++ b/src/hooks.server.ts @@ -1,6 +1,6 @@ import type { Handle, HandleServerError } from '@sveltejs/kit'; import { PUBLIC_POSTHOG_HOST } from '$env/static/public'; -import { pageview, captureError, shutdown } from '$lib/telemetry'; +import { captureError, pageview, shutdown } from '$lib/telemetry'; export const handle: Handle = async ({ event, resolve }) => { const { pathname } = event.url; @@ -60,6 +60,8 @@ export const handleError: HandleServerError = async ({ error, status, message }) return { message, status }; }; -process.on('SIGTERM', () => { - shutdown().catch(() => {}); +['SIGTERM', 'SIGINT'].forEach((signal) => { + process.on(signal, () => { + shutdown().catch(() => {}); + }); }); diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index e415230..9a0e546 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -10,7 +10,8 @@ export async function track(event: string, props?: Record) { ph?.capture(event, props); } else { const ph = await getServerPh(); - ph?.capture({ distinctId: 'server', event, properties: props }); + const distinctId = props?.userId ? String(props.userId) : 'server'; + ph?.capture({ distinctId, event, properties: props }); } } @@ -37,6 +38,7 @@ export async function captureError(err: unknown, ctx?: Record) event: 'server_error', properties: { error: err instanceof Error ? err.message : String(err), + stack: err instanceof Error ? err.stack : undefined, ...ctx } }); @@ -62,32 +64,33 @@ export async function reset() { export async function shutdown() { if (!enabled || browser) return; - const ph = await getServerPh(); - await ph?.shutdown(); + if (!serverPhInstance) return; + await serverPhInstance.shutdown(); serverPhInstance = null; } -let clientPhInstance: any = null; +let clientPhInstance: ReturnType | null = null; async function getClientPh() { if (!browser) return null; if (clientPhInstance) return clientPhInstance; const mod = await import('posthog-js'); clientPhInstance = mod.default; - if (!(clientPhInstance as any).__tilt_init) { + if (!(clientPhInstance as Record).__tilt_init) { clientPhInstance.init(PUBLIC_POSTHOG_PROJECT_TOKEN, { api_host: '/ingest', ui_host: PUBLIC_POSTHOG_HOST, defaults: '2026-01-30', + capture_pageview: false, capture_exceptions: true, person_profiles: 'identified_only' }); - (clientPhInstance as any).__tilt_init = true; + (clientPhInstance as Record).__tilt_init = true; } return clientPhInstance; } -let serverPhInstance: any = null; +let serverPhInstance: Awaited> | null = null; async function getServerPh() { if (browser) return null; @@ -95,8 +98,6 @@ async function getServerPh() { const { PostHog } = await import('posthog-node'); serverPhInstance = new PostHog(PUBLIC_POSTHOG_PROJECT_TOKEN, { host: PUBLIC_POSTHOG_HOST, - flushAt: 1, - flushInterval: 0, personProfiles: 'identified_only' }); return serverPhInstance; From 5fda5fccea5507dd86dd5a4630a638c388769e7a Mon Sep 17 00:00:00 2001 From: Jonathan Gadea Harder Date: Fri, 8 May 2026 13:39:41 +0200 Subject: [PATCH 3/5] fix: PostHog type annotations use direct import types --- src/lib/telemetry.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 9a0e546..23cbbca 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -69,7 +69,7 @@ export async function shutdown() { serverPhInstance = null; } -let clientPhInstance: ReturnType | null = null; +let clientPhInstance: import('posthog-js').default | null = null; async function getClientPh() { if (!browser) return null; @@ -90,7 +90,7 @@ async function getClientPh() { return clientPhInstance; } -let serverPhInstance: Awaited> | null = null; +let serverPhInstance: import('posthog-node').PostHog | null = null; async function getServerPh() { if (browser) return null; From 7b827fb65aa7baa2c22d489314035fccda5316e9 Mon Sep 17 00:00:00 2001 From: Jonathan Gadea Harder Date: Fri, 8 May 2026 13:51:15 +0200 Subject: [PATCH 4/5] fix: use Record type instead of posthog import types --- src/lib/telemetry.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/lib/telemetry.ts b/src/lib/telemetry.ts index 23cbbca..9bb98d8 100644 --- a/src/lib/telemetry.ts +++ b/src/lib/telemetry.ts @@ -69,7 +69,7 @@ export async function shutdown() { serverPhInstance = null; } -let clientPhInstance: import('posthog-js').default | null = null; +let clientPhInstance: Record | null = null; async function getClientPh() { if (!browser) return null; From e013cbd73fbdee82dbb43dd5bfb94b3674601a61 Mon Sep 17 00:00:00 2001 From: Jonathan Gadea Harder Date: Fri, 8 May 2026 14:24:47 +0200 Subject: [PATCH 5/5] perf(ci): merge 3 jobs into 1, single pnpm install --- .github/workflows/ci.yml | 77 +++++----------------------------------- 1 file changed, 9 insertions(+), 68 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index fee5970..b5524c8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -1,4 +1,4 @@ -name: CI/CD Pipeline +name: CI on: push: @@ -7,8 +7,8 @@ on: branches: [main, develop] jobs: - lint-and-test: - name: Lint and Test + ci: + name: CI runs-on: self-hosted env: PUBLIC_POSTHOG_PROJECT_TOKEN: ${{ vars.PUBLIC_POSTHOG_PROJECT_TOKEN }} @@ -32,77 +32,18 @@ jobs: - name: Install dependencies run: pnpm install --frozen-lockfile - - name: Run linter + - name: Lint run: pnpm run lint - - name: Run typecheck + - name: Typecheck run: pnpm run typecheck - - name: Run tests + - name: Test run: pnpm run test - security: - name: Security Audit - runs-on: self-hosted - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - - name: Run audit - run: pnpm audit --audit-level=moderate - continue-on-error: true - - code-quality: - name: Code Quality - runs-on: self-hosted - env: - PUBLIC_POSTHOG_PROJECT_TOKEN: ${{ vars.PUBLIC_POSTHOG_PROJECT_TOKEN }} - PUBLIC_POSTHOG_HOST: ${{ vars.PUBLIC_POSTHOG_HOST }} - - steps: - - name: Checkout code - uses: actions/checkout@v4 - - - name: Install pnpm - uses: pnpm/action-setup@v4 - with: - version: 10 - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - cache: 'pnpm' - - - name: Install dependencies - run: pnpm install --frozen-lockfile - - name: Build run: pnpm run build - notify: - name: Notify - runs-on: self-hosted - needs: [lint-and-test, security, code-quality] - if: always() - - steps: - - name: Check status - run: | - echo "Pipeline status: ${{ job.status }}" - echo "Build completed" + - name: Audit + run: pnpm audit --audit-level=moderate + continue-on-error: true