@aurostack/stacks 0.1.0 → 0.2.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 (88) hide show
  1. package/README.md +84 -0
  2. package/cli/src/generate.mjs +13 -1
  3. package/cli/src/hooks.mjs +10 -3
  4. package/cli/stack.mjs +3 -0
  5. package/package.json +4 -1
  6. package/templates/nest-api/files/.env.example +29 -2
  7. package/templates/nest-api/files/.env.test.example +1 -1
  8. package/templates/nest-api/files/compose.yml +16 -0
  9. package/templates/nest-api/files/package.json +19 -4
  10. package/templates/nest-api/files/src/app.module.ts +9 -1
  11. package/templates/nest-api/files/src/app.setup.ts +5 -6
  12. package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
  13. package/templates/nest-api/files/src/common/common.module.ts +4 -3
  14. package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
  15. package/templates/nest-api/files/src/common/modules/index.ts +0 -1
  16. package/templates/nest-api/files/src/common/modules/logger.module.ts +2 -6
  17. package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
  18. package/templates/nest-api/files/src/common/services/index.ts +1 -0
  19. package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
  20. package/templates/nest-api/files/src/instrumentation.ts +117 -0
  21. package/templates/nest-api/files/src/main.ts +3 -1
  22. package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
  23. package/templates/nest-api/files/src/telemetry/config.ts +62 -0
  24. package/templates/nest-api/template.json +37 -11
  25. package/templates/node-worker/files/.env.example +27 -0
  26. package/templates/node-worker/files/.prettierignore +2 -0
  27. package/templates/node-worker/files/Dockerfile +25 -2
  28. package/templates/node-worker/files/Dockerfile.dev +25 -2
  29. package/templates/node-worker/files/ecosystem.config.cjs +2 -0
  30. package/templates/node-worker/files/package.json +23 -4
  31. package/templates/node-worker/files/src/config.ts +23 -0
  32. package/templates/node-worker/files/src/index.ts +13 -0
  33. package/templates/node-worker/files/src/instrumentation.ts +110 -0
  34. package/templates/node-worker/files/src/telemetry/config.ts +61 -0
  35. package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
  36. package/templates/node-worker/files/src/temporal/worker.ts +84 -0
  37. package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
  38. package/templates/node-worker/files/src/utils/worker.ts +9 -1
  39. package/templates/node-worker/template.json +44 -2
  40. package/templates/py-worker/files/.env.example +28 -0
  41. package/templates/py-worker/files/config.py +32 -1
  42. package/templates/py-worker/files/requirements.txt +16 -0
  43. package/templates/py-worker/files/temporal/__init__.py +0 -0
  44. package/templates/py-worker/files/temporal/activities.py +13 -0
  45. package/templates/py-worker/files/temporal/workflows.py +38 -0
  46. package/templates/py-worker/files/utils/telemetry.py +154 -0
  47. package/templates/py-worker/files/utils/temporal.py +55 -0
  48. package/templates/py-worker/files/utils/worker.py +12 -1
  49. package/templates/py-worker/files/worker.py +16 -0
  50. package/templates/py-worker/template.json +36 -0
  51. package/templates/react-app/derive.sh +2 -1
  52. package/templates/react-app/files/.env.example +11 -0
  53. package/templates/react-app/files/package.json +2 -0
  54. package/templates/react-app/files/src/lib/env.ts +10 -0
  55. package/templates/react-app/files/src/main.tsx +4 -0
  56. package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
  57. package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
  58. package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
  59. package/templates/react-app/template.json +15 -1
  60. package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
  61. package/templates/react-monorepo/files/apps/admin/package.json +1 -0
  62. package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
  63. package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
  64. package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
  65. package/templates/react-monorepo/files/apps/auth/package.json +1 -0
  66. package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
  67. package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
  68. package/templates/react-monorepo/files/apps/client/.env.example +11 -0
  69. package/templates/react-monorepo/files/apps/client/package.json +1 -0
  70. package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
  71. package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
  72. package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
  73. package/templates/react-monorepo/files/apps/landing/package.json +1 -0
  74. package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
  75. package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
  76. package/templates/react-monorepo/files/packages/auth/package.json +1 -0
  77. package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
  78. package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
  79. package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
  80. package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
  81. package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
  82. package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
  83. package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
  84. package/templates/react-monorepo/template.json +47 -1
  85. package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
  86. package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
  87. package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
  88. package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
