@tndhuy/create-app 1.0.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.
- package/README.md +53 -0
- package/dist/cli.js +534 -0
- package/package.json +37 -0
- package/templates/mongo/.env.example +32 -0
- package/templates/mongo/Dockerfile +64 -0
- package/templates/mongo/docker-compose.yml +35 -0
- package/templates/mongo/eslint.config.mjs +35 -0
- package/templates/mongo/nest-cli.json +8 -0
- package/templates/mongo/package.json +105 -0
- package/templates/mongo/src/app.module.ts +59 -0
- package/templates/mongo/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/mongo/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/mongo/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/mongo/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/mongo/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/mongo/src/common/index.ts +5 -0
- package/templates/mongo/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/mongo/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/mongo/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/mongo/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/mongo/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/mongo/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/mongo/src/infrastructure/config/config.module.ts +36 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/mongo/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/mongo/src/infrastructure/database/mongodb.module.ts +17 -0
- package/templates/mongo/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/mongo/src/infrastructure/health/health.module.ts +12 -0
- package/templates/mongo/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/mongo/src/instrumentation.spec.ts +24 -0
- package/templates/mongo/src/instrumentation.ts +44 -0
- package/templates/mongo/src/main.ts +102 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/mongo/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/mongo/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/mongo/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/mongo/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/mongo/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/mongo/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/mongo/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/mongo/src/modules/example/example.module.ts +31 -0
- package/templates/mongo/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/mongoose-item.repository.ts +42 -0
- package/templates/mongo/src/modules/example/infrastructure/persistence/schemas/item.schema.ts +15 -0
- package/templates/mongo/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/mongo/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/mongo/src/shared/base/aggregate-root.ts +20 -0
- package/templates/mongo/src/shared/base/domain-event.ts +6 -0
- package/templates/mongo/src/shared/base/entity.spec.ts +36 -0
- package/templates/mongo/src/shared/base/entity.ts +13 -0
- package/templates/mongo/src/shared/base/index.ts +5 -0
- package/templates/mongo/src/shared/base/repository.interface.ts +6 -0
- package/templates/mongo/src/shared/base/value-object.spec.ts +39 -0
- package/templates/mongo/src/shared/base/value-object.ts +13 -0
- package/templates/mongo/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/mongo/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/mongo/src/shared/dto/response.dto.ts +13 -0
- package/templates/mongo/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/mongo/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/mongo/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/mongo/src/shared/index.ts +7 -0
- package/templates/mongo/src/shared/logger/logger.module.ts +12 -0
- package/templates/mongo/src/shared/logger/logger.service.ts +48 -0
- package/templates/mongo/src/shared/logger/pino.config.ts +86 -0
- package/templates/mongo/src/shared/validation-options.ts +38 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/mongo/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/mongo/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/index.ts +4 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/mongo/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/mongo/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/mongo/tsconfig.build.json +4 -0
- package/templates/mongo/tsconfig.json +23 -0
- package/templates/postgres/.env.example +32 -0
- package/templates/postgres/Dockerfile +64 -0
- package/templates/postgres/eslint.config.mjs +35 -0
- package/templates/postgres/nest-cli.json +8 -0
- package/templates/postgres/package.json +103 -0
- package/templates/postgres/prisma/schema.prisma +14 -0
- package/templates/postgres/prisma.config.ts +11 -0
- package/templates/postgres/src/app.module.ts +34 -0
- package/templates/postgres/src/common/decorators/public-api.decorator.ts +9 -0
- package/templates/postgres/src/common/decorators/raw-response.decorator.ts +4 -0
- package/templates/postgres/src/common/filters/http-exception.filter.spec.ts +95 -0
- package/templates/postgres/src/common/filters/http-exception.filter.ts +43 -0
- package/templates/postgres/src/common/filters/rpc-exception.filter.ts +18 -0
- package/templates/postgres/src/common/index.ts +5 -0
- package/templates/postgres/src/common/interceptors/timeout.interceptor.ts +32 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.spec.ts +52 -0
- package/templates/postgres/src/common/interceptors/transform.interceptor.ts +25 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.spec.ts +69 -0
- package/templates/postgres/src/common/middleware/correlation-id.middleware.ts +26 -0
- package/templates/postgres/src/infrastructure/cache/inject-redis.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/cache/redis.module.ts +9 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.spec.ts +174 -0
- package/templates/postgres/src/infrastructure/cache/redis.service.ts +121 -0
- package/templates/postgres/src/infrastructure/config/config.module.ts +36 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.spec.ts +100 -0
- package/templates/postgres/src/infrastructure/config/environment.validation.ts +21 -0
- package/templates/postgres/src/infrastructure/database/inject-prisma.decorator.ts +4 -0
- package/templates/postgres/src/infrastructure/database/prisma.module.ts +9 -0
- package/templates/postgres/src/infrastructure/database/prisma.service.ts +21 -0
- package/templates/postgres/src/infrastructure/health/health.controller.ts +46 -0
- package/templates/postgres/src/infrastructure/health/health.module.ts +12 -0
- package/templates/postgres/src/infrastructure/health/prisma.health-indicator.ts +19 -0
- package/templates/postgres/src/infrastructure/health/redis.health-indicator.ts +20 -0
- package/templates/postgres/src/instrumentation.spec.ts +24 -0
- package/templates/postgres/src/instrumentation.ts +44 -0
- package/templates/postgres/src/main.ts +102 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/commands/create-item.handler.ts +20 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.command.ts +3 -0
- package/templates/postgres/src/modules/example/application/commands/delete-item.handler.ts +15 -0
- package/templates/postgres/src/modules/example/application/dtos/create-item.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/dtos/item.response.dto.ts +9 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.spec.ts +49 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/get-item.query.ts +3 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.handler.ts +16 -0
- package/templates/postgres/src/modules/example/application/queries/list-items.query.ts +3 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.spec.ts +49 -0
- package/templates/postgres/src/modules/example/domain/item-name.value-object.ts +18 -0
- package/templates/postgres/src/modules/example/domain/item.entity.spec.ts +48 -0
- package/templates/postgres/src/modules/example/domain/item.entity.ts +19 -0
- package/templates/postgres/src/modules/example/domain/item.repository.interface.ts +10 -0
- package/templates/postgres/src/modules/example/example.module.ts +26 -0
- package/templates/postgres/src/modules/example/infrastructure/.gitkeep +0 -0
- package/templates/postgres/src/modules/example/infrastructure/persistence/prisma-item.repository.ts +34 -0
- package/templates/postgres/src/modules/example/presenter/item.controller.ts +52 -0
- package/templates/postgres/src/shared/base/aggregate-root.spec.ts +44 -0
- package/templates/postgres/src/shared/base/aggregate-root.ts +20 -0
- package/templates/postgres/src/shared/base/domain-event.ts +6 -0
- package/templates/postgres/src/shared/base/entity.spec.ts +36 -0
- package/templates/postgres/src/shared/base/entity.ts +13 -0
- package/templates/postgres/src/shared/base/index.ts +5 -0
- package/templates/postgres/src/shared/base/repository.interface.ts +6 -0
- package/templates/postgres/src/shared/base/value-object.spec.ts +39 -0
- package/templates/postgres/src/shared/base/value-object.ts +13 -0
- package/templates/postgres/src/shared/dto/pagination.dto.spec.ts +49 -0
- package/templates/postgres/src/shared/dto/pagination.dto.ts +37 -0
- package/templates/postgres/src/shared/dto/response.dto.ts +13 -0
- package/templates/postgres/src/shared/exceptions/app.exception.spec.ts +59 -0
- package/templates/postgres/src/shared/exceptions/app.exception.ts +19 -0
- package/templates/postgres/src/shared/exceptions/error-codes.ts +9 -0
- package/templates/postgres/src/shared/index.ts +7 -0
- package/templates/postgres/src/shared/logger/logger.module.ts +12 -0
- package/templates/postgres/src/shared/logger/logger.service.ts +48 -0
- package/templates/postgres/src/shared/logger/pino.config.ts +86 -0
- package/templates/postgres/src/shared/validation-options.ts +38 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.spec.ts +40 -0
- package/templates/postgres/src/shared/valueobjects/date.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.spec.ts +28 -0
- package/templates/postgres/src/shared/valueobjects/id.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/index.ts +4 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.spec.ts +48 -0
- package/templates/postgres/src/shared/valueobjects/number.valueobject.ts +14 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.spec.ts +37 -0
- package/templates/postgres/src/shared/valueobjects/string.valueobject.ts +14 -0
- package/templates/postgres/tsconfig.build.json +4 -0
- package/templates/postgres/tsconfig.json +23 -0
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { Injectable, NestMiddleware } from '@nestjs/common';
|
|
2
|
+
import { randomUUID } from 'crypto';
|
|
3
|
+
import { NextFunction, Request, Response } from 'express';
|
|
4
|
+
import { AsyncLocalStorage } from 'async_hooks';
|
|
5
|
+
|
|
6
|
+
export const REQUEST_ID_HEADER = 'X-Request-Id';
|
|
7
|
+
export const requestIdStorage = new AsyncLocalStorage<string>();
|
|
8
|
+
|
|
9
|
+
@Injectable()
|
|
10
|
+
export class CorrelationIdMiddleware implements NestMiddleware {
|
|
11
|
+
use(req: Request, res: Response, next: NextFunction): void {
|
|
12
|
+
const requestId =
|
|
13
|
+
(req.headers[REQUEST_ID_HEADER.toLowerCase()] as string) ||
|
|
14
|
+
(req.headers['x-correlation-id'] as string) ||
|
|
15
|
+
randomUUID();
|
|
16
|
+
|
|
17
|
+
// Ensure it's in headers for downstream and logs
|
|
18
|
+
req.headers[REQUEST_ID_HEADER.toLowerCase()] = requestId;
|
|
19
|
+
res.setHeader(REQUEST_ID_HEADER, requestId);
|
|
20
|
+
|
|
21
|
+
// Wrap in AsyncLocalStorage so it's accessible everywhere
|
|
22
|
+
requestIdStorage.run(requestId, () => {
|
|
23
|
+
next();
|
|
24
|
+
});
|
|
25
|
+
}
|
|
26
|
+
}
|
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { RedisService } from './redis.service';
|
|
2
|
+
import { trace } from '@opentelemetry/api';
|
|
3
|
+
|
|
4
|
+
// ---- ioredis mock ----
|
|
5
|
+
const mockRedisOn = jest.fn();
|
|
6
|
+
const mockGet = jest.fn();
|
|
7
|
+
const mockSet = jest.fn();
|
|
8
|
+
const mockDel = jest.fn();
|
|
9
|
+
const mockExpire = jest.fn();
|
|
10
|
+
const mockQuit = jest.fn().mockResolvedValue('OK');
|
|
11
|
+
|
|
12
|
+
const mockRedisInstance = {
|
|
13
|
+
on: mockRedisOn,
|
|
14
|
+
get: mockGet,
|
|
15
|
+
set: mockSet,
|
|
16
|
+
del: mockDel,
|
|
17
|
+
expire: mockExpire,
|
|
18
|
+
quit: mockQuit,
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
jest.mock('ioredis', () => {
|
|
22
|
+
return jest.fn().mockImplementation(() => mockRedisInstance);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
// ---- cockatiel mock ----
|
|
26
|
+
// circuitBreaker returns a policy whose execute() just calls the fn directly
|
|
27
|
+
const mockPolicyExecute = jest.fn((fn: () => Promise<unknown>) => fn());
|
|
28
|
+
|
|
29
|
+
jest.mock('cockatiel', () => ({
|
|
30
|
+
circuitBreaker: jest.fn(() => ({ execute: mockPolicyExecute })),
|
|
31
|
+
ConsecutiveBreaker: jest.fn(),
|
|
32
|
+
handleAll: {},
|
|
33
|
+
}));
|
|
34
|
+
|
|
35
|
+
describe('RedisService', () => {
|
|
36
|
+
let service: RedisService;
|
|
37
|
+
|
|
38
|
+
beforeEach(() => {
|
|
39
|
+
jest.clearAllMocks();
|
|
40
|
+
// Re-establish required mock implementations after clearAllMocks()
|
|
41
|
+
mockQuit.mockResolvedValue('OK');
|
|
42
|
+
mockPolicyExecute.mockImplementation((fn: () => Promise<unknown>) => fn());
|
|
43
|
+
process.env.REDIS_URL = 'redis://localhost:6379';
|
|
44
|
+
service = new RedisService();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(async () => {
|
|
48
|
+
await service.onModuleDestroy();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe('constructor', () => {
|
|
52
|
+
it('should initialise the Redis client immediately in the constructor', () => {
|
|
53
|
+
// The Redis constructor mock should have been called once
|
|
54
|
+
const RedisMock = require('ioredis');
|
|
55
|
+
expect(RedisMock).toHaveBeenCalledWith(
|
|
56
|
+
process.env.REDIS_URL,
|
|
57
|
+
expect.objectContaining({ enableOfflineQueue: false }),
|
|
58
|
+
);
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('should register connect and error event listeners on the client', () => {
|
|
62
|
+
expect(mockRedisOn).toHaveBeenCalledWith('connect', expect.any(Function));
|
|
63
|
+
expect(mockRedisOn).toHaveBeenCalledWith('error', expect.any(Function));
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe('get', () => {
|
|
68
|
+
it('should return the cached value for a key', async () => {
|
|
69
|
+
mockGet.mockResolvedValue('cached-value');
|
|
70
|
+
const result = await service.get('some-key');
|
|
71
|
+
expect(mockGet).toHaveBeenCalledWith('some-key');
|
|
72
|
+
expect(result).toBe('cached-value');
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('should return null when key does not exist', async () => {
|
|
76
|
+
mockGet.mockResolvedValue(null);
|
|
77
|
+
const result = await service.get('missing-key');
|
|
78
|
+
expect(result).toBeNull();
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
describe('set', () => {
|
|
83
|
+
it('should set a key-value pair without TTL', async () => {
|
|
84
|
+
mockSet.mockResolvedValue('OK');
|
|
85
|
+
await service.set('key', 'value');
|
|
86
|
+
expect(mockSet).toHaveBeenCalledWith('key', 'value');
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('should set a key-value pair with TTL in seconds', async () => {
|
|
90
|
+
mockSet.mockResolvedValue('OK');
|
|
91
|
+
await service.set('key', 'value', 60);
|
|
92
|
+
expect(mockSet).toHaveBeenCalledWith('key', 'value', 'EX', 60);
|
|
93
|
+
});
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
describe('del', () => {
|
|
97
|
+
it('should delete a key from the cache', async () => {
|
|
98
|
+
mockDel.mockResolvedValue(1);
|
|
99
|
+
await service.del('key-to-delete');
|
|
100
|
+
expect(mockDel).toHaveBeenCalledWith('key-to-delete');
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
describe('expire', () => {
|
|
105
|
+
it('should set an expiry time on an existing key', async () => {
|
|
106
|
+
mockExpire.mockResolvedValue(1);
|
|
107
|
+
await service.expire('some-key', 300);
|
|
108
|
+
expect(mockExpire).toHaveBeenCalledWith('some-key', 300);
|
|
109
|
+
});
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
describe('execute (circuit breaker)', () => {
|
|
113
|
+
it('should delegate operations through the circuit breaker policy', async () => {
|
|
114
|
+
mockGet.mockResolvedValue('value');
|
|
115
|
+
await service.get('test');
|
|
116
|
+
expect(mockPolicyExecute).toHaveBeenCalled();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
it('should propagate errors when the circuit breaker is open', async () => {
|
|
120
|
+
const circuitOpenError = new Error('Circuit breaker is open');
|
|
121
|
+
mockPolicyExecute.mockRejectedValueOnce(circuitOpenError);
|
|
122
|
+
await expect(service.get('any-key')).rejects.toThrow(
|
|
123
|
+
'Circuit breaker is open',
|
|
124
|
+
);
|
|
125
|
+
});
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
describe('getClient', () => {
|
|
129
|
+
it('should return the underlying Redis client instance', () => {
|
|
130
|
+
const client = service.getClient();
|
|
131
|
+
expect(client).toBe(mockRedisInstance);
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe('onModuleDestroy', () => {
|
|
136
|
+
it('should quit the Redis client on module destroy', async () => {
|
|
137
|
+
await service.onModuleDestroy();
|
|
138
|
+
expect(mockQuit).toHaveBeenCalled();
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
describe('OTel tracing', () => {
|
|
143
|
+
it('should expose the OTel trace API (smoke test)', () => {
|
|
144
|
+
// When OTel SDK is not initialized, getTracer returns a no-op tracer
|
|
145
|
+
const t = trace.getTracer('test');
|
|
146
|
+
expect(t).toBeDefined();
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('get() should still resolve correctly with OTel no-op spans', async () => {
|
|
150
|
+
mockGet.mockResolvedValue('traced-value');
|
|
151
|
+
const result = await service.get('otel-key');
|
|
152
|
+
expect(result).toBe('traced-value');
|
|
153
|
+
expect(mockGet).toHaveBeenCalledWith('otel-key');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it('set() should still resolve correctly with OTel no-op spans', async () => {
|
|
157
|
+
mockSet.mockResolvedValue('OK');
|
|
158
|
+
await service.set('otel-key', 'otel-value', 30);
|
|
159
|
+
expect(mockSet).toHaveBeenCalledWith('otel-key', 'otel-value', 'EX', 30);
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it('del() should still resolve correctly with OTel no-op spans', async () => {
|
|
163
|
+
mockDel.mockResolvedValue(1);
|
|
164
|
+
await service.del('otel-key');
|
|
165
|
+
expect(mockDel).toHaveBeenCalledWith('otel-key');
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
it('expire() should still resolve correctly with OTel no-op spans', async () => {
|
|
169
|
+
mockExpire.mockResolvedValue(1);
|
|
170
|
+
await service.expire('otel-key', 120);
|
|
171
|
+
expect(mockExpire).toHaveBeenCalledWith('otel-key', 120);
|
|
172
|
+
});
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import { Injectable, OnModuleDestroy, Logger } from '@nestjs/common';
|
|
2
|
+
import Redis from 'ioredis';
|
|
3
|
+
import { circuitBreaker, ConsecutiveBreaker, CircuitBreakerPolicy, handleAll } from 'cockatiel';
|
|
4
|
+
import { trace } from '@opentelemetry/api';
|
|
5
|
+
|
|
6
|
+
const tracer = trace.getTracer('redis-service');
|
|
7
|
+
|
|
8
|
+
@Injectable()
|
|
9
|
+
export class RedisService implements OnModuleDestroy {
|
|
10
|
+
private readonly client: Redis;
|
|
11
|
+
private readonly logger = new Logger(RedisService.name);
|
|
12
|
+
private readonly policy: CircuitBreakerPolicy;
|
|
13
|
+
|
|
14
|
+
constructor() {
|
|
15
|
+
// Initialize circuit breaker
|
|
16
|
+
this.policy = circuitBreaker(handleAll, {
|
|
17
|
+
halfOpenAfter: 10_000,
|
|
18
|
+
breaker: new ConsecutiveBreaker(5),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
// Initialize Redis client in constructor — NOT onModuleInit.
|
|
22
|
+
// ConfigModule validates REDIS_URL at startup (fail-fast),
|
|
23
|
+
// so it is guaranteed available when CacheModule loads.
|
|
24
|
+
this.client = new Redis(process.env.REDIS_URL!, {
|
|
25
|
+
commandTimeout: 5000,
|
|
26
|
+
connectTimeout: 5000,
|
|
27
|
+
enableOfflineQueue: false,
|
|
28
|
+
maxRetriesPerRequest: 3,
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
this.client.on('connect', () => this.logger.log('Redis connected'));
|
|
32
|
+
this.client.on('error', (err) => this.logger.error('Redis error', err.message));
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
async onModuleDestroy(): Promise<void> {
|
|
36
|
+
await this.client?.quit();
|
|
37
|
+
this.logger.log('Redis disconnected');
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
getClient(): Redis {
|
|
41
|
+
return this.client;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
async execute<T>(fn: (client: Redis) => Promise<T>): Promise<T> {
|
|
45
|
+
return this.policy.execute(() => fn(this.client));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async get(key: string): Promise<string | null> {
|
|
49
|
+
return tracer.startActiveSpan('redis.get', async (span) => {
|
|
50
|
+
try {
|
|
51
|
+
span.setAttribute('db.system', 'redis');
|
|
52
|
+
span.setAttribute('db.operation', 'get');
|
|
53
|
+
span.setAttribute('db.redis.key', key);
|
|
54
|
+
return await this.execute((c) => c.get(key));
|
|
55
|
+
} catch (error) {
|
|
56
|
+
span.recordException(error as Error);
|
|
57
|
+
throw error;
|
|
58
|
+
} finally {
|
|
59
|
+
span.end();
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
async set(key: string, value: string, ttlSeconds?: number): Promise<void> {
|
|
65
|
+
return tracer.startActiveSpan('redis.set', async (span) => {
|
|
66
|
+
try {
|
|
67
|
+
span.setAttribute('db.system', 'redis');
|
|
68
|
+
span.setAttribute('db.operation', 'set');
|
|
69
|
+
span.setAttribute('db.redis.key', key);
|
|
70
|
+
if (ttlSeconds !== undefined) {
|
|
71
|
+
span.setAttribute('db.redis.ttl', ttlSeconds);
|
|
72
|
+
}
|
|
73
|
+
await this.execute(async (c) => {
|
|
74
|
+
if (ttlSeconds) {
|
|
75
|
+
await c.set(key, value, 'EX', ttlSeconds);
|
|
76
|
+
} else {
|
|
77
|
+
await c.set(key, value);
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
} catch (error) {
|
|
81
|
+
span.recordException(error as Error);
|
|
82
|
+
throw error;
|
|
83
|
+
} finally {
|
|
84
|
+
span.end();
|
|
85
|
+
}
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async del(key: string): Promise<void> {
|
|
90
|
+
return tracer.startActiveSpan('redis.del', async (span) => {
|
|
91
|
+
try {
|
|
92
|
+
span.setAttribute('db.system', 'redis');
|
|
93
|
+
span.setAttribute('db.operation', 'del');
|
|
94
|
+
span.setAttribute('db.redis.key', key);
|
|
95
|
+
await this.execute((c) => c.del(key).then(() => undefined));
|
|
96
|
+
} catch (error) {
|
|
97
|
+
span.recordException(error as Error);
|
|
98
|
+
throw error;
|
|
99
|
+
} finally {
|
|
100
|
+
span.end();
|
|
101
|
+
}
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
async expire(key: string, ttlSeconds: number): Promise<void> {
|
|
106
|
+
return tracer.startActiveSpan('redis.expire', async (span) => {
|
|
107
|
+
try {
|
|
108
|
+
span.setAttribute('db.system', 'redis');
|
|
109
|
+
span.setAttribute('db.operation', 'expire');
|
|
110
|
+
span.setAttribute('db.redis.key', key);
|
|
111
|
+
span.setAttribute('db.redis.ttl', ttlSeconds);
|
|
112
|
+
await this.execute((c) => c.expire(key, ttlSeconds).then(() => undefined));
|
|
113
|
+
} catch (error) {
|
|
114
|
+
span.recordException(error as Error);
|
|
115
|
+
throw error;
|
|
116
|
+
} finally {
|
|
117
|
+
span.end();
|
|
118
|
+
}
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { ConfigModule as NestConfigModule } from '@nestjs/config';
|
|
3
|
+
import { plainToInstance } from 'class-transformer';
|
|
4
|
+
import { validateSync } from 'class-validator';
|
|
5
|
+
import { EnvironmentVariables } from './environment.validation';
|
|
6
|
+
|
|
7
|
+
function validate(config: Record<string, unknown>) {
|
|
8
|
+
const validatedConfig = plainToInstance(EnvironmentVariables, config, {
|
|
9
|
+
enableImplicitConversion: true,
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
const errors = validateSync(validatedConfig, {
|
|
13
|
+
skipMissingProperties: false,
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
if (errors.length > 0) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
`Environment validation failed:\n${errors
|
|
19
|
+
.map((e) => Object.values(e.constraints ?? {}).join(', '))
|
|
20
|
+
.join('\n')}`,
|
|
21
|
+
);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
return validatedConfig;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
@Module({
|
|
28
|
+
imports: [
|
|
29
|
+
NestConfigModule.forRoot({
|
|
30
|
+
isGlobal: true,
|
|
31
|
+
validate,
|
|
32
|
+
}),
|
|
33
|
+
],
|
|
34
|
+
exports: [NestConfigModule],
|
|
35
|
+
})
|
|
36
|
+
export class AppConfigModule {}
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
import 'reflect-metadata';
|
|
2
|
+
import { validateSync } from 'class-validator';
|
|
3
|
+
import { plainToInstance } from 'class-transformer';
|
|
4
|
+
import { EnvironmentVariables } from './environment.validation';
|
|
5
|
+
|
|
6
|
+
function buildConfig(overrides: Record<string, unknown> = {}): EnvironmentVariables {
|
|
7
|
+
return plainToInstance(
|
|
8
|
+
EnvironmentVariables,
|
|
9
|
+
{
|
|
10
|
+
DATABASE_URL: 'mongodb://localhost:27017/app',
|
|
11
|
+
REDIS_URL: 'redis://localhost:6379',
|
|
12
|
+
...overrides,
|
|
13
|
+
},
|
|
14
|
+
{ enableImplicitConversion: true },
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
describe('EnvironmentVariables', () => {
|
|
19
|
+
describe('valid configuration', () => {
|
|
20
|
+
it('should pass validation with all required fields provided', () => {
|
|
21
|
+
const config = buildConfig();
|
|
22
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
23
|
+
expect(errors).toHaveLength(0);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should use default PORT of 3000 when PORT is not provided', () => {
|
|
27
|
+
const config = buildConfig();
|
|
28
|
+
expect(config.PORT).toBe(3000);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it('should use default NODE_ENV of development when NODE_ENV is not provided', () => {
|
|
32
|
+
const config = buildConfig();
|
|
33
|
+
expect(config.NODE_ENV).toBe('development');
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
it('should accept PORT within valid range (1–65535)', () => {
|
|
37
|
+
const config = buildConfig({ PORT: '8080' });
|
|
38
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
39
|
+
expect(errors).toHaveLength(0);
|
|
40
|
+
expect(config.PORT).toBe(8080);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('should accept all valid NODE_ENV values', () => {
|
|
44
|
+
for (const env of ['development', 'production', 'test']) {
|
|
45
|
+
const config = buildConfig({ NODE_ENV: env });
|
|
46
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
47
|
+
expect(errors).toHaveLength(0);
|
|
48
|
+
}
|
|
49
|
+
});
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
describe('missing required fields', () => {
|
|
53
|
+
it('should fail validation when DATABASE_URL is missing', () => {
|
|
54
|
+
const config = plainToInstance(
|
|
55
|
+
EnvironmentVariables,
|
|
56
|
+
{ REDIS_URL: 'redis://localhost:6379' },
|
|
57
|
+
{ enableImplicitConversion: true },
|
|
58
|
+
);
|
|
59
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
60
|
+
const fieldNames = errors.map((e) => e.property);
|
|
61
|
+
expect(fieldNames).toContain('DATABASE_URL');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('should fail validation when REDIS_URL is missing', () => {
|
|
65
|
+
const config = plainToInstance(
|
|
66
|
+
EnvironmentVariables,
|
|
67
|
+
{ DATABASE_URL: 'mongodb://localhost:27017/app' },
|
|
68
|
+
{ enableImplicitConversion: true },
|
|
69
|
+
);
|
|
70
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
71
|
+
const fieldNames = errors.map((e) => e.property);
|
|
72
|
+
expect(fieldNames).toContain('REDIS_URL');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
describe('PORT validation', () => {
|
|
77
|
+
it('should fail validation when PORT is below 1', () => {
|
|
78
|
+
const config = buildConfig({ PORT: '0' });
|
|
79
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
80
|
+
const portErrors = errors.filter((e) => e.property === 'PORT');
|
|
81
|
+
expect(portErrors.length).toBeGreaterThan(0);
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('should fail validation when PORT exceeds 65535', () => {
|
|
85
|
+
const config = buildConfig({ PORT: '65536' });
|
|
86
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
87
|
+
const portErrors = errors.filter((e) => e.property === 'PORT');
|
|
88
|
+
expect(portErrors.length).toBeGreaterThan(0);
|
|
89
|
+
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
describe('NODE_ENV whitelist', () => {
|
|
93
|
+
it('should fail validation when NODE_ENV is not in the allowed list', () => {
|
|
94
|
+
const config = buildConfig({ NODE_ENV: 'staging' });
|
|
95
|
+
const errors = validateSync(config, { skipMissingProperties: false });
|
|
96
|
+
const nodeEnvErrors = errors.filter((e) => e.property === 'NODE_ENV');
|
|
97
|
+
expect(nodeEnvErrors.length).toBeGreaterThan(0);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import { IsString, IsInt, IsIn, IsOptional, Min, Max } from 'class-validator';
|
|
2
|
+
import { Transform } from 'class-transformer';
|
|
3
|
+
|
|
4
|
+
export class EnvironmentVariables {
|
|
5
|
+
@IsString()
|
|
6
|
+
DATABASE_URL: string;
|
|
7
|
+
|
|
8
|
+
@IsString()
|
|
9
|
+
REDIS_URL: string;
|
|
10
|
+
|
|
11
|
+
@IsOptional()
|
|
12
|
+
@Transform(({ value }) => parseInt(value, 10))
|
|
13
|
+
@IsInt()
|
|
14
|
+
@Min(1)
|
|
15
|
+
@Max(65535)
|
|
16
|
+
PORT: number = 3000;
|
|
17
|
+
|
|
18
|
+
@IsOptional()
|
|
19
|
+
@IsIn(['development', 'production', 'test'])
|
|
20
|
+
NODE_ENV: string = 'development';
|
|
21
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { Global, Module } from '@nestjs/common';
|
|
2
|
+
import { MongooseModule } from '@nestjs/mongoose';
|
|
3
|
+
import { ConfigService } from '@nestjs/config';
|
|
4
|
+
|
|
5
|
+
@Global()
|
|
6
|
+
@Module({
|
|
7
|
+
imports: [
|
|
8
|
+
MongooseModule.forRootAsync({
|
|
9
|
+
inject: [ConfigService],
|
|
10
|
+
useFactory: (config: ConfigService) => ({
|
|
11
|
+
uri: config.get<string>('DATABASE_URL'),
|
|
12
|
+
}),
|
|
13
|
+
}),
|
|
14
|
+
],
|
|
15
|
+
exports: [MongooseModule],
|
|
16
|
+
})
|
|
17
|
+
export class MongoDBModule {}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { Controller, Get } from '@nestjs/common';
|
|
2
|
+
import { HealthCheck, HealthCheckService } from '@nestjs/terminus';
|
|
3
|
+
import { ApiTags, ApiOperation } from '@nestjs/swagger';
|
|
4
|
+
import { PrismaHealthIndicator } from './prisma.health-indicator';
|
|
5
|
+
import { RedisHealthIndicator } from './redis.health-indicator';
|
|
6
|
+
import { RawResponse } from '../../common';
|
|
7
|
+
|
|
8
|
+
@ApiTags('health')
|
|
9
|
+
@Controller('health')
|
|
10
|
+
export class HealthController {
|
|
11
|
+
constructor(
|
|
12
|
+
private health: HealthCheckService,
|
|
13
|
+
private db: PrismaHealthIndicator,
|
|
14
|
+
private redis: RedisHealthIndicator,
|
|
15
|
+
) {}
|
|
16
|
+
|
|
17
|
+
@Get()
|
|
18
|
+
@HealthCheck()
|
|
19
|
+
@RawResponse()
|
|
20
|
+
@ApiOperation({ summary: 'Overall health check' })
|
|
21
|
+
check() {
|
|
22
|
+
return this.health.check([
|
|
23
|
+
() => this.db.isHealthy('database'),
|
|
24
|
+
() => this.redis.isHealthy('redis'),
|
|
25
|
+
]);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
@Get('ready')
|
|
29
|
+
@HealthCheck()
|
|
30
|
+
@RawResponse()
|
|
31
|
+
@ApiOperation({ summary: 'Readiness probe' })
|
|
32
|
+
ready() {
|
|
33
|
+
return this.health.check([
|
|
34
|
+
() => this.db.isHealthy('database'),
|
|
35
|
+
() => this.redis.isHealthy('redis'),
|
|
36
|
+
]);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
@Get('live')
|
|
40
|
+
@HealthCheck()
|
|
41
|
+
@RawResponse()
|
|
42
|
+
@ApiOperation({ summary: 'Liveness probe' })
|
|
43
|
+
live() {
|
|
44
|
+
return this.health.check([]);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { Module } from '@nestjs/common';
|
|
2
|
+
import { TerminusModule } from '@nestjs/terminus';
|
|
3
|
+
import { HealthController } from './health.controller';
|
|
4
|
+
import { PrismaHealthIndicator } from './prisma.health-indicator';
|
|
5
|
+
import { RedisHealthIndicator } from './redis.health-indicator';
|
|
6
|
+
|
|
7
|
+
@Module({
|
|
8
|
+
imports: [TerminusModule],
|
|
9
|
+
controllers: [HealthController],
|
|
10
|
+
providers: [PrismaHealthIndicator, RedisHealthIndicator],
|
|
11
|
+
})
|
|
12
|
+
export class HealthModule {}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import { Injectable } from '@nestjs/common';
|
|
2
|
+
import { HealthIndicator, HealthIndicatorResult, HealthCheckError } from '@nestjs/terminus';
|
|
3
|
+
import { RedisService } from '../cache/redis.service';
|
|
4
|
+
|
|
5
|
+
@Injectable()
|
|
6
|
+
export class RedisHealthIndicator extends HealthIndicator {
|
|
7
|
+
constructor(private readonly redis: RedisService) {
|
|
8
|
+
super();
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
async isHealthy(key: string): Promise<HealthIndicatorResult> {
|
|
12
|
+
try {
|
|
13
|
+
const result = await this.redis.getClient().ping();
|
|
14
|
+
const isUp = result === 'PONG';
|
|
15
|
+
return this.getStatus(key, isUp);
|
|
16
|
+
} catch (error) {
|
|
17
|
+
throw new HealthCheckError('Redis check failed', this.getStatus(key, false));
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
describe('instrumentation.ts feature flag', () => {
|
|
2
|
+
const originalEnv = process.env;
|
|
3
|
+
|
|
4
|
+
beforeEach(() => {
|
|
5
|
+
process.env = { ...originalEnv };
|
|
6
|
+
jest.resetModules();
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
afterEach(() => {
|
|
10
|
+
process.env = originalEnv;
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('should export null when OTEL_ENABLED is not set', async () => {
|
|
14
|
+
delete process.env.OTEL_ENABLED;
|
|
15
|
+
const mod = await import('./instrumentation');
|
|
16
|
+
expect(mod.default).toBeNull();
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('should export null when OTEL_ENABLED is "false"', async () => {
|
|
20
|
+
process.env.OTEL_ENABLED = 'false';
|
|
21
|
+
const mod = await import('./instrumentation');
|
|
22
|
+
expect(mod.default).toBeNull();
|
|
23
|
+
});
|
|
24
|
+
});
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import { NodeSDK } from '@opentelemetry/sdk-node';
|
|
2
|
+
|
|
3
|
+
let sdk: NodeSDK | null = null;
|
|
4
|
+
|
|
5
|
+
if (process.env.OTEL_ENABLED === 'true') {
|
|
6
|
+
try {
|
|
7
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
8
|
+
const { resourceFromAttributes } = require('@opentelemetry/resources');
|
|
9
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
10
|
+
const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
|
|
11
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
12
|
+
const { PrometheusExporter } = require('@opentelemetry/exporter-prometheus');
|
|
13
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
14
|
+
const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
|
|
15
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
16
|
+
const { ConsoleSpanExporter } = require('@opentelemetry/sdk-trace-base');
|
|
17
|
+
|
|
18
|
+
const serviceName = process.env.OTEL_SERVICE_NAME ?? 'nestjs-backend-template';
|
|
19
|
+
const prometheusPort = parseInt(process.env.OTEL_PROMETHEUS_PORT ?? '9464', 10);
|
|
20
|
+
|
|
21
|
+
const prometheusExporter = new PrometheusExporter({ port: prometheusPort });
|
|
22
|
+
|
|
23
|
+
const traceExporter = process.env.OTEL_EXPORTER_OTLP_ENDPOINT
|
|
24
|
+
? new OTLPTraceExporter({ url: process.env.OTEL_EXPORTER_OTLP_ENDPOINT })
|
|
25
|
+
: new ConsoleSpanExporter();
|
|
26
|
+
|
|
27
|
+
const instrumentations = getNodeAutoInstrumentations({
|
|
28
|
+
'@opentelemetry/instrumentation-dns': { enabled: false },
|
|
29
|
+
'@opentelemetry/instrumentation-fs': { enabled: false },
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
sdk = new NodeSDK({
|
|
33
|
+
resource: resourceFromAttributes({ 'service.name': serviceName }),
|
|
34
|
+
metricReader: prometheusExporter,
|
|
35
|
+
traceExporter,
|
|
36
|
+
instrumentations,
|
|
37
|
+
});
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('[OTel] Failed to initialize OpenTelemetry SDK:', err);
|
|
40
|
+
sdk = null;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export default sdk;
|