@alexify/migronaut 2.0.0 → 2.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/CHANGELOG.md +436 -0
  2. package/README.md +235 -6
  3. package/bullmq.d.ts +860 -0
  4. package/bullmq.js +1 -0
  5. package/index.d.ts +888 -19
  6. package/migronaut.schema.json +238 -1
  7. package/package.json +21 -5
  8. package/src/bullmq/index.js +55 -0
  9. package/src/bullmq/jobs.js +454 -0
  10. package/src/bullmq/processor.js +632 -0
  11. package/src/bullmq/producer.js +427 -0
  12. package/src/bullmq/service.js +653 -0
  13. package/src/bullmq/wait.js +124 -0
  14. package/src/cli/args.js +12 -2
  15. package/src/cli/commands/converge.js +188 -0
  16. package/src/cli/commands/down.js +2 -0
  17. package/src/cli/commands/lock.js +2 -1
  18. package/src/cli/commands/redo.js +8 -1
  19. package/src/cli/commands/up.js +14 -1
  20. package/src/cli/exit-codes.js +9 -2
  21. package/src/cli/index.js +2 -0
  22. package/src/cli/shared.js +14 -4
  23. package/src/cli/table.js +164 -0
  24. package/src/core/audit.js +88 -3
  25. package/src/core/changelog.js +71 -6
  26. package/src/core/collections.js +396 -0
  27. package/src/core/config.js +130 -25
  28. package/src/core/converge-log.js +47 -0
  29. package/src/core/converge-plan.js +686 -0
  30. package/src/core/converge-search-run.js +440 -0
  31. package/src/core/converge-search.js +404 -0
  32. package/src/core/converge.js +1024 -0
  33. package/src/core/index-spec.js +507 -0
  34. package/src/core/lock-wait.js +260 -0
  35. package/src/core/lock.js +95 -28
  36. package/src/core/migrator.js +600 -287
  37. package/src/core/options.js +266 -0
  38. package/src/core/run-recorder.js +157 -0
  39. package/src/core/run.js +58 -90
  40. package/src/core/search-index-spec.js +758 -0
  41. package/src/core/sequence.js +134 -0
  42. package/src/core/server-info.js +63 -0
  43. package/src/errors/index.js +60 -0
  44. package/src/index.js +8 -0
  45. package/src/utils/actor.js +48 -0
  46. package/src/utils/canonical.js +212 -0
  47. package/src/utils/collection-name.js +21 -0
  48. package/src/utils/error.js +18 -1
  49. package/src/utils/id.js +77 -0
  50. package/src/utils/loader.js +39 -21
  51. package/src/utils/migration-name.js +32 -0
  52. package/src/utils/redact.js +21 -1
  53. package/src/utils/telemetry.js +410 -0
  54. package/src/utils/template.js +43 -2