@@ -0,0 +1,66 @@
1
+ import { Injectable, OnApplicationShutdown } from '@nestjs/common';
2
+ import { Client, Connection, type TLSConfig } from '@temporalio/client';
3
+ import { CustomConfigService } from './config.service';
4
+ import type Config from './config.service';
5
+
6
+ /**
7
+ * A Temporal client for starting and querying workflows; a worker (node-worker
8
+ * or py-worker) polling the same namespace and task queue runs them.
9
+ *
10
+ * The connection is lazy: nothing is dialled until the first call, so the API
11
+ * boots, and serves everything else, while Temporal is unreachable.
12
+ *
13
+ * ```ts
14
+ * const handle = await this.temporal.client.workflow.start('example', {
15
+ * taskQueue: this.temporal.taskQueue,
16
+ * workflowId: `example-${user.id}`, // one run per id: a natural dedupe key
17
+ * args: [{ name: user.name }]
18
+ * });
19
+ * const result = await handle.result(); // or return handle.workflowId
20
+ * ```
21
+ *
22
+ * Reach for Temporal over the BullMQ queue when the work spans many steps,
23
+ * waits (for minutes to months, or on a signal), or must resume exactly where
24
+ * it stopped after a crash. A fire-and-forget job is still a queue job.
25
+ */
26
+ @Injectable()
27
+ export class TemporalService implements OnApplicationShutdown {
28
+ readonly client: Client;
29
+ /** The default task queue, from TEMPORAL_TASK_QUEUE. */
30
+ readonly taskQueue: string;
31
+ private readonly connection: Connection;
32
+
33
+ constructor(config: CustomConfigService) {
34
+ const { address, namespace, taskQueue, tls } = config.temporal;
35
+ this.connection = Connection.lazy({ address, tls: temporalTls(tls) });
36
+ this.client = new Client({ connection: this.connection, namespace });
37
+ this.taskQueue = taskQueue;
38
+ }
39
+
40
+ async onApplicationShutdown() {
41
+ await this.connection.close();
42
+ }
43
+ }
44
+
45
+ /**
46
+ * mTLS settings from PEM strings; none at all means plaintext (`null`).
47
+ * Escaped `\n` are unescaped, so a certificate fits on one line of an env
48
+ * file.
49
+ */
50
+ export function temporalTls({
51
+ ca,
52
+ cert,
53
+ key
54
+ }: Config.Temporal['tls']): TLSConfig | null {
55
+ if (!ca && !cert && !key) return null;
56
+ if (!cert || !key) {
57
+ throw new Error(
58
+ 'TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must be set together'
59
+ );
60
+ }
61
+ const pem = (value: string) => Buffer.from(value.replace(/\\n/g, '\n'));
62
+ return {
63
+ ...(ca && { serverRootCACertificate: pem(ca) }),
64
+ clientCertPair: { crt: pem(cert), key: pem(key) }
65
+ };
66
+ }
@@ -0,0 +1,117 @@
1
+ /**
2
+ * OpenTelemetry bootstrap: traces, logs and metrics over OTLP. main.ts imports
3
+ * this first, because instrumentation has to patch modules before anything
4
+ * requires them. Connection settings are in src/telemetry/config.ts; without
5
+ * them this file does nothing.
6
+ */
7
+ import fs from 'node:fs';
8
+ import path from 'node:path';
9
+ import { register } from 'node:module';
10
+ import { pathToFileURL } from 'node:url';
11
+ import { resolveTelemetry } from './telemetry/config';
12
+
13
+ // Nest reads .env later, through ConfigModule; the connection settings are
14
+ // needed now. Already-set variables (Docker, the shell) are never overridden.
15
+ try {
16
+ process.loadEnvFile();
17
+ } catch {
18
+ // no .env — the environment is configured some other way
19
+ }
20
+
21
+ const telemetry = resolveTelemetry();
22
+
23
+ if (telemetry.enabled) {
24
+ // Nest 12 and many of its dependencies load as ESM, which require-hooks do
25
+ // not see; import-in-the-middle lets instrumentations patch those too.
26
+ register('import-in-the-middle/hook.mjs', pathToFileURL(__filename));
27
+ start();
28
+ }
29
+
30
+ function start() {
31
+ /* eslint-disable @typescript-eslint/no-require-imports */
32
+ // Required lazily so a project with telemetry off never loads the SDK, and
33
+ // typed so the compiler still checks every constructor call below.
34
+ const { NodeSDK } =
35
+ require('@opentelemetry/sdk-node') as typeof import('@opentelemetry/sdk-node');
36
+ const { getNodeAutoInstrumentations } =
37
+ require('@opentelemetry/auto-instrumentations-node') as typeof import('@opentelemetry/auto-instrumentations-node');
38
+ const { OTLPTraceExporter } =
39
+ require('@opentelemetry/exporter-trace-otlp-proto') as typeof import('@opentelemetry/exporter-trace-otlp-proto');
40
+ const { OTLPLogExporter } =
41
+ require('@opentelemetry/exporter-logs-otlp-proto') as typeof import('@opentelemetry/exporter-logs-otlp-proto');
42
+ const { OTLPMetricExporter } =
43
+ require('@opentelemetry/exporter-metrics-otlp-proto') as typeof import('@opentelemetry/exporter-metrics-otlp-proto');
44
+ const { BatchLogRecordProcessor } =
45
+ require('@opentelemetry/sdk-logs') as typeof import('@opentelemetry/sdk-logs');
46
+ const { PeriodicExportingMetricReader } =
47
+ require('@opentelemetry/sdk-metrics') as typeof import('@opentelemetry/sdk-metrics');
48
+ const { resourceFromAttributes } =
49
+ require('@opentelemetry/resources') as typeof import('@opentelemetry/resources');
50
+ const { ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION } =
51
+ require('@opentelemetry/semantic-conventions') as typeof import('@opentelemetry/semantic-conventions');
52
+ const { ExpressLayerType } =
53
+ require('@opentelemetry/instrumentation-express') as typeof import('@opentelemetry/instrumentation-express');
54
+ const { PrismaInstrumentation } =
55
+ require('@prisma/instrumentation') as typeof import('@prisma/instrumentation');
56
+ /* eslint-enable @typescript-eslint/no-require-imports */
57
+
58
+ const { urls, headers } = telemetry;
59
+ // Explicit URLs always come with their headers; without them the SDK reads
60
+ // OTEL_EXPORTER_OTLP_* itself.
61
+ const exporter = (url?: string) => (url && headers ? { url, headers } : {});
62
+
63
+ const sdk = new NodeSDK({
64
+ resource: resourceFromAttributes({
65
+ [ATTR_SERVICE_NAME]: telemetry.serviceName,
66
+ [ATTR_SERVICE_VERSION]: packageVersion(),
67
+ 'deployment.environment.name': telemetry.environment
68
+ }),
69
+ traceExporter: new OTLPTraceExporter(exporter(urls?.traces)),
70
+ logRecordProcessors: [
71
+ new BatchLogRecordProcessor({
72
+ exporter: new OTLPLogExporter(exporter(urls?.logs))
73
+ })
74
+ ],
75
+ metricReaders: [
76
+ new PeriodicExportingMetricReader({
77
+ exporter: new OTLPMetricExporter(exporter(urls?.metrics)),
78
+ exportIntervalMillis:
79
+ Number(process.env.OTEL_METRIC_EXPORT_INTERVAL) || 60_000
80
+ })
81
+ ],
82
+ instrumentations: [
83
+ getNodeAutoInstrumentations({
84
+ // Every file read and DNS lookup as a span is noise, not signal.
85
+ '@opentelemetry/instrumentation-fs': { enabled: false },
86
+ '@opentelemetry/instrumentation-dns': { enabled: false },
87
+ '@opentelemetry/instrumentation-net': { enabled: false },
88
+ // One span per request handler, not one per middleware layer. Express
89
+ // 5 routes through the `router` package, whose own instrumentation
90
+ // would add a span for every layer again.
91
+ '@opentelemetry/instrumentation-express': {
92
+ ignoreLayersType: [ExpressLayerType.MIDDLEWARE]
93
+ },
94
+ '@opentelemetry/instrumentation-router': { enabled: false }
95
+ }),
96
+ new PrismaInstrumentation()
97
+ ]
98
+ });
99
+ sdk.start();
100
+
101
+ // Flush what is buffered on the way out, then let the signal do what it
102
+ // would have done without a handler.
103
+ for (const signal of ['SIGTERM', 'SIGINT'] as const) {
104
+ process.once(signal, () => {
105
+ sdk.shutdown().finally(() => process.kill(process.pid, signal));
106
+ });
107
+ }
108
+ }
109
+
110
+ function packageVersion(): string | undefined {
111
+ try {
112
+ const file = path.join(process.cwd(), 'package.json');
113
+ return JSON.parse(fs.readFileSync(file, 'utf8')).version;
114
+ } catch {
115
+ return undefined;
116
+ }
117
+ }
@@ -1,3 +1,5 @@
1
+ // Must stay first: instrumentation patches modules before anything loads them.
2
+ import './instrumentation'; // @feature observability
1
3
  import { NestFactory } from '@nestjs/core';
