@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.
- package/Server/Services/ProjectService.ts +429 -300
- package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +26 -4
- package/Server/Utils/Telemetry.ts +123 -67
- package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +25 -12
- package/Tests/Server/Utils/Telemetry.test.ts +251 -0
- package/build/dist/Server/Services/ProjectService.js +371 -277
- package/build/dist/Server/Services/ProjectService.js.map +1 -1
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -4
- package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
- package/build/dist/Server/Utils/Telemetry.js +102 -50
- package/build/dist/Server/Utils/Telemetry.js.map +1 -1
- package/package.json +1 -1
|
@@ -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
|
-
)}
|
|
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
|
-
*
|
|
535
|
-
*
|
|
536
|
-
*
|
|
537
|
-
* span
|
|
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
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
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
|
-
|
|
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
|
-
|
|
565
|
-
|
|
566
|
-
"
|
|
567
|
-
|
|
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
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
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
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
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
|
-
|
|
595
|
-
|
|
623
|
+
const error: PotentialDatabaseError = exception as PotentialDatabaseError;
|
|
624
|
+
const databaseError: PotentialDatabaseError = error.driverError || error;
|
|
596
625
|
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
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
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
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
|
-
|
|
615
|
-
|
|
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
|
-
|
|
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
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
);
|
|
246
|
-
expect(
|
|
247
|
-
|
|
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
|
+
});
|