@@ -0,0 +1,77 @@
1
+ const { randomUUID } = require('node:crypto');
2
+ const { ConfigInvalidError } = require('../errors/index.js');
3
+ const { errorText } = require('./error.js');
4
+
5
+ /**
6
+ * Longest id migronaut accepts. Also the limit a queue worker enforces on a
7
+ * job's group id — one constant, so a producer can never mint an id its own
8
+ * worker would reject.
9
+ */
10
+ const MAX_ID_LENGTH = 128;
11
+
12
+ /**
13
+ * The default id: a random (v4) UUID. This module is the only place in `src/`
14
+ * that mints one — everything else asks for an id through it, which is what
15
+ * lets the `generateId` config option replace the format everywhere at once.
16
+ */
17
+ const randomId = () => randomUUID();
18
+
19
+ /**
20
+ * Return `value` when it is usable as an id, throw otherwise. An id is stored
21
+ * as a string field (changelog `runId`, lock `owner`, a job's `groupId`) and
22
+ * gated on by truthiness in the kit, so an empty or non-string value would
23
+ * silently switch off the reentrancy guard and the owner-scoped lock release.
24
+ */
25
+ function assertId(value) {
26
+ if (typeof value !== 'string' || value.length === 0 || value.length > MAX_ID_LENGTH) {
27
+ throw new ConfigInvalidError(
28
+ `generateId must return a non-empty string of at most ${MAX_ID_LENGTH} characters`,
29
+ typeof value === 'string' ? { length: value.length } : { returned: typeof value },
30
+ );
31
+ }
32
+ return value;
33
+ }
34
+
35
+ /**
36
+ * Turn the `generateId` config option into the function the kit mints ids
37
+ * with. `undefined` keeps the default (`randomId`); anything else must be a
38
+ * function, and every id it returns is checked.
39
+ *
40
+ * The user's function is called bare — no arguments, no receiver — so a
41
+ * third-party generator passes straight through (`generateId: ulid`,
42
+ * `generateId: nanoid`): their first parameter means something of its own
43
+ * (a seed time, a size), and any argument migronaut passed would be read as it.
44
+ *
45
+ * It must be synchronous: the run id is minted in the same tick as the
46
+ * reentrancy guard that checks it, and a promise would be stored as a truthy
47
+ * non-id.
48
+ */
49
+ function createIdGenerator(generateId) {
50
+ if (generateId === undefined) return randomId;
51
+ if (typeof generateId !== 'function') {
52
+ throw new ConfigInvalidError('generateId must be a function', {
53
+ generateId: typeof generateId,
54
+ });
55
+ }
56
+ return () => {
57
+ let value;
58
+ try {
59
+ value = generateId();
60
+ } catch (error) {
61
+ throw new ConfigInvalidError(
62
+ 'generateId threw',
63
+ { cause: errorText(error) },
64
+ { cause: error },
65
+ );
66
+ }
67
+ if (typeof value?.then === 'function') {
68
+ // The promise is dropped, so its rejection must not surface as an
69
+ // unhandled one on top of the error below.
70
+ value.then(undefined, () => {});
71
+ throw new ConfigInvalidError('generateId must be synchronous — it returned a promise');
72
+ }
73
+ return assertId(value);
74
+ };
75
+ }
76
+
77
+ module.exports = { MAX_ID_LENGTH, assertId, createIdGenerator, randomId };
@@ -7,7 +7,7 @@ const { errorText } = require('./error.js');
7
7
  /** TypeScript source extensions that require a TS-capable runtime to import */
8
8
  const TS_EXTENSIONS = new Set(['.ts', '.mts', '.cts']);
9
9
 
10
- /** Distinguishes reload URLs; see the reload comment in loadMigrationFile */
10
+ /** Distinguishes reload URLs; see importUserFile */
11
11
  let reloadCounter = 0;
12
12
 
13
13
  /** Narrow an unknown value to a function */
@@ -36,9 +36,10 @@ function isUnsupportedTsSyntaxError(error) {
36
36
  }
37
37
 
38
38
  /**
39
- * Translate a dynamic-import failure into a clear MigrationInvalidExportError
40
- * when the cause is a `.ts`/`.mts`/`.cts` file the current runtime refused, or
41
- * return null to let the original error propagate.
39
+ * An actionable message for a dynamic-import failure whose cause is a
40
+ * `.ts`/`.mts`/`.cts` file the current runtime refused, or null to let the
41
+ * original error speak for itself. `noun` names what the file is ("migration",
42
+ * "collection definition").
42
43
  *
43
44
  * The shipped CLI runs as plain Node, whose type stripping (always present on
44
45
  * the supported Node >= 22.18 range) handles erasable TypeScript only. Two
@@ -47,18 +48,29 @@ function isUnsupportedTsSyntaxError(error) {
47
48
  * `--no-experimental-strip-types`), and non-erasable syntax such as `enum` or
48
49
  * `namespace` (`ERR_UNSUPPORTED_TYPESCRIPT_SYNTAX`).
49
50
  */