2
4
  import { AppModule } from 'app.module';
3
5
  import * as setup from './app.setup';
@@ -11,7 +13,7 @@ import * as setup from './app.setup';
11
13
  bufferLogs: true
12
14
  });
13
15
  setup.usePinoLogger(app);
14
- setup.enableBasicAuth(app); // @feature openapi, queue, observability
16
+ setup.enableBasicAuth(app); // @feature openapi, queue
15
17
  setup.enableVersioning(app);
16
18
  setup.setStatic(app);
17
19
  setup.enableJsonBodyParser(app);
@@ -0,0 +1,67 @@
1
+ import { resolveTelemetry } from '../config';
2
+
3
+ describe('resolveTelemetry', () => {
4
+ const openobserve = {
5
+ OPENOBSERVE_URL: 'https://o2.example.com/',
6
+ OPENOBSERVE_ORG: 'acme',
7
+ OPENOBSERVE_TOKEN: 'o2oi_secret'
8
+ };
9
+
10
+ it('stays off without connection settings', () => {
11
+ expect(resolveTelemetry({}).enabled).toBe(false);
12
+ });
13
+
14
+ it.each(['OPENOBSERVE_URL', 'OPENOBSERVE_ORG', 'OPENOBSERVE_TOKEN'])(
15
+ 'stays off when %s is missing',
16
+ (key) => {
17
+ const env: Record<string, string | undefined> = { ...openobserve };
18
+ delete env[key];
19
+ expect(resolveTelemetry(env).enabled).toBe(false);
20
+ }
21
+ );
22
+
23
+ it('builds the OpenObserve endpoints and org-token auth', () => {
24
+ const config = resolveTelemetry(openobserve, 'acme-api');
25
+
26
+ expect(config.enabled).toBe(true);
27
+ expect(config.urls).toEqual({
28
+ traces: 'https://o2.example.com/api/acme/v1/traces',
29
+ logs: 'https://o2.example.com/api/acme/v1/logs',
30
+ metrics: 'https://o2.example.com/api/acme/v1/metrics'
31
+ });
32
+ expect(config.headers).toEqual({
33
+ Authorization: `Basic ${Buffer.from('acme:o2oi_secret').toString('base64')}`,
34
+ 'stream-name': 'acme-api'
35
+ });
36
+ });
37
+
38
+ it('uses OTEL_SERVICE_NAME, APP_ENV and OPENOBSERVE_STREAM', () => {
39
+ const config = resolveTelemetry({
40
+ ...openobserve,
41
+ OTEL_SERVICE_NAME: 'billing-api',
42
+ APP_ENV: 'production',
43
+ OPENOBSERVE_STREAM: 'api'
44
+ });
45
+
46
+ expect(config.serviceName).toBe('billing-api');
47
+ expect(config.environment).toBe('production');
48
+ expect(config.headers?.['stream-name']).toBe('api');
49
+ });
50
+
51
+ it('defers to the standard OTEL_* variables when they are set', () => {
52
+ const config = resolveTelemetry({
53
+ ...openobserve,
54
+ OTEL_EXPORTER_OTLP_ENDPOINT: 'https://collector.example.com'
55
+ });
56
+
57
+ expect(config.enabled).toBe(true);
58
+ expect(config.urls).toBeUndefined();
59
+ expect(config.headers).toBeUndefined();
60
+ });
61
+
62
+ it('honours OTEL_SDK_DISABLED over everything', () => {
63
+ expect(
64
+ resolveTelemetry({ ...openobserve, OTEL_SDK_DISABLED: 'true' }).enabled
65
+ ).toBe(false);
66
+ });
67
+ });
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Where telemetry goes, resolved from the environment before anything else
3
+ * loads (instrumentation runs ahead of Nest, so it cannot use
4
+ * CustomConfigService — this is the one place that reads process.env for it).
5
+ *
6
+ * Two ways to connect, in order of precedence:
7
+ * - standard OpenTelemetry variables (OTEL_EXPORTER_OTLP_ENDPOINT and friends),
8
+ * which the SDK reads itself, for any OTLP backend;
9
+ * - OPENOBSERVE_URL + OPENOBSERVE_ORG + OPENOBSERVE_TOKEN, from which the
10
+ * OpenObserve endpoint and auth header are built.
11
+ * With neither, telemetry stays off and the SDK is never started.
12
+ */
13
+ export interface TelemetryConfig {
14
+ enabled: boolean;
15
+ serviceName: string;
16
+ environment: string;
17
+ /** Signal URLs; undefined means the SDK resolves them from OTEL_* vars. */
18
+ urls?: { traces: string; logs: string; metrics: string };
19
+ headers?: Record<string, string>;
20
+ }
21
+
22
+ type Env = Record<string, string | undefined>;
23
+
24
+ export function resolveTelemetry(
25
+ env: Env = process.env,
26
+ defaultServiceName = 'acme-api'
27
+ ): TelemetryConfig {
28
+ const serviceName = env.OTEL_SERVICE_NAME || defaultServiceName;
29
+ const environment = env.APP_ENV || 'development';
30
+ const off = { enabled: false, serviceName, environment };
31
+
32
+ if (env.OTEL_SDK_DISABLED === 'true') return off;
33
+ if (env.OTEL_EXPORTER_OTLP_ENDPOINT) {
34
+ return { enabled: true, serviceName, environment };
35
+ }
36
+
37
+ const {
38
+ OPENOBSERVE_URL: url,
39
+ OPENOBSERVE_ORG: org,
40
+ OPENOBSERVE_TOKEN: token
41
+ } = env;
42
+ if (!url || !org || !token) return off;
43
+
44
+ // OTLP/HTTP endpoints live under /api/<org>; an org ingestion token
45
+ // authenticates as `<org>:<token>`. Logs and traces land in the stream
46
+ // named here — one per service keeps an API and its workers apart.
47
+ const base = `${url.replace(/\/+$/, '')}/api/${org}`;
48
+ return {
49
+ enabled: true,
50
+ serviceName,
51
+ environment,
52
+ urls: {
53
+ traces: `${base}/v1/traces`,
54
+ logs: `${base}/v1/logs`,
55
+ metrics: `${base}/v1/metrics`
56
+ },
57
+ headers: {
58
+ Authorization: `Basic ${Buffer.from(`${org}:${token}`).toString('base64')}`,
59
+ 'stream-name': env.OPENOBSERVE_STREAM || serviceName
60
+ }
61
+ };
62
+ }
@@ -104,7 +104,8 @@
104
104
  "bullmq",
