See the best posts from any Bluesky account
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

Add server-side PostHog pageview tracking via middleware

Captures $pageview events for HTML responses with real client IP
from Cloudflare's CF-Connecting-IP header. Authenticated users are
tracked by DID with identify(); anonymous visitors use session ID.
Tracking is disabled when POSTHOG_API_KEY is unset.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>

+143
+5
.env.example
··· 31 31 # Backfill cap (number of posts; defaults to 10000) 32 32 BACKFILL_MAX_POSTS=10000 33 33 34 + # PostHog (analytics) 35 + # Tracking is disabled when POSTHOG_API_KEY is unset. 36 + POSTHOG_API_KEY= 37 + POSTHOG_HOST=https://ph.btao.org 38 + 34 39 # OpenTelemetry (traces to Axiom) 35 40 # Tracing is disabled when OTEL_EXPORTER_OTLP_ENDPOINT is unset. 36 41 # OTEL_SERVICE_NAME is set per-process in docker-compose.yml (web/worker/jetstream).
+1
adonisrc.ts
··· 67 67 () => import('#providers/atproto_provider'), 68 68 () => import('#providers/atproto_oauth_provider'), 69 69 () => import('#providers/clickhouse_provider'), 70 + () => import('#providers/posthog_provider'), 70 71 () => import('@adonisjs/otel/otel_provider'), 71 72 ], 72 73
+65
app/middleware/posthog_middleware.ts
··· 1 + import { type HttpContext } from '@adonisjs/core/http' 2 + import { type NextFn } from '@adonisjs/core/types/http' 3 + import { getPostHogClient } from '#services/posthog' 4 + 5 + export default class PostHogMiddleware { 6 + async handle(ctx: HttpContext, next: NextFn) { 7 + const output = await next() 8 + 9 + const posthog = getPostHogClient() 10 + if (!posthog) return output 11 + 12 + // Only track HTML page views, not API calls or assets 13 + const method = ctx.request.method() 14 + if (method !== 'GET') return output 15 + 16 + const contentType = ctx.response.getHeader('content-type') 17 + if (typeof contentType === 'string' && !contentType.includes('text/html')) return output 18 + 19 + const url = ctx.request.url(true) 20 + const userAgent = ctx.request.header('user-agent') || '' 21 + 22 + // Real client IP behind Cloudflare 23 + const ip = 24 + ctx.request.header('cf-connecting-ip') || 25 + ctx.request.header('x-forwarded-for')?.split(',')[0]?.trim() || 26 + ctx.request.ip() 27 + 28 + // Use DID for authenticated users, session ID for anonymous visitors 29 + let distinctId: string 30 + let isAuthenticated = false 31 + try { 32 + await ctx.auth.check() 33 + if (ctx.auth.isAuthenticated && ctx.auth.user) { 34 + distinctId = ctx.auth.user.did 35 + isAuthenticated = true 36 + } else { 37 + distinctId = ctx.session.sessionId 38 + } 39 + } catch { 40 + distinctId = ctx.session.sessionId 41 + } 42 + 43 + posthog.capture({ 44 + distinctId, 45 + event: '$pageview', 46 + properties: { 47 + $current_url: `${process.env.APP_URL}${url}`, 48 + $ip: ip, 49 + $raw_user_agent: userAgent, 50 + $referrer: ctx.request.header('referer') || '', 51 + }, 52 + }) 53 + 54 + if (isAuthenticated && ctx.auth.user) { 55 + posthog.identify({ 56 + distinctId: ctx.auth.user.did, 57 + properties: { 58 + handle: ctx.auth.user.handle, 59 + }, 60 + }) 61 + } 62 + 63 + return output 64 + } 65 + }
+26
app/services/posthog.ts
··· 1 + import { PostHog } from 'posthog-node' 2 + 3 + let client: PostHog | null = null 4 + 5 + export function getPostHogClient(): PostHog | null { 6 + if (client) return client 7 + 8 + const apiKey = process.env.POSTHOG_API_KEY 9 + if (!apiKey) return null 10 + 11 + client = new PostHog(apiKey, { 12 + host: process.env.POSTHOG_HOST || 'https://ph.btao.org', 13 + // Disable geoip on PostHog's side — we send $ip from Cloudflare's 14 + // CF-Connecting-IP header so PostHog can geolocate from that. 15 + disableGeoip: false, 16 + }) 17 + 18 + return client 19 + } 20 + 21 + export async function shutdownPostHog(): Promise<void> { 22 + if (client) { 23 + client.shutdown() 24 + client = null 25 + } 26 + }
+2
docker-compose.yml
··· 11 11 LOG_LEVEL: info 12 12 SESSION_DRIVER: cookie 13 13 QUEUE_DRIVER: database 14 + POSTHOG_API_KEY: ${POSTHOG_API_KEY:-} 15 + POSTHOG_HOST: ${POSTHOG_HOST:-https://ph.btao.org} 14 16 OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT} 15 17 OTEL_EXPORTER_OTLP_HEADERS: ${OTEL_EXPORTER_OTLP_HEADERS} 16 18
+1
package.json
··· 90 90 "better-sqlite3": "^12.8.0", 91 91 "edge.js": "^6.5.0", 92 92 "luxon": "^3.7.2", 93 + "posthog-node": "^5.29.2", 93 94 "reflect-metadata": "^0.2.2", 94 95 "tailwindcss": "^4.2.2" 95 96 },
+23
pnpm-lock.yaml
··· 62 62 luxon: 63 63 specifier: ^3.7.2 64 64 version: 3.7.2 65 + posthog-node: 66 + specifier: ^5.29.2 67 + version: 5.29.2(rxjs@7.8.2) 65 68 reflect-metadata: 66 69 specifier: ^0.2.2 67 70 version: 0.2.2 ··· 1536 1539 1537 1540 '@poppinss/validator-lite@2.1.2': 1538 1541 resolution: {integrity: sha512-UhSG1ouT6r67VbEFHK/8ax3EMZYHioew9PqGmEZjV41G15aPZi6cyhXtBVvF9xqkHMflA5V680k7bQzV0kfD5w==} 1542 + 1543 + '@posthog/core@1.25.2': 1544 + resolution: {integrity: sha512-h2FO7ut/BbfwpAXWpwdDHTzQgUo9ibDFEs6ZO+3cI3KPWQt5XwczK1OLAuPprcjm8T/jl0SH8jSFo5XdU4RbTg==} 1539 1545 1540 1546 '@protobufjs/aspromise@1.1.2': 1541 1547 resolution: {integrity: sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==} ··· 3495 3501 resolution: {integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==} 3496 3502 engines: {node: '>=0.10.0'} 3497 3503 3504 + posthog-node@5.29.2: 3505 + resolution: {integrity: sha512-rI7kkF0XqDc0G1qjx+Hb4iuY9NAlL+XQNoGOpnEpRNTUcXvjY6WlsRGZ9m2whgc39emrrYdszi/YT8wZkr2xsg==} 3506 + engines: {node: ^20.20.0 || >=22.22.0} 3507 + peerDependencies: 3508 + rxjs: ^7.0.0 3509 + peerDependenciesMeta: 3510 + rxjs: 3511 + optional: true 3512 + 3498 3513 powershell-utils@0.1.0: 3499 3514 resolution: {integrity: sha512-dM0jVuXJPsDN6DvRpea484tCUaMiXWjuCn++HGTqUWzGDjv5tZkEZldAJ/UMlqRYGFrD/etByo4/xOuC/snX2A==} 3500 3515 engines: {node: '>=20'} ··· 5795 5810 5796 5811 '@poppinss/validator-lite@2.1.2': {} 5797 5812 5813 + '@posthog/core@1.25.2': {} 5814 + 5798 5815 '@protobufjs/aspromise@1.1.2': {} 5799 5816 5800 5817 '@protobufjs/base64@1.1.2': {} ··· 7530 7547 postgres-interval@1.2.0: 7531 7548 dependencies: 7532 7549 xtend: 4.0.2 7550 + 7551 + posthog-node@5.29.2(rxjs@7.8.2): 7552 + dependencies: 7553 + '@posthog/core': 1.25.2 7554 + optionalDependencies: 7555 + rxjs: 7.8.2 7533 7556 7534 7557 powershell-utils@0.1.0: {} 7535 7558
+15
providers/posthog_provider.ts
··· 1 + import type { ApplicationService } from '@adonisjs/core/types' 2 + import { getPostHogClient, shutdownPostHog } from '#services/posthog' 3 + 4 + export default class PostHogProvider { 5 + constructor(protected app: ApplicationService) {} 6 + 7 + boot() { 8 + // Eagerly initialise the client so it's ready when the first request arrives 9 + getPostHogClient() 10 + } 11 + 12 + async shutdown() { 13 + await shutdownPostHog() 14 + } 15 + }
+4
start/env.ts
··· 43 43 // Backfill 44 44 BACKFILL_MAX_POSTS: Env.schema.number.optional(), 45 45 46 + // PostHog 47 + POSTHOG_API_KEY: Env.schema.string.optional(), 48 + POSTHOG_HOST: Env.schema.string.optional(), 49 + 46 50 // OpenTelemetry 47 51 OTEL_EXPORTER_OTLP_ENDPOINT: Env.schema.string.optional(), 48 52 OTEL_EXPORTER_OTLP_HEADERS: Env.schema.string.optional(),
+1
start/kernel.ts
··· 39 39 () => import('@adonisjs/session/session_middleware'), 40 40 () => import('@adonisjs/shield/shield_middleware'), 41 41 () => import('@adonisjs/auth/initialize_auth_middleware'), 42 + () => import('#middleware/posthog_middleware'), 42 43 ]) 43 44 44 45 /**