50
- function tsLoadErrorOrNull(filepath, error) {
51
+ function tsLoadMessageOrNull(filepath, error, noun) {
51
52
  const ext = path.extname(filepath).toLowerCase();
52
53
  if (!TS_EXTENSIONS.has(ext)) {
53
54
  return null;
54
55
  }
55
56
  const name = path.basename(filepath);
56
- let message;
57
57
  if (isUnknownExtensionError(error)) {
58
- message = `Cannot load TypeScript migration "${name}" — type stripping is disabled in this Node process. Re-enable it, run migronaut under a TypeScript loader (e.g. tsx), or author the migration as .js.`;
59
- } else if (isUnsupportedTsSyntaxError(error)) {
60
- message = `Cannot load TypeScript migration "${name}" — it uses syntax Node's type stripping cannot erase (e.g. enum, namespace). Rewrite with erasable-only syntax, or run migronaut under a TypeScript loader (e.g. tsx).`;
61
- } else {
58
+ return `Cannot load TypeScript ${noun} "${name}" — type stripping is disabled in this Node process. Re-enable it, run migronaut under a TypeScript loader (e.g. tsx), or author the ${noun} as .js.`;
59
+ }
60
+ if (isUnsupportedTsSyntaxError(error)) {
61
+ return `Cannot load TypeScript ${noun} "${name}" — it uses syntax Node's type stripping cannot erase (e.g. enum, namespace). Rewrite with erasable-only syntax, or run migronaut under a TypeScript loader (e.g. tsx).`;
62
+ }
63
+ return null;
64
+ }
65
+
66
+ /**
67
+ * Translate a dynamic-import failure of a migration into a clear
68
+ * MigrationInvalidExportError (see {@link tsLoadMessageOrNull}), or return
69
+ * null to let the original error propagate.
70
+ */
71
+ function tsLoadErrorOrNull(filepath, error) {
72
+ const message = tsLoadMessageOrNull(filepath, error, 'migration');
73
+ if (message === null) {
62
74
  return null;
63
75
  }
64
76
  return new MigrationInvalidExportError(
@@ -68,6 +80,21 @@ function tsLoadErrorOrNull(filepath, error) {
68
80
  );
69
81
  }
70
82
 
83
+ /**
84
+ * Import a user-authored module (a migration, a collection definition).
85
+ *
86
+ * Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
87
+ * long-lived process (a test runner, a dev server re-running migrations, a
88
+ * queue worker) would keep evaluating the version it first imported; with
89
+ * `reload` a unique query string forces a fresh evaluation. Off by default —
90
+ * it leaks a module per load. A monotonic counter, not Date.now(): two
91
+ * reloads in one millisecond must still get distinct URLs.
92
+ */
93
+ function importUserFile(filepath, options = {}) {
94
+ const url = pathToFileURL(filepath).href;
95
+ return import(options.reload ? `${url}?migronaut=${++reloadCounter}` : url);
96
+ }
97
+
71
98
  /**
72
99
  * Dynamically load a migration file and validate its exports.
73
100
  *
@@ -85,18 +112,9 @@ async function loadMigrationFile(filepath, options = {}) {
85
112
  throw new MigrationFileNotFoundError('Migration file not found', { filepath });
86
113
  }
87
114
 
88
- // Node caches ESM modules by URL forever. A one-shot CLI never notices, but a
89
- // long-lived process (a test runner, a dev server re-running migrations)
90
- // would keep executing the version it first imported; a unique query string
91
- // forces a fresh evaluation. Off by default — it leaks a module per load.
92
- // A monotonic counter, not Date.now(): two reloads in one millisecond must
93
- // still get distinct URLs.
94
- const url = pathToFileURL(filepath).href;
95
- const href = options.reload ? `${url}?migronaut=${++reloadCounter}` : url;
96
-
97
115
  let imported;
98
116
  try {
99
- imported = await import(href);
117
+ imported = await importUserFile(filepath, { reload: options.reload });
100
118
  } catch (error) {
101
119
  const tsError = tsLoadErrorOrNull(filepath, error);
102
120
  if (tsError) {
@@ -128,4 +146,4 @@ async function loadMigrationFile(filepath, options = {}) {
128
146
  return migration;
129
147
  }
130
148
 
131
- module.exports = { tsLoadErrorOrNull, loadMigrationFile };
149
+ module.exports = { importUserFile, loadMigrationFile, tsLoadErrorOrNull, tsLoadMessageOrNull };
@@ -0,0 +1,32 @@
1
+ const { MigrationInvalidNameError } = require('../errors/index.js');
2
+
3
+ /**
4
+ * Whether `name` is a bare filename: a non-empty string with no path
5
+ * separator, no NUL byte, and not `.`/`..`. The single definition of the rule
6
+ * that keeps a migration name from escaping the migrations directory — shared
7
+ * by the kit's path resolution and by anything that accepts a name from
8
+ * outside the process (a queue job's payload).
9
+ */
10
+ function isBareFilename(name) {
11
+ return (
12
+ typeof name === 'string' &&
13
+ name.length > 0 &&
14
+ name !== '.' &&
15
+ name !== '..' &&
16
+ !name.includes('/') &&
17
+ !name.includes('\\') &&
18
+ !name.includes('\0')
19
+ );
20
+ }
21
+
22
+ /** Throw MigrationInvalidNameError unless `name` is a bare filename */
23
+ function assertMigrationName(name, context = {}) {
24
+ if (!isBareFilename(name)) {
25
+ throw new MigrationInvalidNameError(
26
+ 'Invalid migration name — must be a bare filename with no path segments',
27
+ { name, ...context },
28
+ );
29
+ }
30
+ }
31
+
32
+ module.exports = { assertMigrationName, isBareFilename };
@@ -49,6 +49,26 @@ function redactUris(text) {
49
49
  });
50
50
  }
