@oneuptime/common 11.3.0 → 11.3.2

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.
@@ -1327,10 +1327,26 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1327
1327
  );
1328
1328
  }
1329
1329
 
1330
+ /*
1331
+ * ON CLUSTER is appended as RAW SQL right after the table reference. The
1332
+ * analytics schema is ALWAYS a sharded + replicated cluster (see
1333
+ * ClusterConfig: a single node is just a "cluster of one"). A bare
1334
+ * `ALTER … ADD COLUMN` reaches only the shard the client is connected to —
1335
+ * Keeper replicates within a shard but never across shards — so the column
1336
+ * lands on one shard and a later scatter-gather read through the Distributed
1337
+ * wrapper hits a shard that lacks it and fails with
1338
+ * "Missing columns: '<col>'" (Code 47 UNKNOWN_IDENTIFIER). ON CLUSTER also
1339
+ * makes this ADD COLUMN wait for cluster-wide completion, so the separate
1340
+ * `ADD INDEX` that addColumnInDatabase issues next never races a column that
1341
+ * has not yet propagated to the node the index DDL lands on.
1342
+ */
1330
1343
  const statement: Statement = SQL`
1331
1344
  ALTER TABLE ${this.database.getDatasourceOptions().database!}.${getStorageTableName(
1332
1345
  this.model.tableName,
1333
- )} ADD COLUMN IF NOT EXISTS `.append(columnDef);
1346
+ )}`
1347
+ .append(onClusterClause())
1348
+ .append(" ADD COLUMN IF NOT EXISTS ")
1349
+ .append(columnDef);
1334
1350
 
1335
1351
  logger.debug(`${this.model.tableName} Add Column Statement`);
1336
1352
  logger.debug(statement);
@@ -1359,8 +1375,14 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1359
1375
 
1360
1376
  const databaseName: string = this.database.getDatasourceOptions().database!;
1361
1377
  const statement: Statement = new Statement();
1378
+ /*
1379
+ * ON CLUSTER (raw SQL) so the skip index is added on every shard, matching
1380
+ * toAddColumnStatement. A bare ADD INDEX only reaches the connected shard,
1381
+ * and if the column it references has not propagated to that node yet it
1382
+ * fails with "Missing columns: '<col>'" (Code 47).
1383
+ */
1362
1384
  statement.append(
1363
- `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)} ADD INDEX IF NOT EXISTS ${idx.name} ${columnExpr} TYPE ${idx.type}${paramsStr} GRANULARITY ${idx.granularity}`,
1385
+ `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)}${onClusterClause()} ADD INDEX IF NOT EXISTS ${idx.name} ${columnExpr} TYPE ${idx.type}${paramsStr} GRANULARITY ${idx.granularity}`,
1364
1386
  );
1365
1387
 
1366
1388
  logger.debug(`${this.model.tableName} Add Skip Index Statement`);
@@ -1371,7 +1393,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1371
1393
 
1372
1394
  public toDropSkipIndexStatement(indexName: string): string {
1373
1395
  const databaseName: string = this.database.getDatasourceOptions().database!;
1374
- const statement: string = `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)} DROP INDEX IF EXISTS ${indexName}`;
1396
+ const statement: string = `ALTER TABLE ${databaseName}.${getStorageTableName(this.model.tableName)}${onClusterClause()} DROP INDEX IF EXISTS ${indexName}`;
1375
1397
 
1376
1398
  logger.debug(`${this.model.tableName} Drop Skip Index Statement`);
1377
1399
  logger.debug(statement);
@@ -1383,7 +1405,7 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
1383
1405
  const statement: string = `ALTER TABLE ${this.database.getDatasourceOptions()
1384
1406
  .database!}.${getStorageTableName(
1385
1407
  this.model.tableName,
1386
- )} DROP COLUMN IF EXISTS ${columnName}`;
1408
+ )}${onClusterClause()} DROP COLUMN IF EXISTS ${columnName}`;
1387
1409
 
1388
1410
  logger.debug(`${this.model.tableName} Drop Column Statement`);
1389
1411
  logger.debug(statement);
