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
47 changes: 33 additions & 14 deletions .github/workflows/docker.yml
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
name: Docker image

# Publishes ghcr.io/<owner>/hexlode. Every merge to `main` updates the `main` tag. A release, the
# `v1.2.3` tag Release Please starts this workflow for, publishes `latest`, `1.2.3` and `1.2`, then
# calls the Dokploy deploy webhook, since Dokploy runs `latest`. Every image also gets a
# `sha-<commit>` tag to roll back to. With the Sentry secret and variables set, the build uploads
# source maps. See DEPLOY.md.
# Publishes ghcr.io/<owner>/hexlode. Every merge to `main` updates the `main` tag and deploys
# staging, which runs `main`. A release, the `v1.2.3` tag Release Please starts this workflow for,
# publishes `latest`, `1.2.3` and `1.2` and deploys production, which runs `latest`. Every image also
# gets a `sha-<commit>` tag to roll back to. With the Sentry secret and variables set, the build
# uploads source maps. See DEPLOY.md.
on:
push:
branches: [main]
Expand Down Expand Up @@ -139,22 +139,41 @@ jobs:
while read -r tag; do echo "- \`$tag\`"; done <<< "$TAGS"
} >> "$GITHUB_STEP_SUMMARY"

deploy:
name: deploy to Dokploy
# Each deploy calls its Dokploy application's webhook, and is skipped until that secret is set.
deploy-staging:
name: deploy staging
needs: image
if: github.ref == 'refs/heads/main'
runs-on: ubuntu-latest
environment: staging
steps:
- name: Call the staging deploy webhook
env:
WEBHOOK_URL: ${{ secrets.DOKPLOY_STAGING_WEBHOOK_URL }}
run: |
if [ -z "$WEBHOOK_URL" ]; then
echo "::notice::DOKPLOY_STAGING_WEBHOOK_URL is not set, so staging was not deployed."
exit 0
fi
curl -fsS --retry 3 --retry-all-errors -X POST "$WEBHOOK_URL"
echo
echo "Dokploy is deploying the main image to staging."