51
51
 
52
+ /**
53
+ * The document values a server error can quote: an E11000 duplicate-key
54
+ * message ends with the offending key's values — an email, a phone number —
55
+ * which is the database's data, not an error's. Everything from `dup key: {`
56
+ * to the last `}` on that line is masked; the index name before it still says
57
+ * which constraint was violated.
58
+ */
59
+ const DUPLICATE_KEY_VALUES = /(dup key: )\{[^\n]*\}/g;
60
+
61
+ /**
62
+ * For text that leaves the process for a third party — a tracing backend, a
63
+ * queue that keeps failed jobs and serves them to dashboards: credentials
64
+ * masked (as everywhere) and the data values a server error quotes, too.
65
+ * Local log lines keep the values; they are what a developer debugs with.
66
+ */
67
+ function redactOutbound(text) {
68
+ if (typeof text !== 'string') return text;
69
+ return redactUris(text).replace(DUPLICATE_KEY_VALUES, '$1{ <redacted> }');
70
+ }
71
+
52
72
  /**
53
73
  * Redact every string reachable from `value` (plain objects and arrays only —
54
74
  * class instances are left alone rather than cloned into broken shapes).
@@ -69,4 +89,4 @@ function redactDeep(value) {
69
89
  return value;
70
90
  }
71
91
 
72
- module.exports = { redactUris, redactDeep };
92
+ module.exports = { redactDeep, redactOutbound, redactUris };
@@ -0,0 +1,410 @@
1
+ const { MigronautError } = require('../errors/index.js');
2
+ const { errorWithCause } = require('./error.js');
3
+ const { redactOutbound } = require('./redact.js');
4
+
5
+ /**
6
+ * OpenTelemetry's `SpanStatusCode.ERROR`. Spelled as a number because the enum
7
+ * lives in `@opentelemetry/api`, which migronaut never imports — the tracer and
8
+ * the meter are injected, the same way a logger is.
9
+ */
10
+ const SPAN_STATUS_ERROR = 2;
11
+
12
+ /** Span names — static on purpose: the migration's own name is an attribute */
13
+ const SPANS = {
14
+ RUN: 'migronaut.run',
15
+ MIGRATION: 'migronaut.migration',
16
+ };
17
+
18
+ /** Every attribute key migronaut sets, on spans and on metric points */
19
+ const ATTRIBUTES = {
20
+ RUN_ID: 'migronaut.run.id',
21
+ RUN_COMMAND: 'migronaut.run.command',
22
+ RUN_DIRECTION: 'migronaut.run.direction',
23
+ RUN_APPLIED: 'migronaut.run.applied',
24
+ RUN_REVERTED: 'migronaut.run.reverted',
25
+ RUN_SKIPPED: 'migronaut.run.skipped',
26
+ RUN_TOTAL: 'migronaut.run.total',
27
+ LOCK_ACQUIRE_MS: 'migronaut.lock.acquire_ms',
28
+ LOCK_SKIPPED: 'migronaut.lock.skipped',
29
+ LOCK_LOST_REASON: 'migronaut.lock.lost_reason',
30
+ LOCK_WAIT_OUTCOME: 'migronaut.lock.wait.outcome',
31
+ SEARCH_WAIT_OUTCOME: 'migronaut.converge.search.wait.outcome',
32
+ MIGRATION_NAME: 'migronaut.migration.name',
33
+ MIGRATION_DIRECTION: 'migronaut.migration.direction',
34
+ MIGRATION_BATCH: 'migronaut.migration.batch',
35
+ MIGRATION_INDEX: 'migronaut.migration.index',
36
+ MIGRATION_TOTAL: 'migronaut.migration.total',
37
+ MIGRATION_TRANSACTION: 'migronaut.migration.transaction',
38
+ ERROR_TYPE: 'error.type',
39
+ /** The database a run is against — OpenTelemetry's database semantic convention */
40
+ DB_NAMESPACE: 'db.namespace',
41
+ };
42
+
43
+ const METRICS = {
44
+ RUN_DURATION: 'migronaut.run.duration',
45
+ MIGRATION_DURATION: 'migronaut.migration.duration',
46
+ LOCK_ACQUIRE_DURATION: 'migronaut.lock.acquire.duration',
47
+ LOCK_WAIT_DURATION: 'migronaut.lock.wait.duration',
48
+ LOCK_REFUSED: 'migronaut.lock.refused',
49
+ LOCK_LOST: 'migronaut.lock.lost',
50
+ SEARCH_WAIT_DURATION: 'migronaut.converge.search.wait.duration',
51
+ };
52
+
53
+ /**
54
+ * Histogram bucket boundaries, in seconds. An SDK's default boundaries are
55
+ * sized for milliseconds (0…10000), which would put every migration shorter
56
+ * than five seconds into one bucket; these span 10ms to an hour.
57
+ */
58
+ const DURATION_BUCKETS_SECONDS = [0.01, 0.05, 0.1, 0.5, 1, 5, 10, 30, 60, 300, 900, 3600];
59
+
60
+ /** The longest span status message sent — a blocked run can list hundreds of files */
61
+ const MAX_STATUS_MESSAGE_LENGTH = 1024;
62
+
63
+ /**
64
+ * Mark a promise an SDK handed back as handled. A tracer that wraps the work
65
+ * (`return fn(span).finally(…)`) or an instrument that is async returns a
66
+ * promise nobody here awaits — and when it rejects, an unhandled rejection
67
+ * ends the process. The caller's own handlers are unaffected.
68
+ */
69
+ function quiet(value) {
70
+ if (value !== null && (typeof value === 'object' || typeof value === 'function')) {
71
+ try {
72
+ if (typeof value.then === 'function') value.then(undefined, () => {});
73
+ } catch {
74
+ // A `then` getter that throws is one more SDK fault to ignore.
75
+ }
76
+ }
77
+ return value;
78
+ }
79
+
80
+ /**
81
+ * The one guard every tracer, span and instrument call goes through: telemetry
82
+ * must never break a migration run, so a throwing SDK is swallowed here — and
83
+ * a rejecting one too, see {@link quiet}.
84
+ */
85
+ function safe(call) {
86
+ try {
87
+ return quiet(call());
88
+ } catch {
89
+ return undefined;
90
+ }
91
+ }
92
+
93
+ /** `attributes` without its undefined values — an SDK warns about (or rejects) those */
94
+ function defined(attributes) {
95
+ const result = {};
96
+ if (!attributes) return result;
97
+ for (const key of Object.keys(attributes)) {
98
+ if (attributes[key] !== undefined) result[key] = attributes[key];
99
+ }
100
+ return result;
101
+ }
102
+
103
+ /**
104
+ * The low-cardinality failure class OpenTelemetry's `error.type` asks for: the
105
+ * typed migronaut code when there is one, the error's class name otherwise.
106
+ */
107
+ function errorType(error) {
108
+ // Never throws: it runs on the way out of a failed run, where a hostile
109
+ // `name` getter would otherwise replace the run's own error.
110
+ try {
111
+ if (error instanceof MigronautError) return error.code;
112
+ if (typeof error?.name === 'string' && error.name.length > 0) return error.name;
113
+ } catch {
114
+ // fall through
115
+ }
116
+ return '_OTHER';
117
+ }
118
+
119
+ /**
120
+ * The status message for a failed span. Redacted like every string that leaves
121
+ * the process — a raw driver message can echo the credentialed URI — and joined
122
+ * with the wrapped cause, since "Migration up failed: X" alone says which
123
+ * migration and not why.
124
+ */
125
+ function failureText(error) {
126
+ // A span goes to a third-party backend: no data values, and a bounded size.
127
+ const text = redactOutbound(errorWithCause(error));
128
+ return text.length > MAX_STATUS_MESSAGE_LENGTH
129
+ ? `${text.slice(0, MAX_STATUS_MESSAGE_LENGTH - 1)}…`
130
+ : text;
131
+ }
132
+
133
+ /** The parts of `telemetry` — anything else in it is a typo, mentioned at debug level */
134
+ const TELEMETRY_KEYS = Object.freeze(['tracer', 'meter', 'attributes']);
135
+
136
+ /** The types a static attribute's value may have */
137
+ const ATTRIBUTE_VALUE_TYPES = new Set(['string', 'number', 'boolean']);
138
+ /** Static attributes are dimensions: a handful at most */
139
+ const MAX_STATIC_ATTRIBUTES = 20;
140
+
141
+ const hasMethods = (value, names) => {
142
+ if (typeof value !== 'object' || value === null) return false;
143
+ for (const name of names) {
144
+ if (typeof value[name] !== 'function') return false;
145
+ }
146
+ return true;
147
+ };
148
+
149
+ /**
150
+ * Issues with the `telemetry` option. Absent, `null` and an empty object all
151
+ * mean "off" — a config that builds it conditionally must not have to special-
152
+ * case the disabled branch. What is present has to be usable: a tracer that
153
+ * cannot start a span would fail on the first run, long after the mistake.
154
+ * Lives here, next to the calls it vouches for: no other module knows an
155
+ * OpenTelemetry method name.
156
+ */
157
+ function telemetryIssues(telemetry) {
158
+ if (telemetry === undefined || telemetry === null) return [];
159
+ if (typeof telemetry !== 'object' || Array.isArray(telemetry)) {
160
+ return [{ path: 'telemetry', message: 'must be an object' }];
161
+ }
162
+ const issues = [];
163
+ if (telemetry.tracer != null && !hasMethods(telemetry.tracer, ['startActiveSpan'])) {
164
+ issues.push({
165
+ path: 'telemetry.tracer',
166
+ message: 'must be an OpenTelemetry Tracer (an object with startActiveSpan)',
167
+ });
168
+ }
169
+ if (
170
+ telemetry.meter != null &&
171
+ !hasMethods(telemetry.meter, ['createHistogram', 'createCounter'])
172
+ ) {
173
+ issues.push({
174
+ path: 'telemetry.meter',
175
+ message: 'must be an OpenTelemetry Meter (an object with createHistogram and createCounter)',
176
+ });
177
+ }
178
+ const attributes = telemetry.attributes;
179
+ if (attributes !== undefined) {
180
+ if (attributes === null || typeof attributes !== 'object' || Array.isArray(attributes)) {
181
+ issues.push({ path: 'telemetry.attributes', message: 'must be an object' });
182
+ } else {
183
+ const keys = Object.keys(attributes);
184
+ if (keys.length > MAX_STATIC_ATTRIBUTES) {
185
+ issues.push({
186
+ path: 'telemetry.attributes',
187
+ message: `must hold at most ${MAX_STATIC_ATTRIBUTES} attributes — they are dimensions`,
188
+ });
189
+ }
190
+ for (const key of keys) {
191
+ const value = attributes[key];
192
+ if (!ATTRIBUTE_VALUE_TYPES.has(typeof value)) {
193
+ issues.push({
194
+ path: `telemetry.attributes.${key}`,
195
+ message: 'must be a string, a number or a boolean',
196
+ });
197
+ }
198
+ }
199
+ }
200
+ }
201
+ return issues;
202
+ }
203
+
204
+ /** What the kit holds when there is no tracer: the same surface, doing nothing */
205
+ const NOOP_SPAN = { set() {}, finish() {} };
206
+
207
+ /** Wrap an SDK span so no call on it can throw into the run, and it ends once */
208
+ function guardSpan(span) {
209
+ let ended = false;
210
+ const set = (attributes) => {
211
+ const values = defined(attributes);
212
+ for (const key of Object.keys(values)) {
213
+ safe(() => span.setAttribute(key, values[key]));
214
+ }
215
+ };
216
+ return {
217
+ set,
218
+ /**
219
+ * End the span. A failure sets the ERROR status and `error.type`; a
220
+ * success leaves the status unset, as the specification asks of
221
+ * instrumentation libraries (OK is the application's to claim).
222
+ */
223
+ finish(attributes, error) {
224
+ if (ended) return;
225
+ ended = true;
226
+ set(attributes);
227
+ if (error !== undefined) {
228
+ safe(() => span.setStatus({ code: SPAN_STATUS_ERROR, message: failureText(error) }));
229
+ safe(() => span.setAttribute(ATTRIBUTES.ERROR_TYPE, errorType(error)));
230
+ }
231
+ safe(() => span.end());
232
+ },
233
+ };
234
+ }
235
+
236
+ /**
237
+ * Turn the `telemetry` config option into what the kit reports through. With
238
+ * no tracer and no meter every method is a no-op that costs a function call.
239
+ *
240
+ * Both parts are the caller's own OpenTelemetry objects
241
+ * (`trace.getTracer(…)`, `metrics.getMeter(…)`): migronaut asks them for a
242
+ * span or an instrument and never looks at the SDK behind them.
243
+ */
244
+ function createTelemetry(telemetry, { dbName } = {}) {
245
+ const tracer = telemetry?.tracer ?? undefined;
246
+ const meter = telemetry?.meter ?? undefined;
247
+ // On every span and every metric point: which database the run was against
248
+ // (one process can migrate many — one kit per tenant), plus the caller's own
249
+ // low-cardinality dimensions. The caller's cannot overwrite migronaut's.
250
+ const base = defined({ ...telemetry?.attributes, [ATTRIBUTES.DB_NAMESPACE]: dbName });
251
+ const withBase = (attributes) => ({ ...base, ...defined(attributes) });
252
+
253
+ /**
254
+ * Run `fn(span)` with a new span as the active one, and leave ending it to
255
+ * the caller — the run span outlives the unit of work it is active around.
256
+ *
257
+ * `fn` runs exactly once whatever the tracer does: a tracer that throws
258
+ * before calling back still gets the work done (with a no-op span), one that
259
+ * throws afterwards cannot turn a finished run into a failed one, and one
260
+ * that calls back twice cannot run a migration twice. The result is taken
261
+ * from `fn` itself rather than from what the tracer returns.
262
+ */
263
+ function open(name, attributes, fn) {
264
+ if (!tracer) return fn(NOOP_SPAN);
265
+ let called = false;
266
+ let threw = false;
267
+ let result;
268
+ const run = (span) => {
269
+ if (called) return result;
270
+ called = true;
271
+ try {
272
+ result = fn(span);
273
+ } catch (error) {
274
+ threw = true;
275
+ result = error;
276
+ throw error;
277
+ }
278
+ return result;
279
+ };
280
+ // Three arguments, always: an SDK picks the overload by argument count.
281
+ safe(() =>
282
+ tracer.startActiveSpan(name, { attributes: withBase(attributes) }, (span) =>
283
+ run(guardSpan(span)),
284
+ ),
285
+ );
286
+ // Through `run`, not `fn`: it marks the work as done, so a tracer that
287
+ // calls back late finds nothing left to run.
288
+ if (!called) return run(NOOP_SPAN);
289
+ if (threw) throw result;
290
+ return result;
291
+ }
292
+
293
+ /** `open`, plus ending the span when `fn` settles — failed when it rejects */
294
+ function wrap(name, attributes, fn) {
295
+ if (!tracer) return fn(NOOP_SPAN);
296
+ return open(name, attributes, async (span) => {
297
+ try {
298
+ const value = await fn(span);
299
+ span.finish();
300
+ return value;
301
+ } catch (error) {
302
+ span.finish(undefined, error);
303
+ throw error;
304
+ }
305
+ });
306
+ }
307
+
308
+ const histogram = (name, description) =>
309
+ meter
310
+ ? safe(() =>
311
+ meter.createHistogram(name, {
312
+ description,
313
+ unit: 's',
314
+ advice: { explicitBucketBoundaries: DURATION_BUCKETS_SECONDS },
315
+ }),
316
+ )
317
+ : undefined;
318
+ const counter = (name, description, unit) =>
319
+ meter ? safe(() => meter.createCounter(name, { description, unit })) : undefined;
320
+
321
+ const runDuration = histogram(METRICS.RUN_DURATION, 'Duration of a migration run');
322
+ const migrationDuration = histogram(METRICS.MIGRATION_DURATION, 'Duration of one migration');
323
+ const lockAcquireDuration = histogram(
324
+ METRICS.LOCK_ACQUIRE_DURATION,
325
+ 'Round trip of the successful migration lock acquisition',
326
+ );
327
+ const lockWaitDuration = histogram(
328
+ METRICS.LOCK_WAIT_DURATION,
329
+ 'Time spent waiting for a held migration lock, by how the wait ended',
330
+ );
331
+ const lockRefused = counter(
332
+ METRICS.LOCK_REFUSED,
333
+ 'Attempts refused because the migration lock was held',
334
+ '{refusal}',
335
+ );
336
+ const lockLost = counter(METRICS.LOCK_LOST, 'Migration locks lost mid-run', '{loss}');
337
+ const searchWaitDuration = histogram(
338
+ METRICS.SEARCH_WAIT_DURATION,
339
+ 'Time a converge waited for its search index builds, by how the wait ended',
340
+ );
341
+
342
+ // Durations are measured in milliseconds everywhere in migronaut and
343
+ // reported in seconds, the unit OpenTelemetry's conventions settle on.
344
+ const record = (instrument, durationMs, attributes) => {
345
+ if (instrument) safe(() => instrument.record(durationMs / 1000, withBase(attributes)));
346
+ };
347
+ const increment = (instrument) => {
348
+ if (instrument) safe(() => instrument.add(1, withBase()));
349
+ };
350
+ const failure = (error) =>
351
+ error !== undefined ? { [ATTRIBUTES.ERROR_TYPE]: errorType(error) } : {};
352
+
353
+ return {
354
+ open,
355
+ wrap,
356
+ runEnded({ command, direction, durationMs, error }) {
357
+ record(runDuration, durationMs, {
358
+ [ATTRIBUTES.RUN_COMMAND]: command,
359
+ [ATTRIBUTES.RUN_DIRECTION]: direction,
360
+ ...failure(error),
361
+ });
362
+ },
363
+ migrationEnded({ direction, durationMs, error }) {
364
+ record(migrationDuration, durationMs, {
365
+ [ATTRIBUTES.MIGRATION_DIRECTION]: direction,
366
+ ...failure(error),
367
+ });
368
+ },
369
+ lockAcquired(acquireMs) {
370
+ record(lockAcquireDuration, acquireMs);
371
+ },
372
+ /**
373
+ * A wait for a held lock ended — `outcome` is `'acquired'`, `'timeout'`
374
+ * or `'aborted'`. Only waits that happened are recorded: the free-lock
375
+ * path is `lock.acquire.duration`'s.
376
+ */
377
+ lockWaited({ waitedMs, outcome }) {
378
+ record(lockWaitDuration, waitedMs, { [ATTRIBUTES.LOCK_WAIT_OUTCOME]: outcome });
379
+ },
380
+ lockRefused() {
381
+ increment(lockRefused);
382
+ },
383
+ lockLost() {
384
+ increment(lockLost);
385
+ },
386
+ /**
387
+ * A converge's wait for search index builds ended — `outcome` is
388
+ * `'ready'`, `'failed'`, `'timeout'`, `'unreadable'` or `'aborted'`. One
389
+ * point per wait, however many polls.
390
+ */
391
+ searchWaited({ waitedMs, outcome }) {
392
+ record(searchWaitDuration, waitedMs, { [ATTRIBUTES.SEARCH_WAIT_OUTCOME]: outcome });
393
+ },
394
+ };
395
+ }
396
+
397
+ module.exports = {
398
+ ATTRIBUTES,
399
+ TELEMETRY_KEYS,
400
+ telemetryIssues,
401
+ DURATION_BUCKETS_SECONDS,
402
+ MAX_STATUS_MESSAGE_LENGTH,
403
+ METRICS,
404
+ NOOP_SPAN,
405
+ SPANS,
406
+ SPAN_STATUS_ERROR,
407
+ createTelemetry,
408
+ errorType,
409
+ failureText,
410
+ };