diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index b322f36..2c6709f 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -1,10 +1,10 @@ name: Docker image -# Publishes ghcr.io//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-` tag to roll back to. With the Sentry secret and variables set, the build uploads -# source maps. See DEPLOY.md. +# Publishes ghcr.io//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-` tag to roll back to. With the Sentry secret and variables set, the build +# uploads source maps. See DEPLOY.md. on: push: branches: [main] @@ -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." diff --git a/DEPLOY.md b/DEPLOY.md index 2bc82c2..7bb5ceb 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -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` @@ -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 @@ -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 @@ -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 diff --git a/docs/adr/0005-cookieless-explicit-analytics.md b/docs/adr/0005-cookieless-explicit-analytics.md index 7d9dfc2..b46b42b 100644 --- a/docs/adr/0005-cookieless-explicit-analytics.md +++ b/docs/adr/0005-cookieless-explicit-analytics.md @@ -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 diff --git a/implementation.md b/implementation.md index 940e2a6..c2387e8 100644 --- a/implementation.md +++ b/implementation.md @@ -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 @@ -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`. diff --git a/instrument.server.mjs b/instrument.server.mjs index d18cf1e..d149be0 100644 --- a/instrument.server.mjs +++ b/instrument.server.mjs @@ -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, diff --git a/src/env.ts b/src/env.ts index 6753c83..75098c2 100644 --- a/src/env.ts +++ b/src/env.ts @@ -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: { diff --git a/src/features/usage/__tests__/public-config.test.ts b/src/features/usage/__tests__/public-config.test.ts index bbbd3b0..3a58235 100644 --- a/src/features/usage/__tests__/public-config.test.ts +++ b/src/features/usage/__tests__/public-config.test.ts @@ -10,6 +10,7 @@ describe('public config', () => { VITE_POSTHOG_HOST: 'https://eu.i.posthog.com', VITE_SENTRY_DSN: 'https://key@o1.ingest.sentry.io/2', HEXLODE_VERSION: '1.4.0', + HEXLODE_ENVIRONMENT: 'staging', DATABASE_URL: 'postgresql://secret@db/hexlode', }), ).toEqual({ @@ -17,9 +18,21 @@ describe('public config', () => { posthogHost: 'https://eu.i.posthog.com', sentryDsn: 'https://key@o1.ingest.sentry.io/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({ @@ -27,7 +40,7 @@ describe('public config', () => { 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. @@ -36,6 +49,7 @@ describe('public config', () => { posthogKey: 'phc_live', sentryDsn: 'https://key@o1.ingest.sentry.io/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({}) diff --git a/src/features/usage/__tests__/start.test.ts b/src/features/usage/__tests__/start.test.ts index 69ebf8d..a20b61d 100644 --- a/src/features/usage/__tests__/start.test.ts +++ b/src/features/usage/__tests__/start.test.ts @@ -32,11 +32,13 @@ describe('starting analytics and error reports', () => { await startErrorReporting({ sentryDsn: 'https://key@o1.ingest.sentry.io/2', appVersion: '1.4.0', + environment: 'staging', }) expect(init).toHaveBeenCalledWith( expect.objectContaining({ dsn: 'https://key@o1.ingest.sentry.io/2', release: '1.4.0', + environment: 'staging', sendDefaultPii: false, enableLogs: true, tracesSampleRate: expect.any(Number), diff --git a/src/features/usage/__tests__/usage.test.ts b/src/features/usage/__tests__/usage.test.ts index f6a6b2e..83c873e 100644 --- a/src/features/usage/__tests__/usage.test.ts +++ b/src/features/usage/__tests__/usage.test.ts @@ -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. diff --git a/src/features/usage/constants.ts b/src/features/usage/constants.ts index 474341f..ac96235 100644 --- a/src/features/usage/constants.ts +++ b/src/features/usage/constants.ts @@ -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' /** diff --git a/src/features/usage/error-reports.ts b/src/features/usage/error-reports.ts index 3744618..1f5d97d 100644 --- a/src/features/usage/error-reports.ts +++ b/src/features/usage/error-reports.ts @@ -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, diff --git a/src/features/usage/public-config.ts b/src/features/usage/public-config.ts index a5fe9b2..0f3c706 100644 --- a/src/features/usage/public-config.ts +++ b/src/features/usage/public-config.ts @@ -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, }), ) diff --git a/src/features/usage/types.ts b/src/features/usage/types.ts index 7b5b0f4..26627e5 100644 --- a/src/features/usage/types.ts +++ b/src/features/usage/types.ts @@ -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 } diff --git a/src/features/usage/usage.ts b/src/features/usage/usage.ts index 3a74344..0929ad7 100644 --- a/src/features/usage/usage.ts +++ b/src/features/usage/usage.ts @@ -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(event: E, properties: EventProperties) { @@ -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 { diff --git a/src/features/usage/validators.ts b/src/features/usage/validators.ts index ed79216..a5d0c70 100644 --- a/src/features/usage/validators.ts +++ b/src/features/usage/validators.ts @@ -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( @@ -18,6 +20,9 @@ export function parsePublicConfig(env: Record): 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'), }) } @@ -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. */