@@ -524,30 +524,45 @@ export default class Telemetry {
524
524
  }): void {
525
525
  const { span, exception } = data;
526
526
 
527
- const exceptionAttributes: Attributes =
528
- this.getExceptionAttributes(exception);
529
-
530
- // log the exception as well
531
- logger.error(exception);
532
-
533
527
  /*
534
- * Span *events* (from recordException) are not reliably surfaced when the
535
- * span is read back, and setStatus on its own only records the error CODE,
536
- * not the message. So we also attach the exception details as queryable
537
- * span attributes including DB driver fields like the failing constraint
538
- * and table — so the actual cause is visible in the trace UI instead of an
539
- * empty "Error" status.
528
+ * This runs on the universal catch path of every @CaptureSpan-decorated
529
+ * function, so it must NEVER throw a throw here would mask the original
530
+ * error and skip marking the span. The whole body is wrapped defensively
531
+ * and the span is always marked-as-error and ended (in the finally).
540
532
  */
541
- span.setAttributes(exceptionAttributes);
542
- span.recordException(exception as SpanException);
543
- span.setStatus({
544
- code: SpanStatusCode.ERROR,
545
- message:
546
- (exceptionAttributes["exception.message"] as string | undefined) ||
547
- "Error",
548
- });
533
+ try {
534
+ const exceptionAttributes: Attributes =
535
+ this.getExceptionAttributes(exception);
536
+
537
+ // log the exception as well
538
+ logger.error(exception);
549
539
 
550
- this.endSpan(span);
540
+ /*
541
+ * Span *events* (from recordException) are not reliably surfaced when the
542
+ * span is read back, and setStatus on its own only records the error CODE,
543
+ * not the message. So we also attach the exception details as queryable
544
+ * span attributes — including DB driver fields like the failing constraint
545
+ * and table — so the actual cause is visible in the trace UI instead of an
546
+ * empty "Error" status.
547
+ */
548
+ span.setAttributes(exceptionAttributes);
549
+ span.recordException(exception as SpanException);
550
+ span.setStatus({
551
+ code: SpanStatusCode.ERROR,
552
+ message:
553
+ (exceptionAttributes["exception.message"] as string | undefined) ||
554
+ "Error",
555
+ });
556
+ } catch {
557
+ // Enrichment failed on some exotic thrown value — still flag the span.
558
+ try {
559
+ span.setStatus({ code: SpanStatusCode.ERROR });
560
+ } catch {
561
+ // span may already be ended; nothing more we can do.
562
+ }
563
+ } finally {
564
+ this.endSpan(span);
565
+ }
551
566
  }
552
567
 
553
568
  /*
@@ -557,72 +572,113 @@ export default class Telemetry {
557
572
  * the Postgres fields (SQLSTATE code, detail, constraint, table, column) live
558
573
  * either on the error itself or on `driverError`; these are what tell us which
559
574
  * constraint failed during e.g. a cascade delete.
575
+ *
576
+ * The thrown value is `unknown`, so anything (a Proxy, an object with a
577
+ * throwing getter or a throwing toString) could be passed — every field read
578
+ * and string coercion is guarded so this can never throw on the error path.
560
579
  */
