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 DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,13 @@ runs with analytics and error reports off.
The `VITE_*` values are public: they reach every visitor's browser. Never put a secret in a
variable that starts with `VITE_`. A change takes effect on the next deploy or restart.

Hexlode sends PostHog cookieless events, so in the PostHog project turn on **cookieless server
hash mode** and **Discard client IP data**. Without the first, PostHog accepts the events and then
drops them. The app sends only its own named events, such as `page_viewed`, and no `$pageview`, so
look for them under **Activity → Events**; the Web analytics dashboard stays empty. PostHog's
onboarding snippet `posthog.capture(…)` does not work in the console, because the app does not put
PostHog on `window`.

### 4. Add the domain

On the **Domains** tab, add your host with path `/`, container port `3000`, and HTTPS on with a
Expand Down
6 changes: 4 additions & 2 deletions docs/adr/0005-cookieless-explicit-analytics.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
# Cookieless analytics with explicit events

PostHog runs with `cookieless_mode: 'always'` and `person_profiles: 'never'`, so it stores nothing
in the browser and the app needs no consent banner. IP capture, session replay and autocapture are
off because they would record file names shown on screen. The app sends its own detailed events
in the browser and the app needs no consent banner. PostHog hashes each visitor's IP address, user
agent and host into an anonymous ID that changes daily, and the project discards the IP afterwards.
The client leaves `$ip` alone: PostHog drops cookieless events that arrive without one. Session
replay and autocapture are off because they would record file names shown on screen. The app sends its own detailed events
from one analytics module instead. Events never contain file names, paths, pixels, image metadata
or text the user types.
7 changes: 4 additions & 3 deletions implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -155,13 +155,14 @@ removed.

## Analytics

- PostHog uses `cookieless_mode: 'always'` and `person_profiles: 'never'`, with IP capture, session
replay and autocapture turned off. The app sends its own events from one analytics module
- PostHog uses `cookieless_mode: 'always'` and `person_profiles: 'never'`, with session replay and
autocapture turned off. The app sends its own events from one analytics module
([ADR 0005](./docs/adr/0005-cookieless-explicit-analytics.md)).
- Sentry sends errors with `sendDefaultPii: false` and no replay. File names are removed from error
messages before sending.
- The PostHog project must have cookieless mode enabled and "Discard client IP data" turned on;
without the first, PostHog ignores cookieless events. The client also clears `$ip` on every event.
without the first, PostHog ignores cookieless events. The client must not clear `$ip`: PostHog
hashes it into the daily anonymous ID and drops cookieless events without it.
- The event catalogue lives in `src/features/usage/events.ts`, and the privacy page lists it.

## Deployment
Expand Down
10 changes: 6 additions & 4 deletions src/features/usage/__tests__/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,10 +36,12 @@ describe('analytics', () => {
})
})

it('removes the IP address from every event', () => {
const beforeSend = POSTHOG_OPTIONS.before_send
const event = beforeSend({ event: 'x', properties: { $ip: '203.0.113.9', itemCount: 2 } })
expect(event?.properties).toEqual({ $ip: null, itemCount: 2 })
// PostHog hashes the IP into the daily cookieless ID and drops cookieless events without one.
it('leaves the IP for PostHog to hash into the cookieless ID', () => {
const options: Record<string, unknown> = POSTHOG_OPTIONS
const beforeSend = options.before_send as ((event: unknown) => unknown) | undefined
const event = { event: 'x', properties: { itemCount: 2 } }
expect(beforeSend ? beforeSend(event) : event).toEqual(event)
})

it('does nothing without a key', () => {
Expand Down
8 changes: 0 additions & 8 deletions src/features/usage/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,6 @@
import { type AnalyticsEventName, EVENTS, type EventProperties } from '#/features/usage/events'
import type { PublicConfig } from '#/features/usage/types'

interface CaptureEvent {
event: string
properties: Record<string, unknown>
}

export interface PostHogLike {
init(key: string, options: Record<string, unknown>): unknown
capture(event: string, properties: Record<string, unknown>): unknown
Expand All @@ -35,9 +30,6 @@ export const POSTHOG_OPTIONS = {
disable_external_dependency_loading: true,
advanced_disable_flags: true,
mask_personal_data_properties: true,
/** Clears the IP address. The PostHog project also discards client IP data. */
before_send: (event: CaptureEvent | null) =>
event ? { ...event, properties: { ...event.properties, $ip: null } } : null,
} as const

export interface AnalyticsOptions {
Expand Down
5 changes: 3 additions & 2 deletions src/routes/privacy.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,8 +57,9 @@ function Privacy() {
<Heading level={2}>What Hexlode measures</Heading>
<Text type="body" as="p">
Product analytics run without cookies and without identifying you. Your IP address is
discarded. Events contain only counts, timings, node types, settings and error codes.
They never contain file names, paths, pixels, image metadata or text you type.
turned into an anonymous ID that changes daily, then discarded. Events contain only
counts, timings, node types, settings and error codes. They never contain file names,
paths, pixels, image metadata or text you type.
</Text>
<List listStyle="disc">
{Object.entries(EVENTS).map(([name, event]) => (
Expand Down
Loading