@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
|
@@ -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
|
|
16
|
+
setup.enableBasicAuth(app); // @feature openapi, queue
|
|
15
17
|
setup.enableVersioning(app);
|
|
16
18
|
setup.setStatic(app);
|
|
17
19
|
setup.enableJsonBodyParser(app);
|
|
@@ -2,7 +2,7 @@ import { Injectable, NotFoundException } from '@nestjs/common';
|
|
|
2
2
|
import { NotificationType, Prisma } from '@db/client';
|
|
3
3
|
import { PrismaService, LoggerService } from 'common/services';
|
|
4
4
|
import { ok } from 'common/entity';
|
|
5
|
-
import { RealtimeService } from 'realtime/services/realtime.service';
|
|
5
|
+
import { RealtimeService } from 'realtime/services/realtime.service'; // @feature realtime
|
|
6
6
|
import {
|
|
7
7
|
NotificationEntity,
|
|
8
8
|
PaginatedNotificationEntity,
|
|
@@ -14,7 +14,7 @@ import { NotificationFilters } from '../dto';
|
|
|
14
14
|
export class NotificationsService {
|
|
15
15
|
constructor(
|
|
16
16
|
private readonly db: PrismaService,
|
|
17
|
-
private readonly realtime: RealtimeService,
|
|
17
|
+
private readonly realtime: RealtimeService, // @feature realtime
|
|
18
18
|
private readonly logger: LoggerService
|
|
19
19
|
) {}
|
|
20
20
|
|
|
@@ -36,9 +36,11 @@ export class NotificationsService {
|
|
|
36
36
|
const notification = await this.db.notification.create({
|
|
37
37
|
data: { userId, type, data }
|
|
38
38
|
});
|
|
39
|
+
// @feature:start realtime
|
|
39
40
|
// Sockets bypass the HTTP serializer; encode through the same schema so
|
|
40
41
|
// the event has exactly the shape the REST endpoints return.
|
|
41
42
|
this.realtime.notifyUser(userId, NotificationEntity.parse(notification));
|
|
43
|
+
// @feature:end
|
|
42
44
|
return notification;
|
|
43
45
|
} catch (err) {
|
|
44
46
|
this.logger.warn(
|
|
@@ -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
|
+
}
|
|
@@ -47,6 +47,12 @@
|
|
|
47
47
|
"regex": true,
|
|
48
48
|
"flags": "gm"
|
|
49
49
|
},
|
|
50
|
+
{
|
|
51
|
+
"from": "^(SERVER_HOST|BETTER_AUTH_URL)='http://localhost:5000'$",
|
|
52
|
+
"to": "$1='http://localhost:{{port}}'",
|
|
53
|
+
"regex": true,
|
|
54
|
+
"flags": "gm"
|
|
55
|
+
},
|
|
50
56
|
{
|
|
51
57
|
"from": "^EXPOSE 5000$",
|
|
52
58
|
"to": "EXPOSE {{port}}",
|
|
@@ -104,7 +110,8 @@
|
|
|
104
110
|
"bullmq",
|
|
105
111
|
"@bull-board/api",
|
|
106
112
|
"@bull-board/express",
|
|
107
|
-
"@bull-board/nestjs"
|
|
113
|
+
"@bull-board/nestjs",
|
|
114
|
+
"bullmq-otel"
|
|
108
115
|
]
|
|
109
116
|
}
|
|
110
117
|
},
|
|
@@ -168,23 +175,38 @@
|
|
|
168
175
|
"core": true
|
|
169
176
|
},
|
|
170
177
|
"observability": {
|
|
171
|
-
"title": "
|
|
172
|
-
"description": "
|
|
178
|
+
"title": "Observability: OpenTelemetry and health checks",
|
|
179
|
+
"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.",
|
|
180
|
+
"requires": [
|
|
181
|
+
"http-client"
|
|
182
|
+
],
|
|
173
183
|
"default": true,
|
|
174
184
|
"files": [
|
|
175
|
-
"src/common/modules/metrics.module.ts",
|
|
176
|
-
"src/common/controllers/metrics.controller.ts",
|
|
177
185
|
"src/common/controllers/health.controller.ts",
|
|
178
|
-
"src/common/interceptors/metrics.interceptor.ts",
|
|
179
186
|
"src/common/misc/prisma.indicator.ts",
|
|
180
187
|
"src/common/__tests__/health.controller.spec.ts",
|
|
181
|
-
"src/common/__tests__/prisma.indicator.spec.ts"
|
|
188
|
+
"src/common/__tests__/prisma.indicator.spec.ts",
|
|
189
|
+
"src/instrumentation.ts",
|
|
190
|
+
"src/telemetry/**"
|
|
182
191
|
],
|
|
183
192
|
"packageJson": {
|
|
184
193
|
"dependencies": [
|
|
185
|
-
"
|
|
186
|
-
"@
|
|
187
|
-
"@
|
|
194
|
+
"@nestjs/terminus",
|
|
195
|
+
"@opentelemetry/api",
|
|
196
|
+
"@opentelemetry/core",
|
|
197
|
+
"@opentelemetry/sdk-node",
|
|
198
|
+
"@opentelemetry/auto-instrumentations-node",
|
|
199
|
+
"@opentelemetry/exporter-trace-otlp-proto",
|
|
200
|
+
"@opentelemetry/exporter-logs-otlp-proto",
|
|
201
|
+
"@opentelemetry/exporter-metrics-otlp-proto",
|
|
202
|
+
"@opentelemetry/sdk-logs",
|
|
203
|
+
"@opentelemetry/sdk-metrics",
|
|
204
|
+
"@opentelemetry/resources",
|
|
205
|
+
"@opentelemetry/semantic-conventions",
|
|
206
|
+
"@opentelemetry/instrumentation",
|
|
207
|
+
"@opentelemetry/instrumentation-express",
|
|
208
|
+
"import-in-the-middle",
|
|
209
|
+
"@prisma/instrumentation"
|
|
188
210
|
]
|
|
189
211
|
}
|
|
190
212
|
},
|
|
@@ -204,10 +226,11 @@
|
|
|
204
226
|
"media": {
|
|
205
227
|
"title": "File uploads (S3)",
|
|
206
228
|
"description": "MediaService and S3Service for public and private uploads, with multipart decorators.",
|
|
207
|
-
"default": true,
|
|
208
229
|
"requires": [
|
|
209
|
-
"auth"
|
|
230
|
+
"auth",
|
|
231
|
+
"queue"
|
|
210
232
|
],
|
|
233
|
+
"default": true,
|
|
211
234
|
"files": [
|
|
212
235
|
"src/media/**",
|
|
213
236
|
"src/users/dto/avatar.dto.ts"
|
|
@@ -263,7 +286,7 @@
|
|
|
263
286
|
},
|
|
264
287
|
"notifications": {
|
|
265
288
|
"title": "Persistent user notifications",
|
|
266
|
-
"description": "A Notification model plus dispatch/read endpoints.
|
|
289
|
+
"description": "A Notification model plus dispatch/read endpoints. Pushes each new notification over the realtime layer when that feature is also on, and persists either way.",
|
|
267
290
|
"requires": [
|
|
268
291
|
"auth"
|
|
269
292
|
],
|
|
@@ -278,7 +301,8 @@
|
|
|
278
301
|
"description": "A session-aware FeatureFlagGuard and FeatureFlagService for gating routes on flags.",
|
|
279
302
|
"requires": [
|
|
280
303
|
"cache",
|
|
281
|
-
"auth"
|
|
304
|
+
"auth",
|
|
305
|
+
"scheduler"
|
|
282
306
|
],
|
|
283
307
|
"files": [
|
|
284
308
|
"src/common/services/feature-flag.service.ts",
|
|
@@ -290,6 +314,19 @@
|
|
|
290
314
|
]
|
|
291
315
|
}
|
|
292
316
|
},
|
|
317
|
+
"temporal": {
|
|
318
|
+
"title": "Temporal client",
|
|
319
|
+
"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.",
|
|
320
|
+
"files": [
|
|
321
|
+
"src/common/services/temporal.service.ts",
|
|
322
|
+
"src/common/__tests__/temporal.service.spec.ts"
|
|
323
|
+
],
|
|
324
|
+
"packageJson": {
|
|
325
|
+
"dependencies": [
|
|
326
|
+
"@temporalio/client"
|
|
327
|
+
]
|
|
328
|
+
}
|
|
329
|
+
},
|
|
293
330
|
"graphql": {
|
|
294
331
|
"title": "GraphQL endpoint",
|
|
295
332
|
"description": "Code-first GraphQL via Apollo, registered alongside the REST controllers. Resolvers in any feature module are picked up automatically.",
|
|
@@ -382,6 +419,9 @@
|
|
|
382
419
|
"ci": {
|
|
383
420
|
"title": "Forgejo Actions CI",
|
|
384
421
|
"description": "A workflow that spins up the test stack and runs both Vitest projects on pull requests.",
|
|
422
|
+
"requires": [
|
|
423
|
+
"secrets"
|
|
424
|
+
],
|
|
385
425
|
"default": true,
|
|
386
426
|
"files": [
|
|
387
427
|
".forgejo/**"
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
1
2
|
import shell from 'shelljs';
|
|
2
3
|
import yargs from 'yargs';
|
|
3
4
|
import { hideBin } from 'yargs/helpers';
|
|
@@ -52,9 +53,7 @@ const getCmd = (env: string) => {
|
|
|
52
53
|
if (!PROJECT_ID) {
|
|
53
54
|
throw new Error('INFISICAL_PROJECT_ID is not set — nothing to fetch from.');
|
|
54
55
|
}
|
|
55
|
-
return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env}
|
|
56
|
-
env
|
|
57
|
-
)}`;
|
|
56
|
+
return `infisical export --projectId="${PROJECT_ID}" --path=${SECRET_PATH} --env=${env}`;
|
|
58
57
|
};
|
|
59
58
|
|
|
60
59
|
const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
|
|
@@ -62,7 +61,17 @@ const log = (env: string) => console.log(`Fetching secrets for '${env}'...`);
|
|
|
62
61
|
(async () => {
|
|
63
62
|
const env = await getArg();
|
|
64
63
|
const command = getCmd(env);
|
|
64
|
+
const file = getEnvFile(env);
|
|
65
65
|
log(env);
|
|
66
|
-
shell
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
// Export first and write only on success: a shell `>` redirect would empty
|
|
67
|
+
// the existing file before infisical had the chance to fail.
|
|
68
|
+
const result = shell.exec(command, { silent: true });
|
|
69
|
+
if (result.code !== 0) {
|
|
70
|
+
throw new Error(result.stderr.trim() || `infisical exited with ${result.code}`);
|
|
71
|
+
}
|
|
72
|
+
fs.writeFileSync(file, result.stdout);
|
|
73
|
+
console.log(`Wrote ${file}`);
|
|
74
|
+
})().catch((err: unknown) => {
|
|
75
|
+
console.error(err instanceof Error ? err.message : err);
|
|
76
|
+
process.exit(1);
|
|
77
|
+
});
|
|
@@ -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
|
|
@@ -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
|