105
105
  "@bull-board/api",
106
106
  "@bull-board/express",
107
- "@bull-board/nestjs"
107
+ "@bull-board/nestjs",
108
+ "bullmq-otel"
108
109
  ]
109
110
  }
110
111
  },
@@ -168,23 +169,35 @@
168
169
  "core": true
169
170
  },
170
171
  "observability": {
171
- "title": "Metrics and health checks",
172
- "description": "A Prometheus /metrics endpoint and Terminus health checks, including a Prisma indicator. Structured pino logging is always included.",
172
+ "title": "Observability: OpenTelemetry and health checks",
173
+ "description": "OpenTelemetry traces, logs and metrics pushed over OTLP (built for OpenObserve; set OPENOBSERVE_URL/ORG/TOKEN, or any OTEL_* backend), plus Terminus health checks with a Prisma indicator. Off at runtime until connected.",
173
174
  "default": true,
174
175
  "files": [
175
- "src/common/modules/metrics.module.ts",
176
- "src/common/controllers/metrics.controller.ts",
177
176
  "src/common/controllers/health.controller.ts",
178
- "src/common/interceptors/metrics.interceptor.ts",
179
177
  "src/common/misc/prisma.indicator.ts",
180
178
  "src/common/__tests__/health.controller.spec.ts",
181
- "src/common/__tests__/prisma.indicator.spec.ts"
179
+ "src/common/__tests__/prisma.indicator.spec.ts",
180
+ "src/instrumentation.ts",
181
+ "src/telemetry/**"
182
182
  ],
183
183
  "packageJson": {
184
184
  "dependencies": [
185
- "prom-client",
186
- "@willsoto/nestjs-prometheus",
187
- "@nestjs/terminus"
185
+ "@nestjs/terminus",
186
+ "@opentelemetry/api",
187
+ "@opentelemetry/core",
188
+ "@opentelemetry/sdk-node",
189
+ "@opentelemetry/auto-instrumentations-node",
190
+ "@opentelemetry/exporter-trace-otlp-proto",
191
+ "@opentelemetry/exporter-logs-otlp-proto",
192
+ "@opentelemetry/exporter-metrics-otlp-proto",
193
+ "@opentelemetry/sdk-logs",
194
+ "@opentelemetry/sdk-metrics",
195
+ "@opentelemetry/resources",
196
+ "@opentelemetry/semantic-conventions",
197
+ "@opentelemetry/instrumentation",
198
+ "@opentelemetry/instrumentation-express",
199
+ "import-in-the-middle",
200
+ "@prisma/instrumentation"
188
201
  ]
189
202
  }
190
203
  },
