@clubedge/create-clubedge-app 0.13.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/CHANGELOG.md +5 -0
  2. package/README.md +12 -11
  3. package/package.json +3 -3
  4. package/template/.env.example +16 -0
  5. package/template/README.md +5 -0
  6. package/template/SETUP.md +36 -0
  7. package/template/apps/start/.env.example +16 -0
  8. package/template/apps/start/package.json +4 -1
  9. package/template/apps/start/src/nitro/plugins/observability.ts +10 -0
  10. package/template/apps/start/src/routes/api/webhooks/payments.ts +2 -1
  11. package/template/apps/start/src/server/auth-forms.ts +4 -4
  12. package/template/apps/start/src/server/auth.ts +2 -2
  13. package/template/apps/start/src/server/email.ts +3 -2
  14. package/template/apps/start/src/server/jobs.functions.ts +2 -1
  15. package/template/apps/start/src/server/jobs.ts +9 -1
  16. package/template/apps/start/src/server/observability.ts +36 -0
  17. package/template/apps/start/src/server/payments.ts +12 -8
  18. package/template/apps/start/src/server/storage.ts +3 -2
  19. package/template/apps/start/src/server/users.ts +2 -1
  20. package/template/apps/start/src/server/variants/auth.better-auth.ts +2 -2
  21. package/template/apps/start/src/server/variants/email.resend.ts +3 -2
  22. package/template/apps/start/src/server/variants/observability.sentry.ts +42 -0
  23. package/template/apps/start/src/server/variants/storage.supabase.ts +3 -2
  24. package/template/apps/start/src/start.ts +40 -1
  25. package/template/apps/web/next-env.d.ts +2 -2
  26. package/template/apps/web/package.json +4 -1
  27. package/template/apps/web/src/app/actions/auth.ts +4 -4
  28. package/template/apps/web/src/app/api/webhooks/payments/route.ts +2 -1
  29. package/template/apps/web/src/app/dashboard/_components/jobs-card.tsx +2 -1
  30. package/template/apps/web/src/instrumentation.ts +27 -1
  31. package/template/apps/web/src/server/email.ts +3 -2
  32. package/template/apps/web/src/server/jobs.ts +9 -1
  33. package/template/apps/web/src/server/observability.ts +36 -0
  34. package/template/apps/web/src/server/payments.ts +12 -8
  35. package/template/apps/web/src/server/storage.ts +3 -2
  36. package/template/apps/web/src/server/users.ts +2 -1
  37. package/template/apps/web/src/server/variants/email.resend.ts +3 -2
  38. package/template/apps/web/src/server/variants/observability.sentry.ts +42 -0
  39. package/template/apps/web/src/server/variants/storage.supabase.ts +3 -2
  40. package/template/apps/web/src/variants/proxy.better-auth.ts +2 -1
  41. package/template/clubedge.template.json +45 -0
  42. package/template/e2e/observability.spec.ts +28 -0
  43. package/template/e2e/telemetry-fake.ts +91 -0
  44. package/template/package.json +1 -1
  45. package/template/packages/auth/package.json +1 -1
  46. package/template/packages/auth-better-auth/package.json +1 -1
  47. package/template/packages/auth-better-auth/src/index.ts +2 -2
  48. package/template/packages/auth-supabase/package.json +1 -1
  49. package/template/packages/cache/package.json +1 -1
  50. package/template/packages/cache-redis/package.json +1 -1
  51. package/template/packages/core/package.json +1 -1
  52. package/template/packages/core/src/errors.ts +3 -1
  53. package/template/packages/core/src/index.ts +9 -0
  54. package/template/packages/core/src/telemetry.test.ts +86 -0
  55. package/template/packages/core/src/telemetry.ts +79 -0
  56. package/template/packages/db/package.json +1 -1
  57. package/template/packages/email/package.json +1 -1
  58. package/template/packages/email-resend/package.json +1 -1
  59. package/template/packages/email-smtp/package.json +1 -1
  60. package/template/packages/jobs/package.json +1 -1
  61. package/template/packages/jobs-pg-boss/package.json +1 -1
  62. package/template/packages/jobs-pg-boss/src/index.test.ts +40 -3
  63. package/template/packages/jobs-pg-boss/src/index.ts +23 -10
  64. package/template/packages/local-db/package.json +1 -1
  65. package/template/packages/observability/package.json +21 -0
  66. package/template/packages/observability/src/index.ts +65 -0
  67. package/template/packages/observability/tsconfig.json +4 -0
  68. package/template/packages/observability-otel/package.json +30 -0
  69. package/template/packages/observability-otel/src/index.test.ts +93 -0
  70. package/template/packages/observability-otel/src/index.ts +176 -0
  71. package/template/packages/observability-otel/tsconfig.json +4 -0
  72. package/template/packages/observability-sentry/package.json +22 -0
  73. package/template/packages/observability-sentry/src/index.test.ts +70 -0
  74. package/template/packages/observability-sentry/src/index.ts +69 -0
  75. package/template/packages/observability-sentry/tsconfig.json +4 -0
  76. package/template/packages/payments/package.json +1 -1
  77. package/template/packages/payments-stripe/package.json +1 -1
  78. package/template/packages/storage/package.json +1 -1
  79. package/template/packages/storage-s3/package.json +1 -1
  80. package/template/packages/storage-supabase/package.json +1 -1
  81. package/template/packages/ui/package.json +1 -1
  82. package/template/pnpm-lock.yaml +592 -0
  83. package/template.lock.json +22 -2