deploy-production:
name: deploy production
needs: image
# Only releases deploy, and only once the webhook secret is set (see DEPLOY.md).
if: startsWith(github.ref, 'refs/tags/v')
runs-on: ubuntu-latest
environment: production
steps:
- name: Call the deploy webhook
- name: Call the production deploy webhook
env:
DOKPLOY_WEBHOOK_URL: ${{ secrets.DOKPLOY_WEBHOOK_URL }}
WEBHOOK_URL: ${{ secrets.DOKPLOY_WEBHOOK_URL }}
run: |
if [ -z "$DOKPLOY_WEBHOOK_URL" ]; then
echo "::notice::DOKPLOY_WEBHOOK_URL is not set, so Dokploy was not asked to deploy."
if [ -z "$WEBHOOK_URL" ]; then
echo "::notice::DOKPLOY_WEBHOOK_URL is not set, so production was not deployed."
exit 0
fi
curl -fsS --retry 3 --retry-all-errors -X POST "$DOKPLOY_WEBHOOK_URL"
curl -fsS --retry 3 --retry-all-errors -X POST "$WEBHOOK_URL"
echo
echo "Dokploy is deploying the new image."
echo "Dokploy is deploying the release to production."
44 changes: 34 additions & 10 deletions DEPLOY.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ needs rebuilding to change a setting ([ADR 0008](./docs/adr/0008-one-image-confi
| --- | --- |
| `latest` | On every release. Production runs this tag. |
| `1.2.3`, `1.2` | On every release: merging the Release Please pull request tags `v1.2.3`. |
| `main` | On every merge to `main`, released or not. Use it to try unreleased changes. |
| `main` | On every merge to `main`, released or not. Staging runs this tag. |
| `sha-abc1234` | On every build. Use it to pin or roll back to one commit. |

The container listens on port `3000`, runs as an unprivileged user, and answers `GET /api/health`
Expand Down Expand Up @@ -45,6 +45,7 @@ runs with analytics and error reports off.
| `VITE_POSTHOG_KEY` | PostHog project key. Turns on cookieless analytics. |
| `VITE_POSTHOG_HOST` | PostHog API host, for example `https://eu.i.posthog.com`. Defaults to the US host. |
| `VITE_SENTRY_DSN` | Sentry DSN. Turns on error reports, logs and tracing in the browser and on the server. |
| `HEXLODE_ENVIRONMENT` | `staging` on the staging application. Leave it unset in production, which reports as `production`. |
| `PORT` | The port the server listens on. Defaults to `3000`; change the domain's port to match. |

The `VITE_*` values are public: they reach every visitor's browser. Never put a secret in a
Expand Down Expand Up @@ -107,15 +108,38 @@ runs Node directly:

Then click **Deploy**. Open `https://your-domain/api/health` to see the running version.

## Deploying on every release
## Staging and production

Merging a pull request into `main` publishes the `main` image but deploys nothing. Merging the
Release Please pull request makes a release: the `Docker image` workflow publishes `latest` and the
version tags, then calls the application's Dokploy deploy webhook, and Dokploy pulls `latest` and
redeploys. Copy the webhook from the application's
**Deployments** tab in Dokploy and save it in GitHub (**Settings → Secrets and variables →
Actions**) as the repository secret `DOKPLOY_WEBHOOK_URL`. Until it is set, the workflow publishes
the image and skips the deploy.
Hexlode runs as two Dokploy applications from the same image, with the same PostHog key and Sentry
DSN:

| | Staging | Production |
| --- | --- | --- |
| Image | `ghcr.io/pixelactstudio/hexlode:main` | `ghcr.io/pixelactstudio/hexlode:latest` |
| Deploys when | a pull request is merged into `main` | the Release Please pull request is merged |
| `HEXLODE_ENVIRONMENT` | `staging` | unset |
| Webhook secret in GitHub | `DOKPLOY_STAGING_WEBHOOK_URL` | `DOKPLOY_WEBHOOK_URL` |

So a merged change shows up on staging to test, and reaches production with the next release. Set
up the staging application like production (steps 1 to 5 above) with the `main` image, its own
domain with HTTPS, and `HEXLODE_ENVIRONMENT=staging`.

Copy each application's webhook from its **Deployments** tab in Dokploy and save it in GitHub
(**Settings → Secrets and variables → Actions**) as the repository secret in the table. Until a
secret is set, the workflow publishes the image and skips that deploy.

### Keeping staging out of the numbers

Both applications report to the same Sentry and PostHog projects, labelled with their environment.

- **Sentry** files every error, log and trace under the environment, `production` or `staging`.
Pick it with the environment selector at the top of Issues, Traces and Logs. Make alert rules
fire for the `production` environment only.
- **PostHog** labels every event with an `environment` property. In **Project settings → Product
analytics → Filter out internal and test users**, add the filter `environment` **is not**
`staging`, add another for `development`, and turn on **Enable this filter on all new insights**.
Dashboards then count production only. To check events from staging, turn off **Filter out
internal and test users** on an insight.

## Readable Sentry stack traces

Expand Down Expand Up @@ -147,7 +171,7 @@ Set these up in Sentry itself:
- **Uptime monitor** (**Insights → Uptime**): check `https://your-domain/api/health` so Sentry
alerts you when the site is down.
- **Alerts** (**Alerts → Create alert**): for example, email on every new issue, or when errors in
an hour pass a number.
an hour pass a number. Set their environment to `production`, so testing on staging stays quiet.

## Rolling back

Expand Down
4 changes: 4 additions & 0 deletions docs/adr/0005-cookieless-explicit-analytics.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,10 @@ They carry the product signal autocapture cannot: tools, pipeline shapes, node t
counts, timings and error codes. Events never contain file names, paths, pixels, image metadata or
text the user types.

Every event carries the app version and an `environment` property, `production` or `staging`, from
`HEXLODE_ENVIRONMENT`. Staging reports to the same project, since the free plan has one, and
PostHog's test account filter keeps it out of dashboards.

## Considered options

- **Explicit events only** (the first version). Private, but the web analytics dashboard stayed
Expand Down
10 changes: 7 additions & 3 deletions implementation.md
Original file line number Diff line number Diff line change
Expand Up @@ -152,7 +152,8 @@ removed.
browser tests in three engines; coverage; the build with a smoke test of the server; and the
Docker image with a smoke test of the container, its health check, runtime settings and an ARM
build. CodeQL, `pnpm audit`, dependency review and PR titles run in their own workflows. A push
to `main` only publishes the `main` image, since the pull request already ran the checks;
to `main` only publishes the `main` image and deploys staging, since the pull request already
ran the checks;
CodeQL, the audit and the scale test also run on a schedule.
- Release Please keeps a release pull request open on `main` from the Conventional Commits merged
there; merging it tags the version, writes the changelog, publishes `latest` and the version tags, and
Expand Down Expand Up @@ -181,8 +182,11 @@ removed.
## Deployment

The app runs as one Docker container that serves the Nitro build. The `Docker image` workflow
publishes `ghcr.io/pixelactstudio/hexlode` for x86 and ARM: `main` on every merge to `main`, and
`latest` on every release, after which it calls the Dokploy deploy webhook on the maintainer's VPS. Dokploy pulls the image, sets its environment and
publishes `ghcr.io/pixelactstudio/hexlode` for x86 and ARM: `main` on every merge to `main`, which
deploys the staging application, and `latest` on every release, which deploys production. Each
deploy calls that application's Dokploy webhook on the maintainer's VPS. Staging sets
`HEXLODE_ENVIRONMENT=staging`, so Sentry files its reports under `staging` and PostHog's test
account filter keeps its events out of production dashboards. Dokploy pulls the image, sets its environment and
handles the domain and HTTPS ([ADR 0008](./docs/adr/0008-one-image-configured-at-runtime.md)).
`/api/health` answers the container health check that Dokploy's zero-downtime updates wait for.
A future cloud mode adds a Postgres service on the same VPS, reached through `DATABASE_URL`.
Expand Down
7 changes: 6 additions & 1 deletion instrument.server.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ if (sentryDsn) {
Sentry.init({
dsn: sentryDsn,
release: process.env.HEXLODE_VERSION,
environment: process.env.NODE_ENV === 'production' ? 'production' : 'development',
// Matches the environment src/features/usage/validators.ts hands the browser.
environment: /^[a-z][a-z0-9-]{0,31}$/.test(process.env.HEXLODE_ENVIRONMENT ?? '')
? process.env.HEXLODE_ENVIRONMENT
: process.env.NODE_ENV === 'production'
? 'production'
: 'development',
sendDefaultPii: false,
dataCollection: {
userInfo: false,
Expand Down
1 change: 1 addition & 0 deletions src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export const env = createEnv({
SENTRY_ORG: z.string().min(1).optional(),
SENTRY_PROJECT: z.string().min(1).optional(),
HEXLODE_VERSION: z.string().min(1).optional(),
HEXLODE_ENVIRONMENT: z.string().min(1).optional(),
},
clientPrefix: 'VITE_',
client: {
Expand Down
16 changes: 15 additions & 1 deletion src/features/usage/__tests__/public-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,24 +10,37 @@ describe('public config', () => {
VITE_POSTHOG_HOST: 'https://eu.i.posthog.com',
VITE_SENTRY_DSN: 'https://[email protected]/2',
HEXLODE_VERSION: '1.4.0',
HEXLODE_ENVIRONMENT: 'staging',
DATABASE_URL: 'postgresql://secret@db/hexlode',
}),
).toEqual({
posthogKey: 'phc_live',
posthogHost: 'https://eu.i.posthog.com',
sentryDsn: 'https://[email protected]/2',
appVersion: '1.4.0',
environment: 'staging',
})
})

// Staging runs the same image with HEXLODE_ENVIRONMENT=staging; production leaves it unset.
it('names the environment production in a production server unless told otherwise', () => {
expect(parsePublicConfig({ NODE_ENV: 'production' }).environment).toBe('production')
expect(parsePublicConfig({ NODE_ENV: 'development' }).environment).toBe('development')
expect(parsePublicConfig({}).environment).toBe('development')
expect(
parsePublicConfig({ NODE_ENV: 'production', HEXLODE_ENVIRONMENT: 'Staging area!' })
.environment,
).toBe('production')
})

it('leaves out settings that are empty or not valid', () => {
expect(
parsePublicConfig({
VITE_POSTHOG_KEY: ' ',
VITE_POSTHOG_HOST: 'not a url',
VITE_SENTRY_DSN: '',
}),
).toEqual({})
).toEqual({ environment: 'development' })
})

// The page carries the config so the browser can start Sentry before it hydrates.
Expand All @@ -36,6 +49,7 @@ describe('public config', () => {
posthogKey: 'phc_live',
sentryDsn: 'https://[email protected]/2',
appVersion: '1.4.0',
environment: 'staging',
}
expect(parsePublicConfigJson(JSON.stringify({ ...config, extra: 'x' }))).toEqual(config)
expect(parsePublicConfigJson(JSON.stringify({ sentryDsn: 'not a url' }))).toEqual({})
Expand Down
2 changes: 2 additions & 0 deletions src/features/usage/__tests__/start.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,11 +32,13 @@ describe('starting analytics and error reports', () => {
await startErrorReporting({
sentryDsn: 'https://[email protected]/2',
appVersion: '1.4.0',
environment: 'staging',
})
expect(init).toHaveBeenCalledWith(
expect.objectContaining({
dsn: 'https://[email protected]/2',
release: '1.4.0',
environment: 'staging',
sendDefaultPii: false,
enableLogs: true,
tracesSampleRate: expect.any(Number),
Expand Down
11 changes: 8 additions & 3 deletions src/features/usage/__tests__/usage.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,10 +55,15 @@ describe('analytics', () => {
})
})

it('labels every event with the app version', () => {
it('labels every event with the app version and environment', () => {
const posthog = fakePostHog()
createAnalytics({ key: 'phc_test', appVersion: '1.4.0', posthog: posthog.client })
expect(posthog.registered).toEqual([{ app_version: '1.4.0' }])
createAnalytics({
key: 'phc_test',
appVersion: '1.4.0',
environment: 'staging',
posthog: posthog.client,
})
expect(posthog.registered).toEqual([{ app_version: '1.4.0', environment: 'staging' }])
})

// PostHog hashes the IP into the daily cookieless ID and drops cookieless events without one.
Expand Down
3 changes: 3 additions & 0 deletions src/features/usage/constants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,9 @@ export const PUBLIC_CONFIG_META = 'hexlode-config'
/** PostHog's recommended defaults as of this date, as its TanStack Start guide sets them. */
export const POSTHOG_DEFAULTS = '2026-05-30'

/** An environment name such as `staging`: lowercase letters, digits and dashes. */
export const ENVIRONMENT_PATTERN = /^[a-z][a-z0-9-]{0,31}$/

export const DEFAULT_POSTHOG_HOST = 'https://us.i.posthog.com'

/**
Expand Down
2 changes: 1 addition & 1 deletion src/features/usage/error-reports.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ export async function startErrorReporting(config: PublicConfig) {
Sentry.init({
dsn,
release: config.appVersion,
environment: import.meta.env?.PROD ? 'production' : 'development',
environment: config.environment ?? (import.meta.env?.PROD ? 'production' : 'development'),
sendDefaultPii: false,
enableLogs: true,
tracesSampleRate: TRACES_SAMPLE_RATE,
Expand Down
2 changes: 2 additions & 0 deletions src/features/usage/public-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,7 @@ export const getPublicConfig = createServerFn({ method: 'GET' }).handler(() =>
VITE_POSTHOG_HOST: process.env.VITE_POSTHOG_HOST || import.meta.env.VITE_POSTHOG_HOST,
VITE_SENTRY_DSN: process.env.VITE_SENTRY_DSN || import.meta.env.VITE_SENTRY_DSN,
HEXLODE_VERSION: process.env.HEXLODE_VERSION,
HEXLODE_ENVIRONMENT: process.env.HEXLODE_ENVIRONMENT,
NODE_ENV: process.env.NODE_ENV,
}),
)
5 changes: 5 additions & 0 deletions src/features/usage/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,4 +9,9 @@ export interface PublicConfig {
sentryDsn?: string
/** The running image's version, from `HEXLODE_VERSION`. Labels events and error reports. */
appVersion?: string
/**
* `production`, `staging` or `development`, from `HEXLODE_ENVIRONMENT`. Sentry files reports under
* it and PostHog labels events with it, so staging stays out of production numbers.
*/
environment?: string
}
9 changes: 7 additions & 2 deletions src/features/usage/usage.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,14 +32,18 @@ export interface AnalyticsOptions {
key?: string
host?: string
appVersion?: string
environment?: string
posthog: PostHogLike
}

export function createAnalytics({ key, host, appVersion, posthog }: AnalyticsOptions) {
export function createAnalytics({ key, host, appVersion, environment, posthog }: AnalyticsOptions) {
const enabled = Boolean(key)
if (key) {
posthog.init(key, { ...POSTHOG_OPTIONS, api_host: host || DEFAULT_POSTHOG_HOST })
if (appVersion) posthog.register({ app_version: appVersion })
// PostHog's test account filter hides every environment but production from dashboards.
const labels = { app_version: appVersion, environment }
const defined = Object.fromEntries(Object.entries(labels).filter(([, value]) => value))
if (Object.keys(defined).length > 0) posthog.register(defined)
}
return {
track<E extends AnalyticsEventName>(event: E, properties: EventProperties<E>) {
Expand Down Expand Up @@ -73,6 +77,7 @@ export function startAnalytics(config: PublicConfig) {
key,
host: config.posthogHost,
appVersion: config.appVersion,
environment: config.environment,
posthog: posthog as unknown as PostHogLike,
})
} catch {
Expand Down
6 changes: 6 additions & 0 deletions src/features/usage/validators.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,11 @@
import { z } from 'zod'

import { ENVIRONMENT_PATTERN } from '#/features/usage/constants'
import type { PublicConfig } from '#/features/usage/types'

const optionalText = z.string().trim().min(1).optional().catch(undefined)
const optionalUrl = z.url().optional().catch(undefined)
const optionalEnvironment = z.string().regex(ENVIRONMENT_PATTERN).optional().catch(undefined)

function withoutEmpty(config: PublicConfig): PublicConfig {
return Object.fromEntries(
Expand All @@ -18,6 +20,9 @@ export function parsePublicConfig(env: Record<string, string | undefined>): Publ
posthogHost: optionalUrl.parse(env.VITE_POSTHOG_HOST),
sentryDsn: optionalUrl.parse(env.VITE_SENTRY_DSN),
appVersion: optionalText.parse(env.HEXLODE_VERSION),
environment:
optionalEnvironment.parse(env.HEXLODE_ENVIRONMENT) ??
(env.NODE_ENV === 'production' ? 'production' : 'development'),
})
}

Expand All @@ -26,6 +31,7 @@ const publicConfigSchema = z.object({
posthogHost: optionalUrl,
sentryDsn: optionalUrl,
appVersion: optionalText,
environment: optionalEnvironment,
})

/** Reads the settings the server wrote into the page. Anything malformed is left out. */
Expand Down
Loading