@aurostack/stacks 0.1.1 → 0.2.1
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.
- package/README.md +59 -0
- package/cli/src/generate.mjs +13 -1
- package/cli/stack.mjs +1 -1
- package/package.json +1 -1
- package/templates/nest-api/files/.bin/secrets.ts +15 -6
- package/templates/nest-api/files/.env.example +29 -2
- package/templates/nest-api/files/.env.test.example +1 -1
- package/templates/nest-api/files/_gitignore +1 -0
- package/templates/nest-api/files/compose.yml +16 -0
- package/templates/nest-api/files/package.json +18 -3
- package/templates/nest-api/files/src/app.module.ts +9 -1
- package/templates/nest-api/files/src/app.setup.ts +8 -7
- package/templates/nest-api/files/src/common/__tests__/temporal.service.spec.ts +66 -0
- package/templates/nest-api/files/src/common/common.module.ts +4 -3
- package/templates/nest-api/files/src/common/constants/pino.ts +14 -0
- package/templates/nest-api/files/src/common/controllers/index.ts +0 -1
- package/templates/nest-api/files/src/common/modules/index.ts +0 -1
- package/templates/nest-api/files/src/common/modules/logger.module.ts +5 -8
- package/templates/nest-api/files/src/common/services/config.service.ts +43 -5
- package/templates/nest-api/files/src/common/services/index.ts +1 -0
- package/templates/nest-api/files/src/common/services/temporal.service.ts +66 -0
- package/templates/nest-api/files/src/instrumentation.ts +117 -0
- package/templates/nest-api/files/src/main.ts +3 -1
- package/templates/nest-api/files/src/notifications/services/notifications.service.ts +4 -2
- package/templates/nest-api/files/src/telemetry/__tests__/config.spec.ts +67 -0
- package/templates/nest-api/files/src/telemetry/config.ts +62 -0
- package/templates/nest-api/template.json +54 -14
- package/templates/node-worker/files/.bin/secrets.ts +15 -6
- package/templates/node-worker/files/.env.example +27 -0
- package/templates/node-worker/files/Dockerfile +25 -2
- package/templates/node-worker/files/Dockerfile.dev +25 -2
- package/templates/node-worker/files/_gitignore +1 -0
- package/templates/node-worker/files/ecosystem.config.cjs +2 -0
- package/templates/node-worker/files/package.json +24 -4
- package/templates/node-worker/files/src/config.ts +23 -0
- package/templates/node-worker/files/src/index.ts +40 -4
- package/templates/node-worker/files/src/instrumentation.ts +110 -0
- package/templates/node-worker/files/src/logger.ts +14 -1
- package/templates/node-worker/files/src/telemetry/config.ts +61 -0
- package/templates/node-worker/files/src/temporal/activities/index.ts +14 -0
- package/templates/node-worker/files/src/temporal/worker.ts +84 -0
- package/templates/node-worker/files/src/temporal/workflows/index.ts +23 -0
- package/templates/node-worker/files/src/utils/worker.ts +10 -2
- package/templates/node-worker/template.json +43 -1
- package/templates/py-worker/files/.env.example +28 -0
- package/templates/py-worker/files/config.py +32 -1
- package/templates/py-worker/files/requirements.txt +16 -0
- package/templates/py-worker/files/temporal/__init__.py +0 -0
- package/templates/py-worker/files/temporal/activities.py +13 -0
- package/templates/py-worker/files/temporal/workflows.py +38 -0
- package/templates/py-worker/files/utils/db.py +20 -27
- package/templates/py-worker/files/utils/logger.py +9 -2
- package/templates/py-worker/files/utils/telemetry.py +154 -0
- package/templates/py-worker/files/utils/temporal.py +55 -0
- package/templates/py-worker/files/utils/worker.py +14 -3
- package/templates/py-worker/files/worker.py +16 -0
- package/templates/py-worker/template.json +36 -0
- package/templates/react-app/derive.sh +2 -1
- package/templates/react-app/files/.bin/secrets.js +8 -2
- package/templates/react-app/files/.env.example +11 -0
- package/templates/react-app/files/.forgejo/workflows/build-and-push.yml +3 -0
- package/templates/react-app/files/Dockerfile +10 -0
- package/templates/react-app/files/_gitignore +1 -0
- package/templates/react-app/files/build.sh +3 -1
- package/templates/react-app/files/package.json +2 -0
- package/templates/react-app/files/src/lib/env.ts +10 -0
- package/templates/react-app/files/src/main.tsx +4 -0
- package/templates/react-app/files/src/shared/auth/provider.tsx +2 -0
- package/templates/react-app/files/src/shared/layouts/error-boundary.tsx +2 -0
- package/templates/react-app/files/src/shared/telemetry/index.ts +80 -0
- package/templates/react-app/template.json +16 -2
- package/templates/react-monorepo/files/.forgejo/workflows/build-and-push.yml +15 -11
- package/templates/react-monorepo/files/Dockerfile +21 -7
- package/templates/react-monorepo/files/_gitignore +1 -0
- package/templates/react-monorepo/files/apps/admin/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/admin/.env.example +11 -0
- package/templates/react-monorepo/files/apps/admin/package.json +1 -0
- package/templates/react-monorepo/files/apps/admin/src/app/router.tsx +11 -7
- package/templates/react-monorepo/files/apps/admin/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/admin/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/auth/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/auth/.env.example +11 -0
- package/templates/react-monorepo/files/apps/auth/package.json +1 -0
- package/templates/react-monorepo/files/apps/auth/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/auth/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/client/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/client/.env.example +11 -0
- package/templates/react-monorepo/files/apps/client/package.json +1 -0
- package/templates/react-monorepo/files/apps/client/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/client/src/main.tsx +4 -0
- package/templates/react-monorepo/files/apps/client/src/vite-env.d.ts +1 -1
- package/templates/react-monorepo/files/apps/landing/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/apps/landing/.env.example +11 -0
- package/templates/react-monorepo/files/apps/landing/package.json +1 -0
- package/templates/react-monorepo/files/apps/landing/src/lib/env.ts +10 -0
- package/templates/react-monorepo/files/apps/landing/src/main.tsx +4 -0
- package/templates/react-monorepo/files/build.sh +3 -1
- package/templates/react-monorepo/files/packages/auth/package.json +1 -0
- package/templates/react-monorepo/files/packages/auth/src/provider.tsx +2 -0
- package/templates/react-monorepo/files/packages/layouts/package.json +1 -0
- package/templates/react-monorepo/files/packages/layouts/src/error-boundary.tsx +2 -0
- package/templates/react-monorepo/files/packages/telemetry/eslint.config.mjs +3 -0
- package/templates/react-monorepo/files/packages/telemetry/package.json +20 -0
- package/templates/react-monorepo/files/packages/telemetry/src/index.ts +80 -0
- package/templates/react-monorepo/files/packages/telemetry/tsconfig.json +4 -0
- package/templates/react-monorepo/files/packages/types/.bin/secrets.js +8 -3
- package/templates/react-monorepo/files/turbo.json +1 -1
- package/templates/react-monorepo/template.json +87 -9
- package/templates/nest-api/files/src/common/controllers/metrics.controller.ts +0 -21
- package/templates/nest-api/files/src/common/interceptors/index.ts +0 -1
- package/templates/nest-api/files/src/common/interceptors/metrics.interceptor.ts +0 -37
- package/templates/nest-api/files/src/common/modules/metrics.module.ts +0 -28
|
@@ -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
|
-
"
|
|
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",
|
|
@@ -53,6 +72,7 @@
|
|
|
53
72
|
"@types/node": "^26.1.0",
|
|
54
73
|
"@types/shelljs": "^0.10.0",
|
|
55
74
|
"@types/yargs": "^17.0.35",
|
|
75
|
+
"dotenv-cli": "^7.2.1",
|
|
56
76
|
"eslint": "^10.6.0",
|
|
57
77
|
"eslint-config-prettier": "^10.1.8",
|
|
58
78
|
"globals": "^17.7.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.
|
|
@@ -1,7 +1,43 @@
|
|
|
1
1
|
import logger from '#app/logger.js';
|
|
2
2
|
import * as Workers from '#app/workers/index.js'; // @feature queue
|
|
3
|
+
import { createTemporalWorker } from '#app/temporal/worker.js'; // @feature temporal
|
|
3
4
|
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
5
|
+
// Everything still running when a shutdown starts: each entry settles once its
|
|
6
|
+
// worker has stopped taking new work and finished what it had.
|
|
7
|
+
const running: Promise<unknown>[] = [];
|
|
8
|
+
|
|
9
|
+
logger.info('Worker started');
|
|
10
|
+
|
|
11
|
+
// @feature:start queue
|
|
12
|
+
// Add each new BullMQ worker to this list: it is started here and closed on
|
|
13
|
+
// shutdown (close() stops fetching and waits for the active job).
|
|
14
|
+
const queueWorkers = [Workers.example];
|
|
15
|
+
for (const worker of queueWorkers) void worker.run();
|
|
16
|
+
// @feature:end
|
|
17
|
+
|
|
18
|
+
// @feature:start temporal
|
|
19
|
+
// Temporal handles SIGINT/SIGTERM itself: it stops polling and lets running
|
|
20
|
+
// tasks finish, and then run() resolves.
|
|
21
|
+
const temporal = createTemporalWorker().then((worker) => worker.run());
|
|
22
|
+
temporal.catch((err: unknown) => {
|
|
23
|
+
logger.fatal({ err }, 'Temporal worker failed');
|
|
24
|
+
process.exit(1);
|
|
25
|
+
});
|
|
26
|
+
running.push(temporal);
|
|
27
|
+
// @feature:end
|
|
28
|
+
|
|
29
|
+
let stopping = false;
|
|
30
|
+
const shutdown = (signal: NodeJS.Signals) => {
|
|
31
|
+
// `on` rather than `once`, and idempotent: the telemetry preload re-raises
|
|
32
|
+
// the signal after flushing, and that second delivery must not kill the
|
|
33
|
+
// process while jobs are still finishing.
|
|
34
|
+
if (stopping) return;
|
|
35
|
+
stopping = true;
|
|
36
|
+
logger.info({ signal }, 'Shutting down');
|
|
37
|
+
void Promise.allSettled([
|
|
38
|
+
...queueWorkers.map((worker) => worker.close()), // @feature queue
|
|
39
|
+
...running
|
|
40
|
+
]).then(() => process.exit(0));
|
|
41
|
+
};
|
|
42
|
+
process.on('SIGTERM', shutdown);
|
|
43
|
+
process.on('SIGINT', shutdown);
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Preloaded with `node --import` ahead of the worker (see the start scripts,
|
|
3
|
+
* ecosystem.config.cjs and the Dockerfile), for anything that has to run before
|
|
4
|
+
* the worker's own modules load.
|
|
5
|
+
*/
|
|
6
|
+
// @feature:start telemetry
|
|
7
|
+
// OpenTelemetry: traces, logs and metrics over OTLP. It has to be preloaded
|
|
8
|
+
// because ESM links the whole import graph before running any of it, so
|
|
9
|
+
// patching modules from inside src/index.ts would be too late. Connection
|
|
10
|
+
// settings are in src/telemetry/config.ts; without them this does nothing.
|
|
11
|
+
import fs from 'node:fs';
|
|
12
|
+
import path from 'node:path';
|
|
13
|
+
import { register } from 'node:module';
|
|
14
|
+
import { resolveTelemetry } from '#app/telemetry/config.js';
|
|
15
|
+
|
|
16
|
+
// config.ts loads .env later; the connection settings are needed now.
|
|
17
|
+
// Already-set variables are never overridden.
|
|
18
|
+
try {
|
|
19
|
+
process.loadEnvFile();
|
|
20
|
+
} catch {
|
|
21
|
+
// no .env — the environment is configured some other way
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
const telemetry = resolveTelemetry();
|
|
25
|
+
|
|
26
|
+
if (telemetry.enabled) {
|
|
27
|
+
register('import-in-the-middle/hook.mjs', import.meta.url);
|
|
28
|
+
|
|
29
|
+
// Imported only when enabled, so a worker without telemetry never loads the SDK.
|
|
30
|
+
const [
|
|
31
|
+
{ NodeSDK },
|
|
32
|
+
{ getNodeAutoInstrumentations },
|
|
33
|
+
{ OTLPTraceExporter },
|
|
34
|
+
{ OTLPLogExporter },
|
|
35
|
+
{ OTLPMetricExporter },
|
|
36
|
+
{ BatchLogRecordProcessor },
|
|
37
|
+
{ PeriodicExportingMetricReader },
|
|
38
|
+
{ resourceFromAttributes },
|
|
39
|
+
{ ATTR_SERVICE_NAME, ATTR_SERVICE_VERSION },
|
|
40
|
+
{ PrismaInstrumentation }
|
|
41
|
+
] = await Promise.all([
|
|
42
|
+
import('@opentelemetry/sdk-node'),
|
|
43
|
+
import('@opentelemetry/auto-instrumentations-node'),
|
|
44
|
+
import('@opentelemetry/exporter-trace-otlp-proto'),
|
|
45
|
+
import('@opentelemetry/exporter-logs-otlp-proto'),
|
|
46
|
+
import('@opentelemetry/exporter-metrics-otlp-proto'),
|
|
47
|
+
import('@opentelemetry/sdk-logs'),
|
|
48
|
+
import('@opentelemetry/sdk-metrics'),
|
|
49
|
+
import('@opentelemetry/resources'),
|
|
50
|
+
import('@opentelemetry/semantic-conventions'),
|
|
51
|
+
import('@prisma/instrumentation')
|
|
52
|
+
]);
|
|
53
|
+
|
|
54
|
+
const { urls, headers } = telemetry;
|
|
55
|
+
// Explicit URLs always come with their headers; without them the SDK reads
|
|
56
|
+
// OTEL_EXPORTER_OTLP_* itself.
|
|
57
|
+
const exporter = (url?: string) => (url && headers ? { url, headers } : {});
|
|
58
|
+
|
|
59
|
+
const sdk = new NodeSDK({
|
|
60
|
+
resource: resourceFromAttributes({
|
|
61
|
+
[ATTR_SERVICE_NAME]: telemetry.serviceName,
|
|
62
|
+
[ATTR_SERVICE_VERSION]: packageVersion(),
|
|
63
|
+
'deployment.environment.name': telemetry.environment
|
|
64
|
+
}),
|
|
65
|
+
traceExporter: new OTLPTraceExporter(exporter(urls?.traces)),
|
|
66
|
+
logRecordProcessors: [
|
|
67
|
+
new BatchLogRecordProcessor({
|
|
68
|
+
exporter: new OTLPLogExporter(exporter(urls?.logs))
|
|
69
|
+
})
|
|
70
|
+
],
|
|
71
|
+
metricReaders: [
|
|
72
|
+
new PeriodicExportingMetricReader({
|
|
73
|
+
exporter: new OTLPMetricExporter(exporter(urls?.metrics)),
|
|
74
|
+
exportIntervalMillis:
|
|
75
|
+
Number(process.env.OTEL_METRIC_EXPORT_INTERVAL) || 60_000
|
|
76
|
+
})
|
|
77
|
+
],
|
|
78
|
+
instrumentations: [
|
|
79
|
+
getNodeAutoInstrumentations({
|
|
80
|
+
// Every file read and DNS lookup as a span is noise, not signal.
|
|
81
|
+
'@opentelemetry/instrumentation-fs': { enabled: false },
|
|
82
|
+
'@opentelemetry/instrumentation-dns': { enabled: false },
|
|
83
|
+
'@opentelemetry/instrumentation-net': { enabled: false }
|
|
84
|
+
}),
|
|
85
|
+
new PrismaInstrumentation()
|
|
86
|
+
]
|
|
87
|
+
});
|
|
88
|
+
sdk.start();
|
|
89
|
+
|
|
90
|
+
// Flush what is buffered on the way out, then let the signal do what it
|
|
91
|
+
// would have done without a handler.
|
|
92
|
+
for (const signal of ['SIGTERM', 'SIGINT'] as const) {
|
|
93
|
+
process.once(signal, () => {
|
|
94
|
+
void sdk.shutdown().finally(() => process.kill(process.pid, signal));
|
|
95
|
+
});
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function packageVersion(): string | undefined {
|
|
100
|
+
try {
|
|
101
|
+
const file = path.join(process.cwd(), 'package.json');
|
|
102
|
+
return (JSON.parse(fs.readFileSync(file, 'utf8')) as { version?: string })
|
|
103
|
+
.version;
|
|
104
|
+
} catch {
|
|
105
|
+
return undefined;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
// @feature:end
|
|
109
|
+
|
|
110
|
+
export {};
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
1
2
|
import pino, { stdTimeFunctions, levels } from 'pino';
|
|
2
3
|
import config from '#app/config.js';
|
|
3
4
|
|
|
@@ -26,10 +27,22 @@ const PINO_CONSOLE_TARGET_PROD = {
|
|
|
26
27
|
}
|
|
27
28
|
};
|
|
28
29
|
|
|
30
|
+
// pino-pretty is a dev dependency, so the production image doesn't have it.
|
|
31
|
+
// Pretty-print only where it's installed; anywhere else (an image run with an
|
|
32
|
+
// APP_ENV other than production, say) log JSON to stdout instead of crashing.
|
|
33
|
+
const prettyAvailable = (() => {
|
|
34
|
+
try {
|
|
35
|
+
createRequire(import.meta.url).resolve('pino-pretty');
|
|
36
|
+
return true;
|
|
37
|
+
} catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
})();
|
|
41
|
+
|
|
29
42
|
const logger = pino({
|
|
30
43
|
level: config.app.logLevel,
|
|
31
44
|
transport:
|
|
32
|
-
config.app.env === 'production'
|
|
45
|
+
config.app.env === 'production' || !prettyAvailable
|
|
33
46
|
? { targets: [PINO_CONSOLE_TARGET_PROD, PINO_FILE_TARGET] }
|
|
34
47
|
: { targets: [PINO_CONSOLE_TARGET, PINO_FILE_TARGET] },
|
|
35
48
|
base: {
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Where telemetry goes, resolved from the environment before anything else
|
|
3
|
+
* loads (instrumentation is preloaded with `node --import`, ahead of config.ts).
|
|
4
|
+
*
|
|
5
|
+
* Two ways to connect, in order of precedence:
|
|
6
|
+
* - standard OpenTelemetry variables (OTEL_EXPORTER_OTLP_ENDPOINT and friends),
|
|
7
|
+
* which the SDK reads itself, for any OTLP backend;
|
|
8
|
+
* - OPENOBSERVE_URL + OPENOBSERVE_ORG + OPENOBSERVE_TOKEN, from which the
|
|
9
|
+
* OpenObserve endpoint and auth header are built.
|
|
10
|
+
* With neither, telemetry stays off and the SDK is never started.
|
|
11
|
+
*/
|
|
12
|
+
export interface TelemetryConfig {
|
|
13
|
+
enabled: boolean;
|
|
14
|
+
serviceName: string;
|
|
15
|
+
environment: string;
|
|
16
|
+
/** Signal URLs; undefined means the SDK resolves them from OTEL_* vars. */
|
|
17
|
+
urls?: { traces: string; logs: string; metrics: string };
|
|
18
|
+
headers?: Record<string, string>;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
type Env = Record<string, string | undefined>;
|
|
22
|
+
|
|
23
|
+
export function resolveTelemetry(
|
|
24
|
+
env: Env = process.env,
|
|
25
|
+
defaultServiceName = 'collector'
|
|
26
|
+
): TelemetryConfig {
|
|
27
|
+
const serviceName = env.OTEL_SERVICE_NAME || defaultServiceName;
|
|
28
|
+
const environment = env.APP_ENV || 'development';
|
|
29
|
+
const off = { enabled: false, serviceName, environment };
|
|
30
|
+
|
|
31
|
+
if (env.OTEL_SDK_DISABLED === 'true') return off;
|
|
32
|
+
if (env.OTEL_EXPORTER_OTLP_ENDPOINT) {
|
|
33
|
+
return { enabled: true, serviceName, environment };
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const {
|
|
37
|
+
OPENOBSERVE_URL: url,
|
|
38
|
+
OPENOBSERVE_ORG: org,
|
|
39
|
+
OPENOBSERVE_TOKEN: token
|
|
40
|
+
} = env;
|
|
41
|
+
if (!url || !org || !token) return off;
|
|
42
|
+
|
|
43
|
+
// OTLP/HTTP endpoints live under /api/<org>; an org ingestion token
|
|
44
|
+
// authenticates as `<org>:<token>`. Logs and traces land in the stream
|
|
45
|
+
// named here — one per service keeps an API and its workers apart.
|
|
46
|
+
const base = `${url.replace(/\/+$/, '')}/api/${org}`;
|
|
47
|
+
return {
|
|
48
|
+
enabled: true,
|
|
49
|
+
serviceName,
|
|
50
|
+
environment,
|
|
51
|
+
urls: {
|
|
52
|
+
traces: `${base}/v1/traces`,
|
|
53
|
+
logs: `${base}/v1/logs`,
|
|
54
|
+
metrics: `${base}/v1/metrics`
|
|
55
|
+
},
|
|
56
|
+
headers: {
|
|
57
|
+
Authorization: `Basic ${Buffer.from(`${org}:${token}`).toString('base64')}`,
|
|
58
|
+
'stream-name': env.OPENOBSERVE_STREAM || serviceName
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { Context, log } from '@temporalio/activity';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Activities do the real work: I/O, database writes, calls to other services.
|
|
5
|
+
* Temporal retries a failed activity on its own (per the retry policy set in
|
|
6
|
+
* the workflow), so throw on failure and make each one safe to run twice.
|
|
7
|
+
*/
|
|
8
|
+
export async function greet(name: string): Promise<string> {
|
|
9
|
+
log.info('Greeting', { name });
|
|
10
|
+
// Stand-in for real work. The context's sleep is cancellation-aware: it
|
|
11
|
+
// rejects if the workflow cancels this activity.
|
|
12
|
+
await Context.current().sleep(100);
|
|
13
|
+
return `Hello, ${name}!`;
|
|
14
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
import {
|
|
4
|
+
DefaultLogger,
|
|
5
|
+
NativeConnection,
|
|
6
|
+
Runtime,
|
|
7
|
+
Worker,
|
|
8
|
+
type LogLevel,
|
|
9
|
+
type TLSConfig
|
|
10
|
+
} from '@temporalio/worker';
|
|
11
|
+
import config from '#app/config.js';
|
|
12
|
+
import logger from '#app/logger.js';
|
|
13
|
+
import * as activities from './activities/index.js';
|
|
14
|
+
|
|
15
|
+
const here = fileURLToPath(import.meta.url);
|
|
16
|
+
|
|
17
|
+
const PINO_LEVEL = {
|
|
18
|
+
TRACE: 'trace',
|
|
19
|
+
DEBUG: 'debug',
|
|
20
|
+
INFO: 'info',
|
|
21
|
+
WARN: 'warn',
|
|
22
|
+
ERROR: 'error'
|
|
23
|
+
} as const satisfies Record<LogLevel, string>;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Connects to Temporal and returns a worker for TEMPORAL_TASK_QUEUE, running
|
|
27
|
+
* the workflows in ./workflows and the activities in ./activities. Temporal's
|
|
28
|
+
* own logs go through pino, like everything else here.
|
|
29
|
+
*/
|
|
30
|
+
export async function createTemporalWorker(): Promise<Worker> {
|
|
31
|
+
Runtime.install({
|
|
32
|
+
logger: new DefaultLogger(runtimeLogLevel(), ({ level, message, meta }) =>
|
|
33
|
+
logger[PINO_LEVEL[level]](meta ?? {}, message)
|
|
34
|
+
)
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const { address, namespace, taskQueue, tls } = config.temporal;
|
|
38
|
+
const connection = await NativeConnection.connect({
|
|
39
|
+
address,
|
|
40
|
+
tls: temporalTls(tls)
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
return Worker.create({
|
|
44
|
+
connection,
|
|
45
|
+
namespace,
|
|
46
|
+
taskQueue,
|
|
47
|
+
// Workflows run in a sandbox from their own bundle, built from this
|
|
48
|
+
// entry point when the worker starts: index.ts under tsx, index.js in dist.
|
|
49
|
+
workflowsPath: path.join(
|
|
50
|
+
path.dirname(here),
|
|
51
|
+
'workflows',
|
|
52
|
+
`index${path.extname(here)}`
|
|
53
|
+
),
|
|
54
|
+
activities
|
|
55
|
+
});
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* mTLS settings from PEM strings; none at all means plaintext (`null`).
|
|
60
|
+
* Escaped `\n` are unescaped, so a certificate fits on one line of an env
|
|
61
|
+
* file.
|
|
62
|
+
*/
|
|
63
|
+
export function temporalTls({
|
|
64
|
+
ca,
|
|
65
|
+
cert,
|
|
66
|
+
key
|
|
67
|
+
}: typeof config.temporal.tls): TLSConfig | null {
|
|
68
|
+
if (!ca && !cert && !key) return null;
|
|
69
|
+
if (!cert || !key) {
|
|
70
|
+
throw new Error('TEMPORAL_TLS_CERT and TEMPORAL_TLS_KEY must be set together');
|
|
71
|
+
}
|
|
72
|
+
const pem = (value: string) => Buffer.from(value.replace(/\\n/g, '\n'));
|
|
73
|
+
return {
|
|
74
|
+
...(ca ? { serverRootCACertificate: pem(ca) } : {}),
|
|
75
|
+
clientCertPair: { crt: pem(cert), key: pem(key) }
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** LOG_LEVEL as Temporal's level; pino's fatal/silent map to ERROR. */
|
|
80
|
+
function runtimeLogLevel(): LogLevel {
|
|
81
|
+
const level = config.app.logLevel.toUpperCase();
|
|
82
|
+
if (level in PINO_LEVEL) return level as LogLevel;
|
|
83
|
+
return level === 'FATAL' || level === 'SILENT' ? 'ERROR' : 'INFO';
|
|
84
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { proxyActivities, sleep } from '@temporalio/workflow';
|
|
2
|
+
import type * as activities from '../activities/index.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Workflows orchestrate; activities act. This code is replayed from history to
|
|
6
|
+
* rebuild state, so it must be deterministic: no I/O, no Date.now() or
|
|
7
|
+
* Math.random() of its own (the SDK makes both replay-safe), no imports that
|
|
8
|
+
* touch the network or filesystem. Anything like that belongs in an activity.
|
|
9
|
+
*
|
|
10
|
+
* Every function exported from this module is a workflow type, started by name
|
|
11
|
+
* (see the API's TemporalService).
|
|
12
|
+
*/
|
|
13
|
+
const { greet } = proxyActivities<typeof activities>({
|
|
14
|
+
startToCloseTimeout: '1 minute',
|
|
15
|
+
retry: { maximumAttempts: 5 }
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
export async function example(input: { name: string }): Promise<string> {
|
|
19
|
+
// A durable timer: survives worker restarts and deploys, for as long as
|
|
20
|
+
// you like ('30 days' works the same way).
|
|
21
|
+
await sleep('1 second');
|
|
22
|
+
return await greet(input.name);
|
|
23
|
+
}
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { Worker, type WorkerOptions, type Processor } from 'bullmq';
|
|
2
|
+
import { BullMQOtel } from 'bullmq-otel';
|
|
2
3
|
import config from '#app/config.js';
|
|
3
4
|
|
|
4
5
|
export const createWorker = <
|
|
@@ -13,13 +14,20 @@ export const createWorker = <
|
|
|
13
14
|
const connection = {
|
|
14
15
|
host: config.redis.host,
|
|
15
16
|
port: config.redis.port,
|
|
16
|
-
|
|
17
|
+
username: config.redis.user,
|
|
17
18
|
password: config.redis.password
|
|
18
19
|
};
|
|
19
20
|
|
|
20
21
|
const defaultOptions = {
|
|
21
22
|
concurrency: 1,
|
|
22
|
-
autorun: false
|
|
23
|
+
autorun: false,
|
|
24
|
+
// Job spans and metrics; a no-op unless the OpenTelemetry SDK is running
|
|
25
|
+
// (see src/instrumentation.ts).
|
|
26
|
+
telemetry: new BullMQOtel({
|
|
27
|
+
tracerName: 'collector',
|
|
28
|
+
meterName: 'collector',
|
|
29
|
+
enableMetrics: true
|
|
30
|
+
})
|
|
23
31
|
};
|
|
24
32
|
|
|
25
33
|
const options = !opts
|
|
@@ -47,7 +47,49 @@
|
|
|
47
47
|
],
|
|
48
48
|
"packageJson": {
|
|
49
49
|
"dependencies": [
|
|
50
|
-
"bullmq"
|
|
50
|
+
"bullmq",
|
|
51
|
+
"bullmq-otel",
|
|
52
|
+
"ioredis"
|
|
53
|
+
]
|
|
54
|
+
}
|
|
55
|
+
},
|
|
56
|
+
"temporal": {
|
|
57
|
+
"title": "Temporal worker",
|
|
58
|
+
"description": "Runs durable workflows and activities from src/temporal (an example of each included) on TEMPORAL_TASK_QUEUE, next to the BullMQ consumers. Pair with the API's temporal feature, which starts them.",
|
|
59
|
+
"files": [
|
|
60
|
+
"src/temporal/**"
|
|
61
|
+
],
|
|
62
|
+
"packageJson": {
|
|
63
|
+
"dependencies": [
|
|
64
|
+
"@temporalio/activity",
|
|
65
|
+
"@temporalio/worker",
|
|
66
|
+
"@temporalio/workflow"
|
|
67
|
+
]
|
|
68
|
+
}
|
|
69
|
+
},
|
|
70
|
+
"telemetry": {
|
|
71
|
+
"title": "OpenTelemetry → OpenObserve",
|
|
72
|
+
"description": "Traces, logs and metrics pushed over OTLP (set OPENOBSERVE_URL/ORG/TOKEN, or any OTEL_* backend). Off at runtime until connected.",
|
|
73
|
+
"default": true,
|
|
74
|
+
"files": [
|
|
75
|
+
"src/telemetry/**"
|
|
76
|
+
],
|
|
77
|
+
"packageJson": {
|
|
78
|
+
"dependencies": [
|
|
79
|
+
"@opentelemetry/api",
|
|
80
|
+
"@opentelemetry/core",
|
|
81
|
+
"@opentelemetry/sdk-node",
|
|
82
|
+
"@opentelemetry/auto-instrumentations-node",
|
|
83
|
+
"@opentelemetry/exporter-trace-otlp-proto",
|
|
84
|
+
"@opentelemetry/exporter-logs-otlp-proto",
|
|
85
|
+
"@opentelemetry/exporter-metrics-otlp-proto",
|
|
86
|
+
"@opentelemetry/sdk-logs",
|
|
87
|
+
"@opentelemetry/sdk-metrics",
|
|
88
|
+
"@opentelemetry/resources",
|
|
89
|
+
"@opentelemetry/semantic-conventions",
|
|
90
|
+
"@opentelemetry/instrumentation",
|
|
91
|
+
"import-in-the-middle",
|
|
92
|
+
"@prisma/instrumentation"
|
|
51
93
|
]
|
|
52
94
|
}
|
|
53
95
|
},
|
|
@@ -18,3 +18,31 @@ REDIS_HOST="localhost"
|
|
|
18
18
|
REDIS_PORT=6379
|
|
19
19
|
REDIS_USER=
|
|
20
20
|
REDIS_PASSWORD=""
|
|
21
|
+
# @feature:start telemetry
|
|
22
|
+
|
|
23
|
+
# OpenTelemetry → OpenObserve: traces, logs and metrics. Leave empty and
|
|
24
|
+
# telemetry stays off. Per project: OPENOBSERVE_ORG is the organization
|
|
25
|
+
# identifier, OPENOBSERVE_TOKEN its ingestion token (IAM → Ingestion Tokens).
|
|
26
|
+
# Any other OTLP backend: set OTEL_EXPORTER_OTLP_ENDPOINT/HEADERS instead.
|
|
27
|
+
OPENOBSERVE_URL="https://o2.aurostack.co"
|
|
28
|
+
OPENOBSERVE_ORG=""
|
|
29
|
+
OPENOBSERVE_TOKEN=""
|
|
30
|
+
OTEL_SERVICE_NAME="acme-py"
|
|
31
|
+
# @feature:end
|
|
32
|
+
|
|
33
|
+
# @feature:start temporal
|
|
34
|
+
# Temporal: this process also runs the workflows in temporal/. Locally, the
|
|
35
|
+
# API's dev server (its compose.yml; UI on http://localhost:8233), no TLS. In
|
|
36
|
+
# production, the server address, the project's namespace and its mTLS client
|
|
37
|
+
# certificate (plus the CA that signed the server's), as PEM. Escaped \n are
|
|
38
|
+
# accepted, so each fits on one line.
|
|
39
|
+
TEMPORAL_ADDRESS="localhost:7233"
|
|
40
|
+
TEMPORAL_NAMESPACE="default"
|
|
41
|
+
# The queue this worker polls. Its own, not the Node worker's: a workflow task
|
|
42
|
+
# handed to a worker that doesn't know the type fails and retries. Start
|
|
43
|
+
# Python workflows on this queue.
|
|
44
|
+
TEMPORAL_TASK_QUEUE="python"
|
|
45
|
+
TEMPORAL_TLS_CA=""
|
|
46
|
+
TEMPORAL_TLS_CERT=""
|
|
47
|
+
TEMPORAL_TLS_KEY=""
|
|
48
|
+
# @feature:end
|
|
@@ -30,11 +30,30 @@ class Redis:
|
|
|
30
30
|
password: str | None
|
|
31
31
|
|
|
32
32
|
|
|
33
|
+
# @feature:start temporal
|
|
34
|
+
@dataclass
|
|
35
|
+
class TemporalTLS:
|
|
36
|
+
# PEM; all empty means plaintext (the local dev server).
|
|
37
|
+
ca: str
|
|
38
|
+
cert: str
|
|
39
|
+
key: str
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass
|
|
43
|
+
class Temporal:
|
|
44
|
+
address: str
|
|
45
|
+
namespace: str
|
|
46
|
+
task_queue: str
|
|
47
|
+
tls: TemporalTLS
|
|
48
|
+
# @feature:end
|
|
49
|
+
|
|
50
|
+
|
|
33
51
|
@dataclass
|
|
34
52
|
class Env:
|
|
35
53
|
path: ProjectPath
|
|
36
54
|
db: DB
|
|
37
55
|
redis: Redis
|
|
56
|
+
temporal: Temporal # @feature temporal
|
|
38
57
|
|
|
39
58
|
|
|
40
59
|
class Config:
|
|
@@ -67,7 +86,19 @@ class Config:
|
|
|
67
86
|
port=int(os.getenv('REDIS_PORT', 0)),
|
|
68
87
|
user=os.getenv('REDIS_USER'),
|
|
69
88
|
password=os.getenv('REDIS_PASSWORD')
|
|
70
|
-
)
|
|
89
|
+
),
|
|
90
|
+
# @feature:start temporal
|
|
91
|
+
temporal=Temporal(
|
|
92
|
+
address=os.getenv('TEMPORAL_ADDRESS') or 'localhost:7233',
|
|
93
|
+
namespace=os.getenv('TEMPORAL_NAMESPACE') or 'default',
|
|
94
|
+
task_queue=os.getenv('TEMPORAL_TASK_QUEUE') or 'python',
|
|
95
|
+
tls=TemporalTLS(
|
|
96
|
+
ca=os.getenv('TEMPORAL_TLS_CA', ''),
|
|
97
|
+
cert=os.getenv('TEMPORAL_TLS_CERT', ''),
|
|
98
|
+
key=os.getenv('TEMPORAL_TLS_KEY', '')
|
|
99
|
+
)
|
|
100
|
+
),
|
|
101
|
+
# @feature:end
|
|
71
102
|
)
|
|
72
103
|
|
|
73
104
|
cls._instance = instance
|