package/CHANGELOG.md CHANGED
@@ -2,6 +2,11 @@
2
2
 
3
3
  All notable changes to `create-clubedge-app` are documented here.
4
4
 
5
+ ## 0.14.0
6
+
7
+ - Default to Starter `v0.13.0`, which adds observability (`--observability otel|sentry`): request tracing and server error reporting through OpenTelemetry, to any OTLP collector, or Sentry, behind provider-neutral hooks in `@clubedge/core`. `--observability` needs no CLI code: it comes from the Starter's manifest.
8
+ - Test an observability project in CI and in the release smoke test, and add the observability module in the change-modules job.
9
+
5
10
  ## 0.13.0
6
11
 
7
12
  - Default to Starter `v0.12.0`, which adds background jobs (`--jobs pg-boss`): jobs stored in the project's PostgreSQL, run by a worker inside the app server, with retries and schedules, and `/api/jobs/run` for serverless hosts. `--jobs` needs no CLI code: it comes from the Starter's manifest.
package/README.md CHANGED
@@ -12,27 +12,27 @@ The CLI downloads a tested Starter release, names the generated project, creates
12
12
  Each CLI release is tested against one specific Starter release:
13
13
 
14
14
  ```text
15
- create-clubedge-app v0.13.0
15
+ create-clubedge-app v0.14.0
16
16
  |
17
17
  v
18
- Clubedge Starter v0.12.0
19
- commit 445add73c3505cd8505faccb3b0b7061a29f5356
18
+ Clubedge Starter v0.13.0
19
+ commit 62fb8d1b1e13ed8e8e134bd1e53f2bfdde2c6a50
20
20
  ```
21
21
 
22
22
  The current relationship is:
23
23
 
24
24
  | CLI release | Default Starter release | Meaning |
25
25
  | --- | --- | --- |
26
- | `create-clubedge-app@0.13.0` | `clubedge-starter@v0.12.0` | The CLI's required, tested default |
26
+ | `create-clubedge-app@0.14.0` | `clubedge-starter@v0.13.0` | The CLI's required, tested default |
27
27
 
28
28
  This means the same CLI version always scaffolds the same Starter revision by default. The CLI does not silently follow Starter `main`.
29
29
 
30
- The `v0.12.0` tag is expected to resolve to commit
31
- `445add73c3505cd8505faccb3b0b7061a29f5356`. The CLI verifies this before
30
+ The `v0.13.0` tag is expected to resolve to commit
31
+ `62fb8d1b1e13ed8e8e134bd1e53f2bfdde2c6a50`. The CLI verifies this before
32
32
  scaffolding with the default reference and fails rather than silently using a
33
33
  moved tag.
34
34
 
35
- When the Starter changes, its maintainers first publish a new versioned release, such as `v0.12.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
35
+ When the Starter changes, its maintainers first publish a new versioned release, such as `v0.13.0`. The CLI is then updated to use that release, all generated-project checks must pass, and a new CLI version is published. This keeps Starter and CLI releases independently versioned while preserving a tested compatibility relationship.
36
36
 
37
37
  ## Requirements
38
38
 
@@ -85,7 +85,7 @@ Either way, the app lives in `apps/web` and uses the same shared packages, so th
85
85
 
86
86
  ### Choosing services
87
87
 
88
- Authentication, file storage, caching, email, payments, background jobs, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
88
+ Authentication, file storage, caching, email, payments, background jobs, observability, local infrastructure, and deployment are modules. Each has a default, and anything you leave out is removed completely: its package, its provider SDK, its environment variables, its documentation, and its lockfile entries. The Starter's `clubedge.template.json` defines the modules, so a module a newer Starter adds gets its `--<module>` flag without a CLI change; `--help` lists the modules of the Starter this CLI release creates.
89
89
 
90
90
  | Flag | Options | Default |
91
91
  | ----------- | ------------------------------------------ | ---------- |
@@ -95,6 +95,7 @@ Authentication, file storage, caching, email, payments, background jobs, local i
95
95
  | `--email` | `smtp` (any provider, or a local inbox), `resend`, `none` | `smtp` |
96
96
  | `--payments` | `stripe`, `none` (needs an auth module) | `none` |
97
97
  | `--jobs` | `pg-boss`, `none` | `none` |
98
+ | `--observability` | `otel` (OpenTelemetry, to any OTLP collector), `sentry`, `none` | `none` |
98
99
  | `--deploy` | `docker` (any container host), `fly`, `railway`, `render`, `vercel`, `netlify` | `docker` |
99
100
  | `--infra` | `docker` (Docker Compose), `supabase` (Supabase CLI), `local` (no Docker: PGlite and a local folder), `none` | `docker` |
100
101
 
@@ -140,7 +141,7 @@ pnpm dlx @clubedge/create-clubedge-app my-app --yes --no-git --framework tanstac
140
141
  pnpm dlx @clubedge/create-clubedge-app my-app --ref main
141
142
 
142
143
  # Reproduce a specific Starter release or commit
143
- pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.12.0
144
+ pnpm dlx @clubedge/create-clubedge-app my-app --ref v0.13.0
144
145
  pnpm dlx @clubedge/create-clubedge-app my-app --ref <commit-sha>
145
146
 
146
147
  # Test local Starter changes before releasing them
@@ -157,7 +158,7 @@ pnpm dlx @clubedge/create-clubedge-app add cache redis # or switch: add aut
157
158
  pnpm dlx @clubedge/create-clubedge-app remove storage
158
159
  pnpm dlx @clubedge/create-clubedge-app add infra local --dry-run
159
160
  pnpm dlx @clubedge/create-clubedge-app@latest upgrade # to the newest CLI's Starter release
160
- pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.12.0 # or a specific tag, branch, or commit
161
+ pnpm dlx @clubedge/create-clubedge-app upgrade --ref v0.13.0 # or a specific tag, branch, or commit
161
162
  ```