@@ -290,6 +303,19 @@
290
303
  ]
291
304
  }
292
305
  },
306
+ "temporal": {
307
+ "title": "Temporal client",
308
+ "description": "A TemporalService for starting and querying durable workflows (multi-step, long-waiting or crash-resumable work; BullMQ stays for fire-and-forget jobs), plus Temporal's dev server in compose.yml. Pair it with node-worker or py-worker built --with temporal to run the workflows. Connects lazily, so the API runs while Temporal is down.",
309
+ "files": [
310
+ "src/common/services/temporal.service.ts",
311
+ "src/common/__tests__/temporal.service.spec.ts"
312
+ ],
313
+ "packageJson": {
314
+ "dependencies": [
315
+ "@temporalio/client"
316
+ ]
317
+ }
318
+ },
293
319
  "graphql": {
294
320
  "title": "GraphQL endpoint",
295
321
  "description": "Code-first GraphQL via Apollo, registered alongside the REST controllers. Resolvers in any feature module are picked up automatically.",
@@ -415,7 +441,7 @@
415
441
  },
416
442
  {
417
443
  "title": "yarn install",
418
- "run": "yarn install",
444
+ "run": "yarn install --no-immutable",
419
445
  "optional": true
420
446
  },
421
447
  {
@@ -13,6 +13,33 @@ REDIS_HOST='localhost'
13
13
  REDIS_PORT='6379'
14
14
  REDIS_USER=''
15
15
  REDIS_PASSWORD=''
16
+ # @feature:start telemetry
17
+
18
+ # OpenTelemetry → OpenObserve: traces, logs and metrics. Leave empty and
19
+ # telemetry stays off. Per project: OPENOBSERVE_ORG is the organization
20
+ # identifier, OPENOBSERVE_TOKEN its ingestion token (IAM → Ingestion Tokens).
21
+ # Any other OTLP backend: set OTEL_EXPORTER_OTLP_ENDPOINT/HEADERS instead.
22
+ OPENOBSERVE_URL='https://o2.aurostack.co'
23
+ OPENOBSERVE_ORG=''
24
+ OPENOBSERVE_TOKEN=''
25
+ OTEL_SERVICE_NAME='collector'
26
+ # @feature:end
27
+
28
+ # @feature:start temporal
29
+ # Temporal: this process also runs the workflows in src/temporal. Locally, the
30
+ # API's dev server (its compose.yml; UI on http://localhost:8233), no TLS. In
31
+ # production, the server address, the project's namespace and its mTLS client
32
+ # certificate (plus the CA that signed the server's), as PEM. Escaped \n are
33
+ # accepted, so each fits on one line.
34
+ TEMPORAL_ADDRESS='localhost:7233'
35
+ TEMPORAL_NAMESPACE='default'
36
+ # The queue this worker polls; must match where the API starts workflows (its
37
+ # TEMPORAL_TASK_QUEUE). A mismatch is silent: workflows just sit waiting.
38
+ TEMPORAL_TASK_QUEUE='main'
39
+ TEMPORAL_TLS_CA=''
40
+ TEMPORAL_TLS_CERT=''
41
+ TEMPORAL_TLS_KEY=''
42
+ # @feature:end
16
43
 
17
44
  # @feature:start browser
18
45
  # Empty = use Puppeteer's bundled Chromium. Set this in a slim container image
@@ -2,3 +2,5 @@ node_modules/
2
2
  dist/
3
3
  tmp/
4
4
  yarn.lock
5
+ # the Prisma client is generated; formatting it would only churn
6
+ prisma/generated/
@@ -1,5 +1,11 @@
1
1
  # Stage 1: Build
2
+ # @feature:start temporal
3
+ # Temporal's native core ships glibc builds only (it fails to load on Alpine,
4
+ # even with gcompat), so a Temporal worker builds and runs on Debian.
5
+ FROM node:24-bookworm-slim AS build
6
+ # @feature:else
2
7
  FROM aurostack.dev/wesnetech/nodejs:24 AS build
8
+ # @feature:end
3
9
 
4
10
  # @feature:start browser
5
11
  # Chromium comes from apk in the runtime stage; don't download a second copy.
@@ -31,10 +37,25 @@ RUN yarn build
31
37
  RUN yarn workspaces focus --all --production
32
38
 
33
39
  # Stage 2: Production
40
+ # @feature:start temporal
41
+ FROM node:24-bookworm-slim
42
+ # @feature:else
34
43
  FROM aurostack.dev/wesnetech/nodejs:24
44
+ # @feature:end
35
45
  WORKDIR /app
36
46
 
37
47
  # @feature:start browser
48
+ # @feature:start temporal
49
+ RUN apt-get update \
50
+ && apt-get install -y --no-install-recommends \
51
+ chromium \
52
+ ca-certificates \
53
+ fonts-freefont-ttf \
54
+ && rm -rf /var/lib/apt/lists/*
55
+
56
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
57
+ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
58
+ # @feature:else
38
59
  RUN apk add --no-cache \
39
60
  chromium \
40
61
  nss \
@@ -47,6 +68,7 @@ RUN apk add --no-cache \
47
68
  ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
48
69
  PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
49
70
  # @feature:end
71
+ # @feature:end
50
72
 
51
73
  COPY package.json ./
52
74
  # @feature:start pm2
@@ -56,8 +78,9 @@ COPY --from=build /app/node_modules ./node_modules
56
78
  COPY --from=build /app/dist ./dist
57
79
 
58
80
  # @feature:start pm2
59
- # pm2-runtime ships in the base image.
81
+ # pm2-runtime ships in the house base image; Debian's needs it installed.
82
+ RUN npm install -g pm2@7 # @feature temporal
60
83
  CMD [ "pm2-runtime", "start", "ecosystem.config.cjs" ]
61
84
  # @feature:else
62
- CMD [ "node", "dist/src/index.js" ]
85
+ CMD [ "node", "--import", "./dist/src/instrumentation.js", "dist/src/index.js" ]
63
86
  # @feature:end
@@ -1,5 +1,11 @@
1
1
  # Stage 1: Build
2
+ # @feature:start temporal
3
+ # Temporal's native core ships glibc builds only (it fails to load on Alpine,
4
+ # even with gcompat), so a Temporal worker builds and runs on Debian.
5
+ FROM node:24-bookworm-slim AS build
6
+ # @feature:else
2
7
  FROM aurostack.dev/wesnetech/nodejs:24 AS build
8
+ # @feature:end
3
9
 
4
10
  # @feature:start browser
5
11
  # Chromium comes from apk in the runtime stage; don't download a second copy.
@@ -27,10 +33,25 @@ COPY src ./src
27
33
  RUN yarn build
28
34
 
29
35
  # Stage 2: Production
36
+ # @feature:start temporal
37
+ FROM node:24-bookworm-slim
38
+ # @feature:else
30
39
  FROM aurostack.dev/wesnetech/nodejs:24
40
+ # @feature:end
31
41
  WORKDIR /app
32
42
 
33
43
  # @feature:start browser
44
+ # @feature:start temporal
45
+ RUN apt-get update \
46
+ && apt-get install -y --no-install-recommends \
47
+ chromium \
48
+ ca-certificates \
49
+ fonts-freefont-ttf \
50
+ && rm -rf /var/lib/apt/lists/*
51
+
52
+ ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
53
+ PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium
54
+ # @feature:else
34
55
  RUN apk add --no-cache \
35
56
  chromium \
36
57
  nss \
@@ -43,6 +64,7 @@ RUN apk add --no-cache \
43
64
  ENV PUPPETEER_SKIP_CHROMIUM_DOWNLOAD=true \
44
65
  PUPPETEER_EXECUTABLE_PATH=/usr/bin/chromium-browser
45
66
  # @feature:end
67
+ # @feature:end
46
68
 
47
69
  COPY package.json ./
48
70
  # @feature:start pm2
@@ -52,8 +74,9 @@ COPY --from=build /app/node_modules ./node_modules
52
74
  COPY --from=build /app/dist ./dist
53
75
 
54
76
  # @feature:start pm2
55
- # pm2-runtime ships in the base image.
77
+ # pm2-runtime ships in the house base image; Debian's needs it installed.
78
+ RUN npm install -g pm2@7 # @feature temporal
56
79
  CMD [ "pm2-runtime", "start", "ecosystem.config.cjs" ]
57
80
  # @feature:else
58
- CMD [ "node", "dist/src/index.js" ]
81
+ CMD [ "node", "--import", "./dist/src/instrumentation.js", "dist/src/index.js" ]
59
82
  # @feature:end
@@ -3,6 +3,8 @@ module.exports = {
3
3
  {
4
4
  name: 'collector',
5
5
  script: 'dist/src/index.js',
6
+ // Preload src/instrumentation.ts (OpenTelemetry) before the worker's modules.
7
+ node_args: '--import ./dist/src/instrumentation.js',
6
8
  watch: false,
7
9
  autorestart: false,
8
10
  }
@@ -24,10 +24,10 @@
24
24
  }
25
25
  },
26
26
  "scripts": {
27
- "dev": "tsx --conditions=development src/index.ts",
28
- "dev:watch": "tsx watch --conditions=development src/index.ts",
27
+ "dev": "tsx --conditions=development --import ./src/instrumentation.ts src/index.ts",
28
+ "dev:watch": "tsx watch --conditions=development --import ./src/instrumentation.ts src/index.ts",
29
29
  "build": "tsc",
30
- "start": "node dist/src/index.js",
30
+ "start": "node --import ./dist/src/instrumentation.js dist/src/index.js",
31
31
  "secrets": "tsx --conditions=development .bin/secrets",
32
32
  "db:generate": "tsx --conditions=development ./.bin/db.ts",
33
33
  "typecheck": "tsc --noEmit",
@@ -37,11 +37,30 @@
37
37
  "format:check": "prettier --check ."
38
38
  },
39
39
  "dependencies": {
40
+ "@opentelemetry/api": "^1.9.1",
41
+ "@opentelemetry/auto-instrumentations-node": "^0.80.0",
42
+ "@opentelemetry/core": "^2.11.0",
43
+ "@opentelemetry/exporter-logs-otlp-proto": "^0.222.0",
44
+ "@opentelemetry/exporter-metrics-otlp-proto": "^0.222.0",
45
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.222.0",
46
+ "@opentelemetry/instrumentation": "^0.222.0",
47
+ "@opentelemetry/resources": "^2.11.0",
48
+ "@opentelemetry/sdk-logs": "^0.222.0",
49
+ "@opentelemetry/sdk-metrics": "^2.11.0",
50
+ "@opentelemetry/sdk-node": "^0.222.0",
51
+ "@opentelemetry/semantic-conventions": "^1.43.0",
40
52
  "@paralleldrive/cuid2": "^3.3.0",
41
53
  "@prisma/adapter-pg": "^7.8.0",
42
54
  "@prisma/client": "^7.8.0",
43
- "bullmq": "^5.79.2",
55
+ "@prisma/instrumentation": "^7.10.0",
56
+ "@temporalio/activity": "^1.24.0",
57
+ "@temporalio/worker": "^1.24.0",
58
+ "@temporalio/workflow": "^1.24.0",
59
+ "bullmq": "^6.3.9",
60
+ "bullmq-otel": "^2.0.1",
44
61
  "dotenv": "^17.4.2",
62
+ "import-in-the-middle": "^3.5.1",
63
+ "ioredis": "^5.4.1",
45
64
  "moment": "^2.30.1",
46
65
  "pino": "^10.3.1",
47
66
  "puppeteer": "^25.3.0",
@@ -16,6 +16,16 @@ interface Redis {
16
16
  password?: string | undefined;
17
17
  }
18
18
 
19
+ // @feature:start temporal
20
+ interface Temporal {
21
+ address: string;
22
+ namespace: string;
23
+ taskQueue: string;
24
+ /** PEM; all empty means plaintext (the local dev server). */
25
+ tls: { ca: string; cert: string; key: string };
26
+ }
27
+ // @feature:end
28
+
19
29
  // @feature:start browser