561
580
  private static getExceptionAttributes(exception: unknown): Attributes {
562
581
  const attributes: Attributes = {};
563
582
 
564
- if (exception === null || exception === undefined) {
565
- attributes["exception.message"] =
566
- "Unknown error: null or undefined was thrown";
567
- return attributes;
568
- }
583
+ try {
584
+ if (exception === null || exception === undefined) {
585
+ attributes["exception.message"] =
586
+ "Unknown error: null or undefined was thrown";
587
+ return attributes;
588
+ }
569
589
 
570
- if (exception instanceof Error) {
571
- attributes["exception.type"] =
572
- exception.name || exception.constructor?.name || "Error";
573
- attributes["exception.message"] = exception.message || "";
574
- if (exception.stack) {
575
- attributes["exception.stacktrace"] = exception.stack.substring(0, 8000);
590
+ if (exception instanceof Error) {
591
+ attributes["exception.type"] =
592
+ exception.name || exception.constructor?.name || "Error";
593
+ attributes["exception.message"] = this.truncate(
594
+ exception.message || "",
595
+ 4000,
596
+ );
597
+ if (exception.stack) {
598
+ attributes["exception.stacktrace"] = this.truncate(
599
+ exception.stack,
600
+ 8000,
601
+ );
602
+ }
603
+ } else if (typeof exception === "string") {
604
+ attributes["exception.message"] = this.truncate(exception, 4000);
605
+ } else {
606
+ attributes["exception.message"] = this.truncate(
607
+ this.safeStringify(exception),
608
+ 4000,
609
+ );
576
610
  }
577
- } else if (typeof exception === "string") {
578
- attributes["exception.message"] = exception;
579
- } else {
580
- attributes["exception.message"] = this.safeStringify(exception);
581
- }
582
611
 
583
- type PotentialDatabaseError = {
584
- code?: unknown;
585
- detail?: unknown;
586
- constraint?: unknown;
587
- table?: unknown;
588
- column?: unknown;
589
- schema?: unknown;
590
- query?: unknown;
591
- driverError?: PotentialDatabaseError;
592
- };
612
+ type PotentialDatabaseError = {
613
+ code?: unknown;
614
+ detail?: unknown;
615
+ constraint?: unknown;
616
+ table?: unknown;
617
+ column?: unknown;
618
+ schema?: unknown;
619
+ query?: unknown;
620
+ driverError?: PotentialDatabaseError;
621
+ };
593
622
 
594
- const error: PotentialDatabaseError = exception as PotentialDatabaseError;
595
- const databaseError: PotentialDatabaseError = error.driverError || error;
623
+ const error: PotentialDatabaseError = exception as PotentialDatabaseError;
624
+ const databaseError: PotentialDatabaseError = error.driverError || error;
596
625
 
597
- const setStringAttribute: (key: string, value: unknown) => void = (
598
- key: string,
599
- value: unknown,
600
- ): void => {
601
- if (value !== undefined && value !== null && value !== "") {
602
- attributes[key] = String(value);
603
- }
604
- };
626
+ const setStringAttribute: (key: string, value: unknown) => void = (
627
+ key: string,
628
+ value: unknown,
629
+ ): void => {
630
+ try {
631
+ if (value !== undefined && value !== null && value !== "") {
632
+ attributes[key] = this.truncate(String(value), 2000);
633
+ }
634
+ } catch {
635
+ // A single unserializable field must not abort the whole enrichment.
636
+ }
637
+ };
605
638
 
606
- // SQLSTATE (e.g. "23503" = foreign key violation) or a Node error code.
607
- setStringAttribute("exception.code", error.code ?? databaseError.code);
608
- setStringAttribute("db.error.detail", databaseError.detail);
609
- setStringAttribute("db.error.constraint", databaseError.constraint);
610
- setStringAttribute("db.error.table", databaseError.table);
611
- setStringAttribute("db.error.column", databaseError.column);
612
- setStringAttribute("db.error.schema", databaseError.schema);
639
+ // SQLSTATE (e.g. "23503" = foreign key violation) or a Node error code.
640
+ setStringAttribute("exception.code", error.code ?? databaseError.code);
641
+ setStringAttribute("db.error.detail", databaseError.detail);
642
+ setStringAttribute("db.error.constraint", databaseError.constraint);
643
+ setStringAttribute("db.error.table", databaseError.table);
644
+ setStringAttribute("db.error.column", databaseError.column);
645
+ setStringAttribute("db.error.schema", databaseError.schema);
613
646
 
614
- if (typeof error.query === "string" && error.query.length > 0) {
615
- attributes["db.statement"] = error.query.substring(0, 2000);
647
+ if (typeof error.query === "string" && error.query.length > 0) {
648
+ attributes["db.statement"] = this.truncate(error.query, 2000);
649
+ }
650
+ } catch {
651
+ /*
652
+ * Reading exotic thrown values (throwing getters, Proxies) must never
653
+ * crash the error-reporting path. Keep whatever was collected so far.
654
+ */
655
+ if (!attributes["exception.message"]) {
656
+ attributes["exception.message"] =
657
+ "Error (exception details could not be extracted)";
658
+ }
616
659
  }
617
660
 
618
661
  return attributes;
619
662
  }
620
663
 
664
+ private static truncate(value: string, maxLength: number): string {
665
+ return value.length > maxLength ? value.substring(0, maxLength) : value;
666
+ }
667
+
621
668
  private static safeStringify(value: unknown): string {
622
669
  try {
623
- return JSON.stringify(value) || String(value);
670
+ const serialized: string | undefined = JSON.stringify(value);
671
+ if (serialized) {
672
+ return serialized;
673
+ }
624
674
  } catch {
675
+ // fall through to String() below
676
+ }
677
+
678
+ try {
625
679
  return String(value);
680
+ } catch {
681
+ return "[unserializable error value]";
626
682
  }
627
683
  }
628
684
 
@@ -232,20 +232,33 @@ describe("ClickHouse cluster-aware schema (always-on)", () => {
232
232
  );
233
233
  });
234
234
 
235
- test("schema ALTERs target the local table", () => {
235
+ test("schema ALTERs target the local table ON CLUSTER", () => {
236
236
  const column: AnalyticsTableColumn = new SpanModel().tableColumns[1]!;
237
- expect(fullText(spanGen.toAddColumnStatement(column))).toContain(
238
- "SpanItemV3Local",
239
- );
240
- expect(fullText(spanGen.toAddSkipIndexStatement(column)!)).toContain(
241
- "SpanItemV3Local",
242
- );
243
- expect(spanGen.toDropColumnStatement("traceId")).toContain(
244
- "SpanItemV3Local",
245
- );
246
- expect(spanGen.toDropSkipIndexStatement("idx_trace_id")).toContain(
247
- "SpanItemV3Local",
237
+
238
+ /*
239
+ * Column / skip-index ADD + DROP must carry ON CLUSTER: the local storage
240
+ * table is Replicated per shard, and Keeper only replicates within a
241
+ * shard. Without ON CLUSTER a reconciled column/index lands on a single
242
+ * shard, so a scatter-gather read through the Distributed wrapper hits a
243
+ * shard that lacks it and fails with "Missing columns" (Code 47).
244
+ */
245
+ const addColumn: string = fullText(spanGen.toAddColumnStatement(column));
246
+ expect(addColumn).toContain("SpanItemV3Local");
247
+ expect(addColumn).toContain("ON CLUSTER 'oneuptime'");
248
+
249
+ const addIndex: string = fullText(
250
+ spanGen.toAddSkipIndexStatement(column)!,
248
251
  );
252
+ expect(addIndex).toContain("SpanItemV3Local");
253
+ expect(addIndex).toContain("ON CLUSTER 'oneuptime'");
254
+
255
+ const dropColumn: string = spanGen.toDropColumnStatement("traceId");
256
+ expect(dropColumn).toContain("SpanItemV3Local");
257
+ expect(dropColumn).toContain("ON CLUSTER 'oneuptime'");
258
+
259
+ const dropIndex: string = spanGen.toDropSkipIndexStatement("idx_trace_id");
260
+ expect(dropIndex).toContain("SpanItemV3Local");
261
+ expect(dropIndex).toContain("ON CLUSTER 'oneuptime'");
249
262
  });
250
263
 
251
264
  test("ALTER UPDATE mutates the local table with ON CLUSTER", () => {
@@ -0,0 +1,251 @@
1
+ import Telemetry, {
2
+ Span,
3
+ SpanStatusCode,
4
+ } from "../../../Server/Utils/Telemetry";
5
+ import {
6
+ afterAll,
7
+ beforeAll,
8
+ describe,
9
+ expect,
10
+ jest,
11
+ test,
12
+ } from "@jest/globals";
13
+
14
+ type ExceptionAttributes = Record<string, string | undefined>;
15
+
16
+ // getExceptionAttributes is a private static; reach it through a narrow cast.
17
+ function getAttributes(exception: unknown): ExceptionAttributes {
18
+ return (
19
+ Telemetry as unknown as {
20
+ getExceptionAttributes: (e: unknown) => ExceptionAttributes;
21
+ }
22
+ ).getExceptionAttributes(exception);
23
+ }
24
+
25
+ describe("Telemetry.getExceptionAttributes", () => {
26
+ test("extracts Postgres fields from a TypeORM QueryFailedError-shaped error", () => {
27
+ const error: Error = Object.assign(
28
+ new Error('delete on "Project" violates foreign key constraint'),
29
+ {
30
+ driverError: {
31
+ code: "23503",
32
+ detail: 'Key (id)=(abc) is still referenced from table "Monitor".',
33
+ constraint: "FK_monitor_project",
34
+ table: "Monitor",
35
+ column: "projectId",
36
+ schema: "public",
37
+ },
38
+ query: 'DELETE FROM "Project" WHERE "_id" IN ($1)',
39
+ },
40
+ );
41
+
42
+ const attributes: ExceptionAttributes = getAttributes(error);
43
+
44
+ expect(attributes["exception.code"]).toBe("23503");
45
+ expect(attributes["db.error.constraint"]).toBe("FK_monitor_project");
46
+ expect(attributes["db.error.table"]).toBe("Monitor");
47
+ expect(attributes["db.error.column"]).toBe("projectId");
48
+ expect(attributes["db.error.schema"]).toBe("public");
49
+ expect(attributes["db.error.detail"]).toContain("still referenced");
50
+ expect(attributes["db.statement"]).toBe(
51
+ 'DELETE FROM "Project" WHERE "_id" IN ($1)',
52
+ );
53
+ expect(attributes["exception.type"]).toBe("Error");
54
+ expect(attributes["exception.message"]).toContain("violates foreign key");
55
+ });
56
+
57
+ test("reads a top-level pg error code when there is no driverError", () => {
58
+ const error: Error = Object.assign(
59
+ new Error("duplicate key value violates unique constraint"),
60
+ { code: "23505", constraint: "uniq_email" },
61
+ );
62
+
63
+ const attributes: ExceptionAttributes = getAttributes(error);
64
+
65
+ expect(attributes["exception.code"]).toBe("23505");
66
+ expect(attributes["db.error.constraint"]).toBe("uniq_email");
67
+ });
68
+
69
+ test("captures type, message and stacktrace for a plain Error", () => {
70
+ const attributes: ExceptionAttributes = getAttributes(new Error("boom"));
71
+
72
+ expect(attributes["exception.type"]).toBe("Error");
73
+ expect(attributes["exception.message"]).toBe("boom");
74
+ expect(typeof attributes["exception.stacktrace"]).toBe("string");
75
+ });
76
+
77
+ test("handles a thrown string", () => {
78
+ const attributes: ExceptionAttributes = getAttributes("kaboom");
79
+
80
+ expect(attributes["exception.message"]).toBe("kaboom");
81
+ expect(attributes["exception.type"]).toBeUndefined();
82
+ expect(attributes["db.error.constraint"]).toBeUndefined();
83
+ });
84
+
85
+ test("handles null and undefined throws", () => {
86
+ expect(getAttributes(null)["exception.message"]).toBe(
87
+ "Unknown error: null or undefined was thrown",
88
+ );
89
+ expect(getAttributes(undefined)["exception.message"]).toBe(
90
+ "Unknown error: null or undefined was thrown",
91
+ );
92
+ });
93
+
94
+ test("serializes a non-Error object throw into the message", () => {
95
+ const attributes: ExceptionAttributes = getAttributes({ a: 1, b: "x" });
96
+
97
+ expect(attributes["exception.message"]).toBe('{"a":1,"b":"x"}');
98
+ });
99
+
100
+ test("truncates an oversized message and SQL statement", () => {
101
+ const error: Error = Object.assign(new Error("x".repeat(9000)), {
102
+ query: `SELECT ${"a".repeat(9000)}`,
103
+ });
104
+
105
+ const attributes: ExceptionAttributes = getAttributes(error);
106
+
107
+ expect(attributes["exception.message"]?.length).toBe(4000);
108
+ expect(attributes["db.statement"]?.length).toBe(2000);
109
+ });
110
+
111
+ // --- crash-safety: hostile thrown values must never make this throw. ---
112
+
113
+ test("does not throw on an object with throwing getters", () => {
114
+ const hostile: Record<string, unknown> = {};
115
+ Object.defineProperty(hostile, "code", {
116
+ enumerable: true,
117
+ get: (): never => {
118
+ throw new Error("getter blew up");
119
+ },
120
+ });
121
+ Object.defineProperty(hostile, "driverError", {
122
+ enumerable: true,
123
+ get: (): never => {
124
+ throw new Error("driverError blew up");
125
+ },
126
+ });
127
+
128
+ let attributes: ExceptionAttributes = {};
129
+ expect((): void => {
130
+ attributes = getAttributes(hostile);
131
+ }).not.toThrow();
132
+ expect(typeof attributes["exception.message"]).toBe("string");
133
+ });
134
+
135
+ test("does not throw on a field whose toString throws (good fields survive)", () => {
136
+ const error: Error = Object.assign(new Error("db fail"), {
137
+ driverError: {
138
+ detail: {
139
+ toString: (): never => {
140
+ throw new Error("toString blew up");
141
+ },
142
+ },
143
+ },
144
+ });
145
+
146
+ expect((): ExceptionAttributes => {
147
+ return getAttributes(error);
148
+ }).not.toThrow();
149
+ expect(getAttributes(error)["exception.message"]).toBe("db fail");
150
+ });
151
+
152
+ test("does not throw on a Proxy that traps every property read", () => {
153
+ const hostile: unknown = new Proxy(
154
+ {},
155
+ {
156
+ get: (): never => {
157
+ throw new Error("proxy trap");
158
+ },
159
+ },
160
+ );
161
+
162
+ expect((): ExceptionAttributes => {
163
+ return getAttributes(hostile);
164
+ }).not.toThrow();
165
+ });
166
+ });
167
+
168
+ describe("Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan", () => {
169
+ type FakeSpanState = {
170
+ attributes: Record<string, unknown> | null;
171
+ status: { code: number; message?: string } | null;
172
+ recorded: unknown;
173
+ ended: number;
174
+ };
175
+
176
+ // Silence the logger.error console output these tests intentionally trigger.
177
+ beforeAll((): void => {
178
+ jest.spyOn(console, "error").mockImplementation((): void => {
179
+ return undefined;
180
+ });
181
+ });
182
+
183
+ afterAll((): void => {
184
+ jest.restoreAllMocks();
185
+ });
186
+
187
+ function makeFakeSpan(
188
+ state: FakeSpanState,
189
+ opts?: { throwOnSetAttributes?: boolean },
190
+ ): Span {
191
+ return {
192
+ setAttributes: (a: Record<string, unknown>): unknown => {
193
+ if (opts?.throwOnSetAttributes) {
194
+ throw new Error("setAttributes blew up");
195
+ }
196
+ state.attributes = a;
197
+ return undefined;
198
+ },
199
+ recordException: (e: unknown): unknown => {
200
+ state.recorded = e;
201
+ return undefined;
202
+ },
203
+ setStatus: (s: { code: number; message?: string }): unknown => {
204
+ state.status = s;
205
+ return undefined;
206
+ },
207
+ end: (): void => {
208
+ state.ended += 1;
209
+ },
210
+ } as unknown as Span;
211
+ }
212
+
213
+ test("marks the span as error with a message and ends it", () => {
214
+ const state: FakeSpanState = {
215
+ attributes: null,
216
+ status: null,
217
+ recorded: null,
218
+ ended: 0,
219
+ };
220
+
221
+ Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
222
+ span: makeFakeSpan(state),
223
+ exception: new Error("kaboom"),
224
+ });
225
+
226
+ expect(state.ended).toBe(1);
227
+ expect(state.status?.code).toBe(SpanStatusCode.ERROR);
228
+ expect(state.status?.message).toBe("kaboom");
229
+ expect((state.attributes || {})["exception.message"]).toBe("kaboom");
230
+ });
231
+
232
+ test("still ends and flags the span even if a span write throws", () => {
233
+ const state: FakeSpanState = {
234
+ attributes: null,
235
+ status: null,
236
+ recorded: null,
237
+ ended: 0,
238
+ };
239
+ const span: Span = makeFakeSpan(state, { throwOnSetAttributes: true });
240
+
241
+ expect((): void => {
242
+ Telemetry.recordExceptionMarkSpanAsErrorAndEndSpan({
243
+ span,
244
+ exception: new Error("kaboom"),
245
+ });
246
+ }).not.toThrow();
247
+
248
+ expect(state.ended).toBe(1);
249
+ expect(state.status?.code).toBe(SpanStatusCode.ERROR);
250
+ });
251
+ });