162
163
 
163
164
  The CLI renders the project twice, from the Starter commit and modules recorded in your `package.json` and from the new state, and merges the difference into your project. The new state has other modules for `add` and `remove`, and the newer Starter with the same modules for `upgrade`. Modules a newer Starter introduces start as `none` where possible, so an upgrade never adds a service on its own.
@@ -208,7 +209,7 @@ CI packs the CLI with `npm pack`, scaffolds a clean project from the packed tarb
208
209
  1. Make and validate changes in `clubedge-starter`.
209
210
  2. Run lint, typecheck, unit tests, browser tests, and production build.
210
211
  3. Update the Starter package versions.
211
- 4. Commit `main`, create a release tag such as `v0.12.0`, and publish the Starter release.
212
+ 4. Commit `main`, create a release tag such as `v0.13.0`, and publish the Starter release.
212
213
 
213
214
  ### CLI maintainers
214
215
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/create-clubedge-app",
3
- "version": "0.13.0",
3
+ "version": "0.14.0",
4
4
  "description": "Create a Next.js or TanStack Start application from the Clubedge Starter reference implementation.",
5
5
  "type": "module",
6
6
  "packageManager": "pnpm@10.9.0",
@@ -22,8 +22,8 @@
22
22
  },
23
23
  "clubedge": {
24
24
  "starterRepository": "Clubedge/clubedge-starter",
25
- "starterRef": "v0.12.0",
26
- "starterCommit": "445add73c3505cd8505faccb3b0b7061a29f5356"
25
+ "starterRef": "v0.13.0",
26
+ "starterCommit": "62fb8d1b1e13ed8e8e134bd1e53f2bfdde2c6a50"
27
27
  },
28
28
  "license": "Apache-2.0",
