chadstart 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.
Files changed (115) hide show
  1. package/.dockerignore +10 -0
  2. package/.env.example +46 -0
  3. package/.github/workflows/browser-test.yml +34 -0
  4. package/.github/workflows/docker-publish.yml +54 -0
  5. package/.github/workflows/docs.yml +31 -0
  6. package/.github/workflows/npm-chadstart.yml +27 -0
  7. package/.github/workflows/npm-sdk.yml +38 -0
  8. package/.github/workflows/test.yml +85 -0
  9. package/.weblate +9 -0
  10. package/Dockerfile +23 -0
  11. package/README.md +348 -0
  12. package/admin/index.html +2802 -0
  13. package/admin/login.html +207 -0
  14. package/chadstart.example.yml +416 -0
  15. package/chadstart.schema.json +367 -0
  16. package/chadstart.yaml +53 -0
  17. package/cli/cli.js +295 -0
  18. package/core/api-generator.js +606 -0
  19. package/core/auth.js +298 -0
  20. package/core/db.js +384 -0
  21. package/core/entity-engine.js +166 -0
  22. package/core/error-reporter.js +132 -0
  23. package/core/file-storage.js +97 -0
  24. package/core/functions-engine.js +353 -0
  25. package/core/openapi.js +171 -0
  26. package/core/plugin-loader.js +92 -0
  27. package/core/realtime.js +93 -0
  28. package/core/schema-validator.js +50 -0
  29. package/core/seeder.js +231 -0
  30. package/core/telemetry.js +119 -0
  31. package/core/upload.js +372 -0
  32. package/core/workers/php_worker.php +19 -0
  33. package/core/workers/python_worker.py +33 -0
  34. package/core/workers/ruby_worker.rb +21 -0
  35. package/core/yaml-loader.js +64 -0
  36. package/demo/chadstart.yaml +178 -0
  37. package/demo/docker-compose.yml +31 -0
  38. package/demo/functions/greet.go +39 -0
  39. package/demo/functions/hello.cpp +18 -0
  40. package/demo/functions/hello.py +13 -0
  41. package/demo/functions/hello.rb +10 -0
  42. package/demo/functions/onTodoCreated.js +13 -0
  43. package/demo/functions/ping.sh +13 -0
  44. package/demo/functions/stats.js +22 -0
  45. package/demo/public/index.html +522 -0
  46. package/docker-compose.yml +17 -0
  47. package/docs/access-policies.md +155 -0
  48. package/docs/admin-ui.md +29 -0
  49. package/docs/angular.md +69 -0
  50. package/docs/astro.md +71 -0
  51. package/docs/auth.md +160 -0
  52. package/docs/cli.md +56 -0
  53. package/docs/config.md +127 -0
  54. package/docs/crud.md +627 -0
  55. package/docs/deploy.md +113 -0
  56. package/docs/docker.md +59 -0
  57. package/docs/entities.md +385 -0
  58. package/docs/functions.md +196 -0
  59. package/docs/getting-started.md +79 -0
  60. package/docs/groups.md +85 -0
  61. package/docs/index.md +5 -0
  62. package/docs/llm-rules.md +81 -0
  63. package/docs/middlewares.md +78 -0
  64. package/docs/overrides/home.html +350 -0
  65. package/docs/plugins.md +59 -0
  66. package/docs/react.md +75 -0
  67. package/docs/realtime.md +43 -0
  68. package/docs/s3-storage.md +40 -0
  69. package/docs/security.md +23 -0
  70. package/docs/stylesheets/extra.css +375 -0
  71. package/docs/svelte.md +71 -0
  72. package/docs/telemetry.md +97 -0
  73. package/docs/upload.md +168 -0
  74. package/docs/validation.md +115 -0
  75. package/docs/vue.md +86 -0
  76. package/docs/webhooks.md +87 -0
  77. package/index.js +11 -0
  78. package/locales/en/admin.json +169 -0
  79. package/mkdocs.yml +82 -0
  80. package/package.json +65 -0
  81. package/playwright.config.js +24 -0
  82. package/public/.gitkeep +0 -0
  83. package/sdk/README.md +284 -0
  84. package/sdk/package.json +39 -0
  85. package/sdk/scripts/build.js +58 -0
  86. package/sdk/src/index.js +368 -0
  87. package/sdk/test/sdk.test.cjs +340 -0
  88. package/sdk/types/index.d.ts +217 -0
  89. package/server/express-server.js +734 -0
  90. package/test/access-policies.test.js +96 -0
  91. package/test/ai.test.js +81 -0
  92. package/test/api-keys.test.js +361 -0
  93. package/test/auth.test.js +122 -0
  94. package/test/browser/admin-ui.spec.js +127 -0
  95. package/test/browser/global-setup.js +71 -0
  96. package/test/browser/global-teardown.js +11 -0
  97. package/test/db.test.js +227 -0
  98. package/test/entity-engine.test.js +193 -0
  99. package/test/error-reporter.test.js +140 -0
  100. package/test/functions-engine.test.js +240 -0
  101. package/test/groups.test.js +212 -0
  102. package/test/hot-reload.test.js +153 -0
  103. package/test/i18n.test.js +173 -0
  104. package/test/middleware.test.js +76 -0
  105. package/test/openapi.test.js +67 -0
  106. package/test/schema-validator.test.js +83 -0
  107. package/test/sdk.test.js +90 -0
  108. package/test/seeder.test.js +279 -0
  109. package/test/settings.test.js +109 -0
  110. package/test/telemetry.test.js +254 -0
  111. package/test/test.js +17 -0
  112. package/test/upload.test.js +265 -0
  113. package/test/validation.test.js +96 -0
  114. package/test/yaml-loader.test.js +93 -0
  115. package/utils/logger.js +24 -0