20
30
  interface Puppeteer {
21
31
  executablePath: string;
@@ -26,6 +36,7 @@ interface Config {
26
36
  app: App;
27
37
  database: Database;
28
38
  redis: Redis;
39
+ temporal: Temporal; // @feature temporal
29
40
  puppeteer: Puppeteer; // @feature browser
30
41
  }
31
42
 
@@ -43,6 +54,18 @@ const config: Config = {
43
54
  user: process.env.REDIS_USER,
44
55
  password: process.env.REDIS_PASSWORD
45
56
  },
57
+ // @feature:start temporal
58
+ temporal: {
59
+ address: process.env.TEMPORAL_ADDRESS || 'localhost:7233',
60
+ namespace: process.env.TEMPORAL_NAMESPACE || 'default',
61
+ taskQueue: process.env.TEMPORAL_TASK_QUEUE || 'main',
62
+ tls: {
63
+ ca: process.env.TEMPORAL_TLS_CA ?? '',
64
+ cert: process.env.TEMPORAL_TLS_CERT ?? '',
65
+ key: process.env.TEMPORAL_TLS_KEY ?? ''
66
+ }
67
+ },
68
+ // @feature:end
46
69
  // @feature:start browser
47
70
  // Empty means "let Puppeteer use its bundled Chromium". In a slim container
48
71
  // image you install Chromium separately and point this at it.