29
29
  "repository": {
@@ -68,6 +68,22 @@ JOBS_WORKER=
68
68
  CRON_SECRET=
69
69
  # clubedge:end
70
70
 
71
+ # clubedge:if observability=otel
72
+ # OpenTelemetry: where traces and error logs go over OTLP/HTTP, such as a collector, Grafana
73
+ # Cloud, or Honeycomb. Empty sends nothing. Headers are key=value pairs separated by commas.
74
+ OTEL_EXPORTER_OTLP_ENDPOINT=
75
+ OTEL_EXPORTER_OTLP_HEADERS=
76
+ OTEL_SERVICE_NAME=
77
+ # clubedge:end
78
+
79
+ # clubedge:if observability=sentry
80
+ # Sentry (https://sentry.io): the DSN from the project's Client Keys settings. Empty sends nothing.
81
+ # The traces sample rate is the share of requests traced, from 0 to 1 (default 0.1).
82
+ SENTRY_DSN=
83
+ SENTRY_ENVIRONMENT=
84
+ SENTRY_TRACES_SAMPLE_RATE=
85
+ # clubedge:end
86
+
71
87
  # clubedge:if storage=s3
72
88
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
73
89
  STORAGE_BUCKET= # clubedge:only infra!=docker
@@ -29,6 +29,8 @@ This repository contains the same application for two frameworks, Next.js (`apps
29
29
  - Email through Resend; development prints emails to the server log until the API key is set. <!-- clubedge:only email=resend -->
30
30
  - Subscriptions with Stripe Checkout and the customer portal, kept in sync by verified webhooks, behind a provider-neutral payments interface. <!-- clubedge:only payments=stripe -->
31
31
  - Background jobs with retries and schedules, stored in PostgreSQL by pg-boss and run by a worker inside the app server, behind a provider-neutral jobs interface. <!-- clubedge:only jobs=pg-boss -->
32
+ - Server error reporting and request tracing with OpenTelemetry, to any OTLP collector, behind a provider-neutral observability interface. <!-- clubedge:only observability=otel -->
33
+ - Server error reporting and request tracing with Sentry, behind a provider-neutral observability interface. <!-- clubedge:only observability=sentry -->
32
34
  - Zod environment validation, security headers, structured errors, and `/api/health`.
33
35
  - Docker support, GitHub Actions CI, Vitest, Playwright, ESLint, and Prettier.
34
36
  - A production image that includes its own migrator (`node /app/migrate.mjs`), for running migrations before each release.
@@ -227,6 +229,9 @@ The TanStack Start app is `apps/start`, run with `pnpm --filter @clubedge/start
227
229
  - `packages/payments-stripe/`: Stripe payment provider <!-- clubedge:only payments=stripe -->
228
230
  - `packages/jobs/`: `JobQueue` interface, job definitions, and an in-memory queue for tests <!-- clubedge:only jobs!=none -->
229
231
  - `packages/jobs-pg-boss/`: pg-boss job queue <!-- clubedge:only jobs=pg-boss -->
232
+ - `packages/observability/`: `ObservabilityProvider` interface and an in-memory provider for tests <!-- clubedge:only observability!=none -->
233
+ - `packages/observability-otel/`: OpenTelemetry provider (OTLP/HTTP traces and error logs) <!-- clubedge:only observability=otel -->
234
+ - `packages/observability-sentry/`: Sentry provider <!-- clubedge:only observability=sentry -->
230
235
  - `packages/ui/`: shared shadcn/ui components, utilities, and global theme styles
231
236
  - `.github/`: CI workflow, issue forms, and pull request template
232
237
  - `SETUP.md`, `CONTRIBUTING.md`, `SECURITY.md`: setup, contribution, and security guides
package/template/SETUP.md CHANGED
@@ -47,6 +47,8 @@ The variables are:
47
47
  - `RESEND_API_KEY` and `EMAIL_FROM` (for sending email): see [Email](#email). <!-- clubedge:only email=resend -->
48
48
  - `STRIPE_SECRET_KEY`, `STRIPE_WEBHOOK_SECRET`, and `STRIPE_PRICE_<PLAN>` (for payments): see [Payments](#payments). <!-- clubedge:only payments=stripe -->
49
49
  - `JOBS_WORKER` and `CRON_SECRET` (optional): see [Background jobs](#background-jobs). <!-- clubedge:only jobs=pg-boss -->
50
+ - `OTEL_EXPORTER_OTLP_ENDPOINT` (to send traces and errors): see [Observability](#observability). <!-- clubedge:only observability=otel -->
51
+ - `SENTRY_DSN` (to send errors and traces): see [Observability](#observability). <!-- clubedge:only observability=sentry -->
50
52
  - `STORAGE_*` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=s3 -->
51
53
  - `SUPABASE_STORAGE_BUCKET` (for file storage): see [File storage](#file-storage). <!-- clubedge:only storage=supabase -->
52
54
 
@@ -264,6 +266,38 @@ Where no server keeps running, as on Vercel and Netlify, there is no worker: `JO
264
266
 
265
267
  Jobs go through the provider-neutral `JobQueue` interface in `packages/jobs`. To use another queue, implement that interface in a new package and return it from `apps/web/src/server/jobs.ts`; job definitions, the routes, and the jobs card stay the same.
266
268
 
269
+ <!-- clubedge:end -->
270
+ <!-- clubedge:if observability!=none -->
271
+
272
+ ## Observability
273
+
274
+ Server errors and traces go to the tool you chose. Errors that escape a page, route, server action, or server function are sent with the request's method and path, and so are errors the app handles and reports with `reportError` from `@clubedge/core`, such as a failed webhook, email, or background job. Every request is traced, and so are calls through the email, storage, and payments composition roots, as spans such as `email.send` and `storage.upload`. Wrap your own work with `traced("name", () => work())` from `@clubedge/core`. Until the tool is configured nothing is sent, and errors keep going to the server log.
275
+
276
+ <!-- clubedge:end -->
277
+ <!-- clubedge:if observability=otel -->
278
+
279
+ Set `OTEL_EXPORTER_OTLP_ENDPOINT` to an OpenTelemetry collector or to a vendor's OTLP/HTTP endpoint (Grafana Cloud, Honeycomb, Datadog, and others accept it), with any API key in `OTEL_EXPORTER_OTLP_HEADERS` as `key=value` pairs. Traces go to `/v1/traces` and errors to `/v1/logs` as error log records, and each error is also recorded on the span it happened in. `OTEL_SERVICE_NAME` defaults to the project's service id. The spans Next.js and pg-boss create join the same traces. <!-- clubedge:only framework=next -->
280
+ Set `OTEL_EXPORTER_OTLP_ENDPOINT` to an OpenTelemetry collector or to a vendor's OTLP/HTTP endpoint (Grafana Cloud, Honeycomb, Datadog, and others accept it), with any API key in `OTEL_EXPORTER_OTLP_HEADERS` as `key=value` pairs. Traces go to `/v1/traces` and errors to `/v1/logs` as error log records, and each error is also recorded on the span it happened in. `OTEL_SERVICE_NAME` defaults to the project's service id. <!-- clubedge:only framework=tanstack-start -->
281
+
282
+ To see traces during development, run [Jaeger](https://www.jaegertracing.io), set `OTEL_EXPORTER_OTLP_ENDPOINT=http://localhost:4318`, and open http://localhost:16686:
283
+
284
+ ```sh
285
+ docker run --rm -p 16686:16686 -p 4318:4318 jaegertracing/jaeger:latest
286
+ ```
287
+
288
+ <!-- clubedge:end -->
289
+ <!-- clubedge:if observability=sentry -->
290
+
291
+ Create a Node.js project in [Sentry](https://sentry.io) and set `SENTRY_DSN` to its DSN. `SENTRY_TRACES_SAMPLE_RATE` is the share of requests traced, from 0 to 1 (default 0.1; use 1 while trying it out), and `SENTRY_ENVIRONMENT` labels events (default `NODE_ENV`). The SDK sends no user details, cookies, query strings, or request bodies. The spans Next.js and pg-boss create join the same traces. <!-- clubedge:only framework=next -->
292
+ Create a Node.js project in [Sentry](https://sentry.io) and set `SENTRY_DSN` to its DSN. `SENTRY_TRACES_SAMPLE_RATE` is the share of requests traced, from 0 to 1 (default 0.1; use 1 while trying it out), and `SENTRY_ENVIRONMENT` labels events (default `NODE_ENV`). The SDK sends no user details, cookies, query strings, or request bodies. <!-- clubedge:only framework=tanstack-start -->
293
+
294
+ <!-- clubedge:end -->
295
+ <!-- clubedge:if observability!=none -->
296
+
297
+ Only server-side errors are reported; errors in the browser are not. On Vercel and Netlify, a function can stop right after it responds, so spans still waiting in a batch at that moment can be lost.
298
+
299
+ Observability goes through the provider-neutral `ObservabilityProvider` interface in `packages/observability` and the telemetry hooks in `@clubedge/core`. To use another tool, implement that interface in a new package and install it from `apps/web/src/server/observability.ts`; error reports and spans across the app stay the same.
300
+
267
301
  <!-- clubedge:end -->
268
302
  <!-- clubedge:if storage!=none -->
269
303
 
@@ -404,6 +438,8 @@ Production needs these variables:
404
438
  - `STRIPE_SECRET_KEY` (live mode), `STRIPE_WEBHOOK_SECRET` from the production webhook endpoint, and `STRIPE_PRICE_<PLAN>` for each plan. <!-- clubedge:only payments=stripe -->
405
439
  - `REDIS_URL`, when you run more than one instance. <!-- clubedge:only cache=redis -->
406
440
  - `CRON_SECRET`, where no worker runs and a scheduler calls `/api/jobs/run`. <!-- clubedge:only jobs=pg-boss -->
441
+ - `OTEL_EXPORTER_OTLP_ENDPOINT`, and `OTEL_EXPORTER_OTLP_HEADERS` if your collector needs a key. <!-- clubedge:only observability=otel -->
442
+ - `SENTRY_DSN`, and `SENTRY_ENVIRONMENT` to tell environments apart. <!-- clubedge:only observability=sentry -->
407
443
  - The `STORAGE_*` variables for your S3-compatible bucket. <!-- clubedge:only storage=s3 -->
408
444
  - `SUPABASE_STORAGE_BUCKET`. <!-- clubedge:only storage=supabase -->
409
445
 
@@ -70,6 +70,22 @@ JOBS_WORKER=
70
70
  CRON_SECRET=
71
71
  # clubedge:end
72
72
 
73
+ # clubedge:if observability=otel
74
+ # OpenTelemetry: where traces and error logs go over OTLP/HTTP, such as a collector, Grafana
75
+ # Cloud, or Honeycomb. Empty sends nothing. Headers are key=value pairs separated by commas.
76
+ OTEL_EXPORTER_OTLP_ENDPOINT=
77
+ OTEL_EXPORTER_OTLP_HEADERS=
78
+ OTEL_SERVICE_NAME=
79
+ # clubedge:end
80
+
81
+ # clubedge:if observability=sentry
82
+ # Sentry (https://sentry.io): the DSN from the project's Client Keys settings. Empty sends nothing.
83
+ # The traces sample rate is the share of requests traced, from 0 to 1 (default 0.1).
84
+ SENTRY_DSN=
85
+ SENTRY_ENVIRONMENT=
86
+ SENTRY_TRACES_SAMPLE_RATE=
87
+ # clubedge:end
88
+
73
89
  # clubedge:if storage=s3
74
90
  # S3-compatible storage (AWS S3, Cloudflare R2, MinIO)
75
91
  STORAGE_BUCKET= # clubedge:only infra!=docker
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@clubedge/start",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "private": true,
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -24,6 +24,9 @@
24
24
  "@clubedge/email-smtp": "workspace:*",
25
25
  "@clubedge/jobs": "workspace:*",
26
26
  "@clubedge/jobs-pg-boss": "workspace:*",
27
+ "@clubedge/observability": "workspace:*",
28
+ "@clubedge/observability-otel": "workspace:*",
29
+ "@clubedge/observability-sentry": "workspace:*",
27
30
  "@clubedge/payments": "workspace:*",
28
31
  "@clubedge/payments-stripe": "workspace:*",
29
32
  "@clubedge/storage": "workspace:*",
@@ -0,0 +1,10 @@
1
+ import { definePlugin } from "nitro";
2
+ import { startObservability } from "@/server/observability";
3
+
4
+ /** Starts error reporting and tracing with the server, and sends what is buffered when it stops. */
5
+ export default definePlugin((nitroApp) => {
6
+ const provider = startObservability();
7
+ nitroApp.hooks.hook("close", async () => {
8
+ await provider?.flush();
9
+ });
10
+ });
@@ -1,5 +1,6 @@
1
1
  import { createFileRoute } from "@tanstack/react-router";
2
2
  import { getBilling, isPaymentsConfigured, WebhookVerificationError } from "@/server/payments";
3
+ import { reportError } from "@clubedge/core";
3
4
 
4
5
  /**
5
6
  * The payment provider's webhook. The body is read as raw text, because the signature covers
@@ -22,7 +23,7 @@ export const Route = createFileRoute("/api/webhooks/payments")({
22
23
  if (error instanceof WebhookVerificationError) {
23
24
  return Response.json({ error: "Invalid signature." }, { status: 400 });
24
25
  }
25
- console.error("Payment webhook failed", error);
26
+ reportError(error, { message: "Payment webhook failed" });
26
27
  return Response.json({ error: "Webhook failed." }, { status: 500 });
27
28
  }
28
29
  },
@@ -1,6 +1,6 @@
1
1
  import "@tanstack/react-start/server-only";
2
2
  import { z } from "zod";
3
- import { getClientIp, safeRedirectPath } from "@clubedge/core";
3
+ import { getClientIp, safeRedirectPath, reportError } from "@clubedge/core";
4
4
  import { getAppUrl } from "@/env/server";
5
5
  import { loginUrl } from "@/lib/login-url";
6
6
  import { getAuth, isAuthConfigured } from "./auth";
@@ -49,7 +49,7 @@ async function isRateLimited(request: Request, action: "sign-in" | "sign-up" | "
49
49
  return !result.success;
50
50
  } catch (error) {
51
51
  // Fail open: an unavailable limiter backend should not lock every user out.
52
- console.error("Rate limiter unavailable", error);
52
+ reportError(error, { message: "Rate limiter unavailable" });
53
53
  return false;
54
54
  }
55
55
  }
@@ -142,7 +142,7 @@ export async function handleResetPassword(request: Request): Promise<Response> {
142
142
  // A Supabase reset link signed the user in; sign out so the new password is used next.
143
143
  await auth.signOut();
144
144
  } catch (error) {
145
- console.error("Sign-out after password reset failed", error);
145
+ reportError(error, { message: "Sign-out after password reset failed" });
146
146
  }
147
147
  return seeOther(loginUrl({ notice: "password-updated" }));
148
148
  }
@@ -152,7 +152,7 @@ export async function handleSignOut(): Promise<Response> {
152
152
  try {
153
153
  await getAuth().signOut();
154
154
  } catch (error) {
155
- console.error("Sign-out failed", error);
155
+ reportError(error, { message: "Sign-out failed" });
156
156
  }
157
157
  return seeOther("/login");
158
158
  }
@@ -1,7 +1,7 @@
1
1
  import "@tanstack/react-start/server-only";
2
2
  import type { AuthProvider, AuthUser } from "@clubedge/auth";
3
3
  import { createSupabaseAuth, createSupabaseServerClient } from "@clubedge/auth-supabase";
4
- import { AppError } from "@clubedge/core";
4
+ import { AppError, reportError } from "@clubedge/core";
5
5
  import { getSupabaseAuthEnv } from "@/env/auth";
6
6
  import { requestCookieStore } from "./cookies";
7
7
 
@@ -34,7 +34,7 @@ export async function refreshAuthSession(): Promise<void> {
34
34
  await getAuth().refreshSession();
35
35
  } catch (error) {
36
36
  // Pages still render; protected handlers re-check the user and reject when needed.
37
- console.error("Session refresh failed", error);
37
+ reportError(error, { message: "Session refresh failed" });
38
38
  }
39
39
  }
40
40
 
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { createConsoleEmail, type EmailProvider } from "@clubedge/email";
4
4
  import { createSmtpEmail } from "@clubedge/email-smtp";
5
5
  import { optional, parseEnv } from "@/env/server";
6
+ import { withSpans } from "@clubedge/core";
6
7
 
7
8
  export type { EmailMessage, EmailProvider } from "@clubedge/email";
8
9
 
@@ -34,6 +35,6 @@ function resolveProvider(): EmailProvider {
34
35
  }
35
36
 
36
37
  /** The configured email provider, resolved on first use so imports stay side-effect free. */
37
- export const email: EmailProvider = {
38
+ export const email: EmailProvider = withSpans("email", {
38
39
  send: (message) => resolveProvider().send(message),
39
- };
40
+ });
@@ -2,6 +2,7 @@ import { createServerFn } from "@tanstack/react-start";
2
2
  import { sampleJob } from "@/jobs";
3
3
  import { getJobs, isWorkerEnabled } from "./jobs";
4
4
  import { sampleJobRequester } from "./jobs-forms";
5
+ import { reportError } from "@clubedge/core";
5
6
 
6
7
  /** What the dashboard's jobs card shows; serializable, so it can cross to the browser. */
7
8
  export const getJobsStatus = createServerFn({ method: "GET" }).handler(async () => {
@@ -21,7 +22,7 @@ export const getJobsStatus = createServerFn({ method: "GET" }).handler(async ()
21
22
  })),
22
23
  };
23
24
  } catch (error) {
24
- console.error("Background jobs: could not list recent jobs.", error);
25
+ reportError(error, { message: "Background jobs: could not list recent jobs." });
25
26
  return { worker, jobs: null };
26
27
  }
27
28
  });
@@ -4,6 +4,7 @@ import type { JobQueue } from "@clubedge/jobs";
4
4
  import { createPgBossJobs } from "@clubedge/jobs-pg-boss";
5
5
  import { jobs } from "@/jobs";
6
6
  import { getServerEnv, optional, parseEnv } from "@/env/server";
7
+ import { reportError } from "@clubedge/core";
7
8
 
8
9
  export type { JobRecord } from "@clubedge/jobs";
9
10
 
@@ -41,6 +42,11 @@ export function getJobs(): JobQueue {
41
42
  jobs,
42
43
  worker: isWorkerEnabled(),
43
44
  maxConnections: env.NODE_ENV === "production" ? 4 : 2,
45
+ onError: (error, context) =>
46
+ reportError(error, {
47
+ message: context ? `Background job "${context.job}" failed` : "Background jobs error",
48
+ attributes: context,
49
+ }),
44
50
  });
45
51
  return globalForJobs.clubedgeJobs;
46
52
  }
@@ -52,7 +58,9 @@ export function startJobsWorker() {
52
58
  getJobs()
53
59
  .startWorker()
54
60
  .catch((error: unknown) => {
55
- console.error("Background jobs: the worker could not start, retrying in 5 seconds.", error);
61
+ reportError(error, {
62
+ message: "Background jobs: the worker could not start, retrying in 5 seconds.",
63
+ });
56
64
  setTimeout(start, 5_000).unref();
57
65
  });
58
66
  void start();
@@ -0,0 +1,36 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import { z } from "zod";
3
+ import { setTelemetry } from "@clubedge/core";
4
+ import type { ObservabilityProvider } from "@clubedge/observability";
5
+ import { createOtelObservability, parseOtlpHeaders } from "@clubedge/observability-otel";
6
+ import { siteConfig } from "@/config/site";
7
+ import { optional, parseEnv } from "@/env/server";
8
+
9
+ // OpenTelemetry: traces and error logs over OTLP/HTTP to any collector. Without an endpoint,
10
+ // nothing is sent and errors keep going to the server log.
11
+ const observabilityEnvSchema = z.object({
12
+ OTEL_EXPORTER_OTLP_ENDPOINT: optional(z.url()),
13
+ OTEL_EXPORTER_OTLP_HEADERS: optional(z.string()),
14
+ OTEL_SERVICE_NAME: optional(z.string()),
15
+ });
16
+
17
+ const globalForObservability = globalThis as typeof globalThis & {
18
+ clubedgeObservability?: ObservabilityProvider | null;
19
+ };
20
+
21
+ /** Installs the provider once per process; called when the server starts. */
22
+ export function startObservability(): ObservabilityProvider | null {
23
+ if (globalForObservability.clubedgeObservability !== undefined)
24
+ return globalForObservability.clubedgeObservability;
25
+ const env = parseEnv(observabilityEnvSchema, "Observability");
26
+ const provider = env.OTEL_EXPORTER_OTLP_ENDPOINT
27
+ ? createOtelObservability({
28
+ endpoint: env.OTEL_EXPORTER_OTLP_ENDPOINT,
29
+ headers: parseOtlpHeaders(env.OTEL_EXPORTER_OTLP_HEADERS),
30
+ serviceName: env.OTEL_SERVICE_NAME || siteConfig.serviceId,
31
+ })
32
+ : null;
33
+ if (provider) setTelemetry(provider);
34
+ globalForObservability.clubedgeObservability = provider;
35
+ return provider;
36
+ }
@@ -1,5 +1,6 @@
1
1
  import "@tanstack/react-start/server-only";
2
2
  import { z } from "zod";
3
+ import { withSpans } from "@clubedge/core";
3
4
  import { createBilling, type Billing } from "@clubedge/payments";
4
5
  import { createStripePayments } from "@clubedge/payments-stripe";
5
6
  import { plans } from "@/config/plans";
@@ -47,14 +48,17 @@ export function getBilling(): Billing {
47
48
  throw new Error(
48
49
  "Payments are not configured: set STRIPE_SECRET_KEY and STRIPE_WEBHOOK_SECRET.",
49
50
  );
50
- billing = createBilling({
51
- db: getDb(),
52
- provider: createStripePayments({
53
- secretKey: env.secretKey,
54
- webhookSecret: env.webhookSecret,
55
- prices: prices(),
56
- apiBase: env.STRIPE_API_BASE,
51
+ billing = withSpans(
52
+ "payments",
53
+ createBilling({
54
+ db: getDb(),
55
+ provider: createStripePayments({
56
+ secretKey: env.secretKey,
57
+ webhookSecret: env.webhookSecret,
58
+ prices: prices(),
59
+ apiBase: env.STRIPE_API_BASE,
60
+ }),
57
61
  }),
58
- });
62
+ );
59
63
  return billing;
60
64
  }
@@ -6,6 +6,7 @@ import { createS3Storage } from "@clubedge/storage-s3";
6
6
  import { createFileSystemStorage } from "@clubedge/storage/file-system";
7
7
  // clubedge:end
8
8
  import { optional, parseEnv } from "@/env/server";
9
+ import { withSpans } from "@clubedge/core";
9
10
 
10
11
  export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
11
12
 
@@ -46,10 +47,10 @@ function resolveProvider(): StorageProvider {
46
47
  }
47
48
 
48
49
  /** The configured storage provider, resolved on first use so imports stay side-effect free. */
49
- export const storage: StorageProvider = {
50
+ export const storage: StorageProvider = withSpans("storage", {
50
51
  upload: (input) => resolveProvider().upload(input),
51
52
  download: (key) => resolveProvider().download(key),
52
53
  delete: (key) => resolveProvider().delete(key),
53
54
  exists: (key) => resolveProvider().exists(key),
54
55
  getSignedUrl: (key, options) => resolveProvider().getSignedUrl(key, options),
55
- };
56
+ });
@@ -2,6 +2,7 @@ import "@tanstack/react-start/server-only";
2
2
  import type { AuthUser } from "@clubedge/auth";
3
3
  import { ensureUserId, upsertUserProfile } from "@clubedge/db";
4
4
  import { getDb } from "./db";
5
+ import { reportError } from "@clubedge/core";
5
6
 
6
7
  /**
7
8
  * Keeps the app-owned users row in step with the identity provider. Failures are logged
@@ -12,7 +13,7 @@ export async function ensureUserProfile(user: AuthUser | null): Promise<void> {
12
13
  try {
13
14
  await upsertUserProfile(getDb(), { authUserId: user.id, email: user.email });
14
15
  } catch (error) {
15
- console.error("Could not sync the user profile", error);
16
+ reportError(error, { message: "Could not sync the user profile" });
16
17
  }
17
18
  }
18
19
 
@@ -8,7 +8,7 @@ import {
8
8
  } from "@clubedge/auth-better-auth";
9
9
  import { siteConfig } from "@/config/site";
10
10
  import { authEmailSender } from "@/server/auth-email";
11
- import { AppError } from "@clubedge/core";
11
+ import { AppError, reportError } from "@clubedge/core";
12
12
  import { getBetterAuthEnv } from "@/env/auth";
13
13
  import { requestCookieStore } from "@/server/cookies";
14
14
  import { getDb } from "@/server/db";
@@ -47,7 +47,7 @@ export async function refreshAuthSession(): Promise<void> {
47
47
  await getAuth().refreshSession();
48
48
  } catch (error) {
49
49
  // Pages still render; protected handlers re-check the user and reject when needed.
50
- console.error("Session refresh failed", error);
50
+ reportError(error, { message: "Session refresh failed" });
51
51
  }
52
52
  }
53
53
 
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { createConsoleEmail, type EmailProvider } from "@clubedge/email";
4
4
  import { createResendEmail } from "@clubedge/email-resend";
5
5
  import { optional, parseEnv } from "@/env/server";
6
+ import { withSpans } from "@clubedge/core";
6
7
 
7
8
  export type { EmailMessage, EmailProvider } from "@clubedge/email";
8
9
 
@@ -34,6 +35,6 @@ function resolveProvider(): EmailProvider {
34
35
  }
35
36
 
36
37
  /** The configured email provider, resolved on first use so imports stay side-effect free. */
37
- export const email: EmailProvider = {
38
+ export const email: EmailProvider = withSpans("email", {
38
39
  send: (message) => resolveProvider().send(message),
39
- };
40
+ });
@@ -0,0 +1,42 @@
1
+ import "@tanstack/react-start/server-only";
2
+ import { z } from "zod";
3
+ import { setTelemetry } from "@clubedge/core";
4
+ import type { ObservabilityProvider } from "@clubedge/observability";
5
+ import { createSentryObservability } from "@clubedge/observability-sentry";
6
+ import { optional, parseEnv } from "@/env/server";
7
+
8
+ // Sentry: server errors and traces. Without a DSN, nothing is sent and errors keep going to the
9
+ // server log.
10
+ const observabilityEnvSchema = z.object({
11
+ SENTRY_DSN: optional(z.url()),
12
+ SENTRY_ENVIRONMENT: optional(z.string()),
13
+ SENTRY_RELEASE: optional(z.string()),
14
+ // The share of requests traced, from 0 to 1; an empty value keeps the default, 0.1.
15
+ SENTRY_TRACES_SAMPLE_RATE: optional(
16
+ z.string().regex(/^(0(\.\d+)?|1(\.0+)?)$/, "a number from 0 to 1"),
17
+ ),
18
+ });
19
+
20
+ const globalForObservability = globalThis as typeof globalThis & {
21
+ clubedgeObservability?: ObservabilityProvider | null;
22
+ };
23
+
24
+ /** Installs the provider once per process; called when the server starts. */
25
+ export function startObservability(): ObservabilityProvider | null {
26
+ if (globalForObservability.clubedgeObservability !== undefined)
27
+ return globalForObservability.clubedgeObservability;
28
+ const env = parseEnv(observabilityEnvSchema, "Observability");
29
+ const provider = env.SENTRY_DSN
30
+ ? createSentryObservability({
31
+ dsn: env.SENTRY_DSN,
32
+ environment: env.SENTRY_ENVIRONMENT || process.env.NODE_ENV,
33
+ release: env.SENTRY_RELEASE || undefined,
34
+ tracesSampleRate: env.SENTRY_TRACES_SAMPLE_RATE
35
+ ? Number(env.SENTRY_TRACES_SAMPLE_RATE)
36
+ : undefined,
37
+ })
38
+ : null;
39
+ if (provider) setTelemetry(provider);
40
+ globalForObservability.clubedgeObservability = provider;
41
+ return provider;
42
+ }
@@ -4,6 +4,7 @@ import type { StorageProvider } from "@clubedge/storage";
4
4
  import { createSupabaseStorage } from "@clubedge/storage-supabase";
5
5
  import { optional, parseEnv } from "@/env/server";
6
6
  import { getSupabaseClient } from "@/server/auth";
7
+ import { withSpans } from "@clubedge/core";
7
8
 
8
9
  export type { SignedUrlOptions, StorageProvider, UploadInput } from "@clubedge/storage";
9
10
 
@@ -26,10 +27,10 @@ function resolveProvider(): StorageProvider {
26
27
  }
27
28
 
28
29
  /** The configured storage provider, resolved on first use so imports stay side-effect free. */
29
- export const storage: StorageProvider = {
30
+ export const storage: StorageProvider = withSpans("storage", {
30
31
  upload: (input) => resolveProvider().upload(input),
31
32
  download: (key) => resolveProvider().download(key),
32
33
  delete: (key) => resolveProvider().delete(key),
33
34
  exists: (key) => resolveProvider().exists(key),
34
35
  getSignedUrl: (key, options) => resolveProvider().getSignedUrl(key, options),
35
- };
36
+ });