@@ -0,0 +1,93 @@
1
+ 'use strict';
2
+
3
+ const { WebSocketServer } = require('ws');
4
+ const logger = require('../utils/logger');
5
+
6
+ let wss = null;
7
+ // Map of channel -> Set of WebSocket clients
8
+ const subscriptions = new Map();
9
+
10
+ /**
11
+ * Attach a WebSocket server to an existing HTTP server.
12
+ * Clients connect and send JSON messages to subscribe to channels.
13
+ *
14
+ * Subscribe message format:
15
+ * { "type": "subscribe", "channel": "Post" }
16
+ *
17
+ * Event message format (server → client):
18
+ * { "type": "event", "event": "Post.created", "data": { ... } }
19
+ */
20
+ function initRealtime(httpServer) {
21
+ wss = new WebSocketServer({ server: httpServer, path: '/realtime' });
22
+
23
+ wss.on('connection', (ws) => {
24
+ logger.debug('Realtime: new client connected');
25
+ const clientChannels = new Set();
26
+
27
+ ws.on('message', (raw) => {
28
+ try {
29
+ const msg = JSON.parse(raw.toString());
30
+ if (msg.type === 'subscribe' && msg.channel) {
31
+ subscribe(msg.channel, ws);
32
+ clientChannels.add(msg.channel);
33
+ ws.send(JSON.stringify({ type: 'subscribed', channel: msg.channel }));
34
+ } else if (msg.type === 'unsubscribe' && msg.channel) {
35
+ unsubscribe(msg.channel, ws);
36
+ clientChannels.delete(msg.channel);
37
+ ws.send(JSON.stringify({ type: 'unsubscribed', channel: msg.channel }));
38
+ }
39
+ } catch (err) {
40
+ logger.debug('Realtime: invalid message', err.message);
41
+ }
42
+ });
43
+
44
+ ws.on('close', () => {
45
+ for (const channel of clientChannels) {
46
+ unsubscribe(channel, ws);
47
+ }
48
+ logger.debug('Realtime: client disconnected');
49
+ });
50
+ });
51
+
52
+ logger.info('Realtime WebSocket server ready at /realtime');
53
+ return wss;
54
+ }
55
+
56
+ function subscribe(channel, ws) {
57
+ if (!subscriptions.has(channel)) {
58
+ subscriptions.set(channel, new Set());
59
+ }
60
+ subscriptions.get(channel).add(ws);
61
+ }
62
+
63
+ function unsubscribe(channel, ws) {
64
+ if (subscriptions.has(channel)) {
65
+ subscriptions.get(channel).delete(ws);
66
+ }
67
+ }
68
+
69
+ /**
70
+ * Emit an event to all subscribers of the entity channel.
71
+ * eventName format: "EntityName.created" | "EntityName.updated" | "EntityName.deleted"
72
+ */
73
+ function emit(eventName, data) {
74
+ const entityName = eventName.split('.')[0];
75
+ const channels = [entityName, '*'];
76
+
77
+ for (const channel of channels) {
78
+ const clients = subscriptions.get(channel);
79
+ if (!clients) continue;
80
+ const payload = JSON.stringify({ type: 'event', event: eventName, data });
81
+ for (const ws of clients) {
82
+ if (ws.readyState === ws.OPEN) {
83
+ ws.send(payload);
84
+ }
85
+ }
86
+ }
87
+ }
88
+
89
+ function getWss() {
90
+ return wss;
91
+ }
92
+
93
+ module.exports = { initRealtime, emit, subscribe, unsubscribe, getWss };
@@ -0,0 +1,50 @@
1
+ 'use strict';
2
+
3
+ const Ajv = require('ajv');
4
+ const addFormats = require('ajv-formats');
5
+ const path = require('path');
6
+
7
+ const schema = require(path.join(__dirname, '..', 'chadstart.schema.json'));
8
+
9
+ const ajv = new Ajv({ allErrors: true, strict: false });
10
+ addFormats(ajv);
11
+
12
+ const validate = ajv.compile(schema);
13
+
14
+ /**
15
+ * Format a single AJV error into a human-readable string, enriched with
16
+ * the relevant `params` data so the user knows exactly what went wrong.
17
+ *
18
+ * @param {import('ajv').ErrorObject} e
19
+ * @returns {string}
20
+ */
21
+ function formatError(e) {
22
+ switch (e.keyword) {
23
+ case 'additionalProperties':
24
+ return `unknown property '${e.params.additionalProperty}'`;
25
+ case 'enum':
26
+ return `${e.message} (${e.params.allowedValues.join(', ')})`;
27
+ default:
28
+ return `${e.message}`;
29
+ }
30
+ }
31
+
32
+ /**
33
+ * Validate a parsed YAML config against the ChadStart JSON Schema.
34
+ * Throws a descriptive error on failure, returns true on success.
35
+ */
36
+ function validateSchema(config) {
37
+ const valid = validate(config);
38
+ if (!valid) {
39
+ // oneOf/anyOf errors are always redundant when allErrors:true already
40
+ // surfaces the specific sub-errors, so we drop them to reduce noise.
41
+ const errors = validate.errors.filter(
42
+ (e) => e.keyword !== 'oneOf' && e.keyword !== 'anyOf',
43
+ );
44
+ const messages = errors.map(formatError);
45
+ throw new Error(`Config validation failed:\n ${messages.join('\n ')}`);
46
+ }
47
+ return true;
48
+ }
49
+
50
+ module.exports = { validateSchema };
package/core/seeder.js ADDED
@@ -0,0 +1,231 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * Seeder — generates dummy data for all entities defined in the YAML config.
5
+ * Each entity can set `seedCount` (default: 50) to control how many records
6
+ * are created per run.
7
+ */
8
+
9
+ const bcrypt = require('bcryptjs');
10
+ const { create, findAllSimple } = require('./db');
11
+ const logger = require('../utils/logger');
12
+
13
+ const ADMIN_EMAIL = 'admin@chadstart.com';
14
+ const ADMIN_PASSWORD = 'admin';
15
+
16
+ // ─── Dummy value generators ──────────────────────────────────────────────────
17
+
18
+ const WORDS = [
19
+ 'alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', 'golf', 'hotel',
20
+ 'india', 'juliet', 'kilo', 'lima', 'mike', 'november', 'oscar', 'papa',
21
+ 'quebec', 'romeo', 'sierra', 'tango', 'uniform', 'victor', 'whiskey',
22
+ 'xray', 'yankee', 'zulu',
23
+ ];
24
+
25
+ let _counter = 0;
26
+
27
+ function nextId() {
28
+ return ++_counter;
29
+ }
30
+
31
+ function randomInt(min, max) {
32
+ return Math.floor(Math.random() * (max - min + 1)) + min;
33
+ }
34
+
35
+ function randomWord() {
36
+ return WORDS[randomInt(0, WORDS.length - 1)];
37
+ }
38
+
39
+ function randomWords(count) {
40
+ return Array.from({ length: count }, randomWord).join(' ');
41
+ }
42
+
43
+ function fakeValueForProp(prop, idx, groups = {}) {
44
+ const { name, type, options } = prop;
45
+ const n = idx + 1;
46
+
47
+ if (options && Array.isArray(options) && options.length > 0) {
48
+ return options[randomInt(0, options.length - 1)];
49
+ }
50
+
51
+ switch (type) {
52
+ case 'string':
53
+ return `${name} ${n} ${randomWord()}`;
54
+ case 'text':
55
+ case 'richText':
56
+ return `${randomWords(4)} ${n}. ${randomWords(5)}.`;
57
+ case 'integer':
58
+ case 'int':
59
+ return randomInt(1, 1000);
60
+ case 'number':
61
+ case 'float':
62
+ case 'real':
63
+ case 'money':
64
+ return Math.round(randomInt(1, 10000) * 0.01 * 100) / 100;
65
+ case 'boolean':
66
+ case 'bool':
67
+ return randomInt(0, 1);
68
+ case 'date':
69
+ return new Date(Date.now() - randomInt(0, 365) * 24 * 60 * 60 * 1000)
70
+ .toISOString()
71
+ .slice(0, 10);
72
+ case 'timestamp':
73
+ return new Date(Date.now() - randomInt(0, 365) * 24 * 60 * 60 * 1000).toISOString();
74
+ case 'email':
75
+ return `${randomWord()}${n}@example.com`;
76
+ case 'link':
77
+ return `https://example.com/${randomWord()}/${n}`;
78
+ case 'password':
79
+ return `password${n}`;
80
+ case 'choice':
81
+ return randomWord();
82
+ case 'location': {
83
+ const lat = (randomInt(-9000, 9000) / 100).toFixed(2);
84
+ const lng = (randomInt(-18000, 18000) / 100).toFixed(2);
85
+ return `${lat},${lng}`;
86
+ }
87
+ case 'file':
88
+ case 'image':
89
+ return `/uploads/placeholder-${n}.png`;
90
+ case 'json':
91
+ return JSON.stringify({ id: n, value: randomWord() });
92
+ case 'group': {
93
+ const groupName = options && options.group;
94
+ const groupDef = groups && groupName ? groups[groupName] : null;
95
+ if (groupDef && groupDef.properties) {
96
+ const multiple = !options || options.multiple !== false;
97
+ const count = multiple ? 2 : 1;
98
+ const items = Array.from({ length: count }, (_, j) =>
99
+ groupDef.properties.reduce((item, gp) => {
100
+ item[gp.name] = fakeValueForProp(gp, j, groups);
101
+ return item;
102
+ }, {})
103
+ );
104
+ return JSON.stringify(multiple ? items : items[0]);
105
+ }
106
+ return JSON.stringify([]);
107
+ }
108
+ default:
109
+ return `${name} ${n} ${randomWord()}`;
110
+ }
111
+ }
112
+
113
+ // ─── Topological sort ────────────────────────────────────────────────────────
114
+
115
+ /**
116
+ * Sort entities so that parents always come before their dependents.
117
+ * This ensures belongsTo FK references can be resolved.
118
+ */
119
+ function sortByDependency(entities) {
120
+ const names = Object.keys(entities);
121
+ const visited = new Set();
122
+ const sorted = [];
123
+
124
+ function visit(name) {
125
+ if (visited.has(name)) return;
126
+ visited.add(name);
127
+ const entity = entities[name];
128
+ for (const rel of entity.belongsTo || []) {
129
+ const parent = typeof rel === 'string' ? rel : (rel.entity || rel.name);
130
+ if (entities[parent]) visit(parent);
131
+ }
132
+ sorted.push(name);
133
+ }
134
+
135
+ for (const name of names) visit(name);
136
+ return sorted;
137
+ }
138
+
139
+ // ─── Main seed function ──────────────────────────────────────────────────────
140
+
141
+ /**
142
+ * Seed all entities in `core`.
143
+ * Returns a summary map: { EntityName: count }.
144
+ */
145
+ async function seedAll(core) {
146
+ _counter = 0;
147
+ const runToken = `${Date.now().toString(36)}${randomInt(1000, 9999)}`;
148
+ const sortedNames = sortByDependency(core.entities);
149
+ const summary = {};
150
+
151
+ // Track seeded ids per entity for FK resolution
152
+ const seededIds = {};
153
+
154
+ for (const entityName of sortedNames) {
155
+ const entity = core.entities[entityName];
156
+
157
+ // Singles are singleton records — only seed once if table is empty.
158
+ // We still respect seedCount=1 implicitly.
159
+ const count = entity.single ? 1 : (entity.seedCount || 50);
160
+
161
+ const ids = [];
162
+
163
+ for (let i = 0; i < count; i++) {
164
+ const record = {};
165
+
166
+ // Authenticable entities need email + (hashed) password
167
+ if (entity.authenticable) {
168
+ const n = nextId();
169
+ record.email = `${entityName.toLowerCase()}-${runToken}-${n}@example.com`;
170
+ record.password = bcrypt.hashSync(`password${n}`, 10);
171
+ }
172
+
173
+ // Regular properties (skip email/password for authenticable entities — handled above)
174
+ for (const prop of entity.properties) {
175
+ if (entity.authenticable && (prop.name === 'email' || prop.name === 'password')) continue;
176
+ if (prop.type === 'password') {
177
+ record[prop.name] = bcrypt.hashSync(fakeValueForProp(prop, i, core.groups), 10);
178
+ } else {
179
+ record[prop.name] = fakeValueForProp(prop, i, core.groups);
180
+ }
181
+ }
182
+
183
+ // BelongsTo FK: pick a random seeded parent id
184
+ for (const rel of entity.belongsTo || []) {
185
+ const parentName = typeof rel === 'string' ? rel : (rel.entity || rel.name);
186
+ const parentEntity = core.entities[parentName];
187
+ if (parentEntity && seededIds[parentName] && seededIds[parentName].length > 0) {
188
+ const fk = `${parentEntity.tableName}_id`;
189
+ const parentIds = seededIds[parentName];
190
+ record[fk] = parentIds[randomInt(0, parentIds.length - 1)];
191
+ }
192
+ }
193
+
194
+ try {
195
+ const created = create(entity.tableName, record);
196
+ ids.push(created.id);
197
+ } catch (err) {
198
+ logger.warn(`Seed: failed to create record for ${entityName}:`, err.message);
199
+ }
200
+ }
201
+
202
+ seededIds[entityName] = ids;
203
+ summary[entityName] = ids.length;
204
+ }
205
+
206
+ // Create the admin@chadstart.com user in every authenticable entity
207
+ const adminUsers = [];
208
+ for (const entity of Object.values(core.authenticableEntities || {})) {
209
+ const existing = findAllSimple(entity.tableName, { email: ADMIN_EMAIL });
210
+ if (existing.length === 0) {
211
+ const extraProps = entity.properties.reduce((acc, prop) => {
212
+ // Skip email and password — they are handled separately for authenticable entities
213
+ if (prop.name === 'email' || prop.name === 'password') return acc;
214
+ if (prop.type !== 'password') {
215
+ acc[prop.name] = fakeValueForProp(prop, 0, core.groups);
216
+ }
217
+ return acc;
218
+ }, {});
219
+ create(entity.tableName, {
220
+ email: ADMIN_EMAIL,
221
+ password: bcrypt.hashSync(ADMIN_PASSWORD, 10),
222
+ ...extraProps,
223
+ });
224
+ adminUsers.push(entity.name);
225
+ }
226
+ }
227
+
228
+ return { summary, adminEmail: ADMIN_EMAIL, adminPassword: ADMIN_PASSWORD, adminEntities: adminUsers };
229
+ }
230
+
231
+ module.exports = { seedAll, ADMIN_EMAIL, ADMIN_PASSWORD };
@@ -0,0 +1,119 @@
1
+ 'use strict';
2
+
3
+ /**
4
+ * OpenTelemetry integration for ChadStart.
5
+ *
6
+ * Non-secret values (service name, endpoint URL) can be configured via
7
+ * the YAML settings block or environment variables.
8
+ *
9
+ * Secret values (OTLP auth headers / API keys) MUST be provided via
10
+ * environment variables only — never put secrets in the YAML config file.
11
+ *
12
+ * Environment variables (all override YAML):
13
+ * OTEL_ENABLED=true enable tracing
14
+ * OTEL_SERVICE_NAME=my-service service name reported to the collector
15
+ * OTEL_EXPORTER_OTLP_ENDPOINT=http://... OTLP collector base URL
16
+ * OTEL_EXPORTER_OTLP_HEADERS=k1=v1,k2=v2 auth headers (secrets – env var only)
17
+ */
18
+
19
+ const logger = require('../utils/logger');
20
+
21
+ /** @type {import('@opentelemetry/sdk-node').NodeSDK | null} */
22
+ let _sdk = null;
23
+
24
+ /**
25
+ * Parse OTLP headers from the env-var format: "key1=value1,key2=value2".
26
+ *
27
+ * @param {string} raw
28
+ * @returns {Record<string, string>}
29
+ */
30
+ function parseOtlpHeaders(raw) {
31
+ const headers = {};
32
+ for (const pair of raw.split(',')) {
33
+ const idx = pair.indexOf('=');
34
+ if (idx > 0) {
35
+ headers[pair.slice(0, idx).trim()] = pair.slice(idx + 1).trim();
36
+ }
37
+ }
38
+ return headers;
39
+ }
40
+
41
+ /**
42
+ * Derive telemetry configuration from YAML telemetry config + environment variables.
43
+ * Returns null when telemetry is disabled.
44
+ *
45
+ * @param {object|null} telemetry Value of `core.telemetry` (may be null)
46
+ * @returns {{ enabled: true, serviceName: string, endpoint: string, headers: Record<string,string> } | null}
47
+ */
48
+ function getTelemetryConfig(telemetry) {
49
+ const tel = telemetry || {};
50
+
51
+ const enabled =
52
+ process.env.OTEL_ENABLED === 'true' ||
53
+ tel.enabled === true;
54
+
55
+ if (!enabled) return null;
56
+
57
+ return {
58
+ enabled: true,
59
+ serviceName: process.env.OTEL_SERVICE_NAME || tel.serviceName || 'chadstart-app',
60
+ endpoint: process.env.OTEL_EXPORTER_OTLP_ENDPOINT || tel.endpoint || 'http://localhost:4318',
61
+ // Headers carry secrets (API keys, bearer tokens) — only accepted from env var.
62
+ headers: process.env.OTEL_EXPORTER_OTLP_HEADERS
63
+ ? parseOtlpHeaders(process.env.OTEL_EXPORTER_OTLP_HEADERS)
64
+ : {},
65
+ };
66
+ }
67
+
68
+ /**
69
+ * Initialize the OpenTelemetry Node SDK with OTLP HTTP export.
70
+ * This is a singleton — subsequent calls are no-ops (safe to call on hot reload).
71
+ *
72
+ * @param {{ enabled: true, serviceName: string, endpoint: string, headers: object } | null} telConfig
73
+ */
74
+ async function initTelemetry(telConfig) {
75
+ if (!telConfig || !telConfig.enabled) return;
76
+ if (_sdk) return; // Already initialized — no-op on hot reload
77
+
78
+ try {
79
+ const { NodeSDK } = require('@opentelemetry/sdk-node');
80
+ const { OTLPTraceExporter } = require('@opentelemetry/exporter-trace-otlp-http');
81
+ const { getNodeAutoInstrumentations } = require('@opentelemetry/auto-instrumentations-node');
82
+ const { resourceFromAttributes } = require('@opentelemetry/resources');
83
+ const { ATTR_SERVICE_NAME } = require('@opentelemetry/semantic-conventions');
84
+
85
+ const exporter = new OTLPTraceExporter({
86
+ url: `${telConfig.endpoint}/v1/traces`,
87
+ headers: telConfig.headers,
88
+ });
89
+
90
+ _sdk = new NodeSDK({
91
+ resource: resourceFromAttributes({ [ATTR_SERVICE_NAME]: telConfig.serviceName }),
92
+ traceExporter: exporter,
93
+ instrumentations: [getNodeAutoInstrumentations()],
94
+ });
95
+
96
+ _sdk.start();
97
+ logger.info(` OpenTelemetry tracing enabled (service: ${telConfig.serviceName}, endpoint: ${telConfig.endpoint})`);
98
+ } catch (err) {
99
+ logger.error('Failed to initialize OpenTelemetry:', err.message);
100
+ }
101
+ }
102
+
103
+ /**
104
+ * Gracefully flush pending spans and shut down the SDK.
105
+ * Resets the singleton so a fresh `initTelemetry` call can re-initialize.
106
+ */
107
+ async function shutdownTelemetry() {
108
+ if (_sdk) {
109
+ try {
110
+ await _sdk.shutdown();
111
+ } catch (err) {
112
+ logger.error('Failed to shut down OpenTelemetry:', err.message);
113
+ } finally {
114
+ _sdk = null;
115
+ }
116
+ }
117
+ }
118
+
119
+ module.exports = { getTelemetryConfig, initTelemetry, shutdownTelemetry, parseOtlpHeaders };