@oneuptime/common 11.3.8 → 11.3.9

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 (33) hide show
  1. package/Models/AnalyticsModels/ExceptionInstance.ts +27 -0
  2. package/Models/AnalyticsModels/Log.ts +1 -0
  3. package/Models/AnalyticsModels/Metric.ts +1 -0
  4. package/Models/AnalyticsModels/Profile.ts +1 -0
  5. package/Models/AnalyticsModels/Span.ts +1 -0
  6. package/Server/Infrastructure/Queue.ts +19 -2
  7. package/Server/Infrastructure/QueueWorker.ts +67 -1
  8. package/Server/Services/TelemetryAttributeService.ts +3 -4
  9. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +81 -2
  10. package/Tests/Server/Utils/AnalyticsDatabase/StatementGenerator.test.ts +208 -0
  11. package/Tests/Types/Metrics/MetricPipelineRuleFilterCondition.test.ts +208 -0
  12. package/Types/AnalyticsDatabase/TableColumn.ts +16 -0
  13. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js +24 -0
  14. package/build/dist/Models/AnalyticsModels/ExceptionInstance.js.map +1 -1
  15. package/build/dist/Models/AnalyticsModels/Log.js +1 -0
  16. package/build/dist/Models/AnalyticsModels/Log.js.map +1 -1
  17. package/build/dist/Models/AnalyticsModels/Metric.js +1 -0
  18. package/build/dist/Models/AnalyticsModels/Metric.js.map +1 -1
  19. package/build/dist/Models/AnalyticsModels/Profile.js +1 -0
  20. package/build/dist/Models/AnalyticsModels/Profile.js.map +1 -1
  21. package/build/dist/Models/AnalyticsModels/Span.js +1 -0
  22. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  23. package/build/dist/Server/Infrastructure/Queue.js +14 -2
  24. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  25. package/build/dist/Server/Infrastructure/QueueWorker.js +45 -1
  26. package/build/dist/Server/Infrastructure/QueueWorker.js.map +1 -1
  27. package/build/dist/Server/Services/TelemetryAttributeService.js +1 -1
  28. package/build/dist/Server/Services/TelemetryAttributeService.js.map +1 -1
  29. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +66 -2
  30. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  31. package/build/dist/Types/AnalyticsDatabase/TableColumn.js +7 -0
  32. package/build/dist/Types/AnalyticsDatabase/TableColumn.js.map +1 -1
  33. package/package.json +1 -1
@@ -591,6 +591,7 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
591
591
  required: true,
592
592
  defaultValue: {},
593
593
  type: TableColumnType.MapStringString,
594
+ mapKeysColumn: "attributeKeys",
594
595
  accessControl: {
595
596
  read: [
596
597
  Permission.ProjectOwner,
@@ -614,6 +615,23 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
614
615
  },
615
616
  });
616
617
 
618
+ const attributeKeysColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
619
+ key: "attributeKeys",
620
+ codec: { codec: "ZSTD", level: 3 },
621
+ title: "Attribute Keys",
622
+ description: "Attribute keys extracted from attributes",
623
+ required: true,
624
+ defaultValue: [],
625
+ type: TableColumnType.ArrayText,
626
+ skipIndex: {
627
+ name: "idx_attribute_keys",
628
+ type: SkipIndexType.BloomFilter,
629
+ params: [0.01],
630
+ granularity: 1,
631
+ },
632
+ accessControl: attributesColumn.accessControl,
633
+ });
634
+
617
635
  const entityKeysColumn: AnalyticsTableColumn = new AnalyticsTableColumn({
618
636
  key: "entityKeys",
619
637
  codec: { codec: "ZSTD", level: 3 },
@@ -788,6 +806,7 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
788
806
  environmentColumn,
789
807
  parsedFramesColumn,
790
808
  attributesColumn,
809
+ attributeKeysColumn,
791
810
  entityKeysColumn,
792
811
  ...scalarEntityKeyColumns,
793
812
  retentionDateColumn,
@@ -918,6 +937,14 @@ export default class ExceptionInstance extends AnalyticsBaseModel {
918
937
  this.setColumnValue("attributes", v);
919
938
  }
920
939
 
940
+ public get attributeKeys(): Array<string> | undefined {
941
+ return this.getColumnValue("attributeKeys") as Array<string> | undefined;
942
+ }
943
+
944
+ public set attributeKeys(v: Array<string> | undefined) {
945
+ this.setColumnValue("attributeKeys", v);
946
+ }
947
+
921
948
  public get entityKeys(): Array<string> | undefined {
922
949
  return this.getColumnValue("entityKeys") as Array<string> | undefined;
923
950
  }
@@ -253,6 +253,7 @@ export default class Log extends AnalyticsBaseModel {
253
253
  required: true,
254
254
  defaultValue: {},
255
255
  type: TableColumnType.MapStringString,
256
+ mapKeysColumn: "attributeKeys",
256
257
  accessControl: {
257
258
  read: [
258
259
  Permission.ProjectOwner,
@@ -367,6 +367,7 @@ export default class Metric extends AnalyticsBaseModel {
367
367
  required: true,
368
368
  type: TableColumnType.MapStringString,
369
369
  defaultValue: {},
370
+ mapKeysColumn: "attributeKeys",
370
371
  accessControl: {
371
372
  read: [
372
373
  Permission.ProjectOwner,
@@ -514,6 +514,7 @@ export default class Profile extends AnalyticsBaseModel {
514
514
  required: true,
515
515
  defaultValue: {},
516
516
  type: TableColumnType.MapStringString,
517
+ mapKeysColumn: "attributeKeys",
517
518
  accessControl: {
518
519
  read: [
519
520
  Permission.ProjectOwner,
@@ -464,6 +464,7 @@ export default class Span extends AnalyticsBaseModel {
464
464
  required: true,
465
465
  defaultValue: {},
466
466
  type: TableColumnType.MapStringString,
467
+ mapKeysColumn: "attributeKeys",
467
468
  accessControl: {
468
469
  read: [
469
470
  Permission.ProjectOwner,
@@ -455,6 +455,23 @@ export default class Queue {
455
455
  }
456
456
  }
457
457
 
458
+ private static normalizeFailedReason(reason: string | undefined): string {
459
+ if (typeof reason !== "string") {
460
+ return "No reason provided";
461
+ }
462
+
463
+ const normalized: string = reason.trim().toLowerCase();
464
+ if (
465
+ normalized.length === 0 ||
466
+ normalized === "null" ||
467
+ normalized === "undefined"
468
+ ) {
469
+ return "No reason provided";
470
+ }
471
+
472
+ return reason;
473
+ }
474
+
458
475
  @CaptureSpan()
459
476
  public static async getQueueSize(queueName: QueueName): Promise<number> {
460
477
  const queue: BullQueue = this.getQueue(queueName);
@@ -534,7 +551,7 @@ export default class Queue {
534
551
  id: job.id || "unknown",
535
552
  name: job.name || "unknown",
536
553
  data: job.data as JSONObject,
537
- failedReason: job.failedReason || "No reason provided",
554
+ failedReason: Queue.normalizeFailedReason(job.failedReason),
538
555
  processedOn: job.processedOn ? new Date(job.processedOn) : null,
539
556
  finishedOn: job.finishedOn ? new Date(job.finishedOn) : null,
540
557
  attemptsMade: job.attemptsMade || 0,
@@ -641,7 +658,7 @@ export default class Queue {
641
658
  opts: (job.opts as unknown as JSONObject) || {},
642
659
  returnValue: job.returnvalue ?? null,
643
660
  progress: (job.progress as number | Record<string, unknown>) ?? null,
644
- failedReason: job.failedReason || "No reason provided",
661
+ failedReason: Queue.normalizeFailedReason(job.failedReason),
645
662
  stackTrace: job.stacktrace || [],
646
663
  logs: logs,
647
664
  attemptsMade: job.attemptsMade || 0,
@@ -92,7 +92,10 @@ export default class QueueWorker {
92
92
  (err as { name?: string })?.name === "TimeoutException"
93
93
  ? "timeout"
94
94
  : "failure";
95
- throw err;
95
+ throw QueueWorker.toReportableError(
96
+ err,
97
+ `${queueName}/${job.name || "unknown"} (job ${job.id ?? "unknown"})`,
98
+ );
96
99
  } finally {
97
100
  const elapsedNs: bigint = process.hrtime.bigint() - startNs;
98
101
  const durationMs: number = Number(elapsedNs) / 1e6;
@@ -134,6 +137,69 @@ export default class QueueWorker {
134
137
  return worker;
135
138
  }
136
139
 
140
+ private static toReportableError(err: unknown, context: string): unknown {
141
+ const messageIsUseful: (value: unknown) => boolean = (
142
+ value: unknown,
143
+ ): boolean => {
144
+ const message: unknown = (value as { message?: unknown })?.message;
145
+ if (typeof message !== "string") {
146
+ return false;
147
+ }
148
+
149
+ const normalized: string = message.trim().toLowerCase();
150
+ return (
151
+ normalized.length > 0 &&
152
+ normalized !== "null" &&
153
+ normalized !== "undefined"
154
+ );
155
+ };
156
+
157
+ if (err instanceof Error && messageIsUseful(err)) {
158
+ return err;
159
+ }
160
+
161
+ const detailParts: Array<string> = [];
162
+
163
+ const name: unknown = (err as { name?: unknown })?.name;
164
+ if (typeof name === "string" && name && name !== "Error") {
165
+ detailParts.push(name);
166
+ }
167
+
168
+ const rawMessage: unknown = (err as { message?: unknown })?.message;
169
+ if (typeof rawMessage === "string" && rawMessage.trim()) {
170
+ detailParts.push(rawMessage.trim());
171
+ }
172
+
173
+ if (detailParts.length === 0) {
174
+ let serialized: string;
175
+ try {
176
+ serialized =
177
+ typeof err === "object" && err !== null
178
+ ? JSON.stringify(err)
179
+ : String(err);
180
+ } catch {
181
+ serialized = Object.prototype.toString.call(err);
182
+ }
183
+
184
+ detailParts.push(
185
+ serialized && serialized !== "{}" ? serialized : String(err),
186
+ );
187
+ }
188
+
189
+ const reportable: Error = new Error(
190
+ `${context} failed with a non-descriptive error (original message was empty/null). Detail: ${detailParts.join(
191
+ ": ",
192
+ )}`,
193
+ );
194
+
195
+ const originalStack: unknown = (err as { stack?: unknown })?.stack;
196
+ if (typeof originalStack === "string" && originalStack) {
197
+ reportable.stack = originalStack;
198
+ }
199
+
200
+ return reportable;
201
+ }
202
+
137
203
  @CaptureSpan()
138
204
  public static async runJobWithTimeout(
139
205
  timeoutInMS: number,
@@ -20,9 +20,8 @@ type TelemetrySource = {
20
20
  tableName: string;
21
21
  attributesColumn: string;
22
22
  /*
23
- * Some tables (e.g. ExceptionInstance) don't have a separate
24
- * attributeKeys array column only the attributes map. Leave this
25
- * undefined for those; the SQL falls back to mapKeys(attributes).
23
+ * Older telemetry tables may lack a separate attributeKeys array column.
24
+ * Leave this undefined for those; the SQL falls back to mapKeys(attributes).
26
25
  */
27
26
  attributeKeysColumn?: string | undefined;
28
27
  timeColumn: string;
@@ -83,7 +82,7 @@ export class TelemetryAttributeService {
83
82
  service: ExceptionInstanceService,
84
83
  tableName: ExceptionInstanceService.model.tableName,
85
84
  attributesColumn: "attributes",
86
- // ExceptionInstance has no attributeKeys column.
85
+ attributeKeysColumn: "attributeKeys",
87
86
  timeColumn: "time",
88
87
  };
89
88
  default:
@@ -82,6 +82,38 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
82
82
  this.database = data.database;
83
83
  }
84
84
 
85
+ private appendMapKeyPresenceFilter(input: {
86
+ whereStatement: Statement;
87
+ mapColumn: AnalyticsTableColumn;
88
+ mapKey: string;
89
+ skip?: boolean;
90
+ }): void {
91
+ const mapKeysColumnName: string | undefined = input.mapColumn.mapKeysColumn;
92
+
93
+ if (input.skip || !mapKeysColumnName) {
94
+ return;
95
+ }
96
+
97
+ const mapKeysColumn: AnalyticsTableColumn | null =
98
+ this.model.getTableColumn(mapKeysColumnName);
99
+
100
+ if (!mapKeysColumn || mapKeysColumn.type !== TableColumnType.ArrayText) {
101
+ return;
102
+ }
103
+
104
+ /*
105
+ * Keep empty key-array rows eligible so data written before the
106
+ * denormalized column existed, or by a lagging ingest path, is still
107
+ * checked by the canonical map['key'] predicate that follows.
108
+ */
109
+ input.whereStatement.append(
110
+ SQL`AND (empty(${mapKeysColumn.key}) OR hasAny(${mapKeysColumn.key}, ${{
111
+ value: [input.mapKey],
112
+ type: TableColumnType.ArrayText,
113
+ }})) `,
114
+ );
115
+ }
116
+
85
117
  public toUpdateStatement(updateBy: UpdateBy<TBaseModel>): Statement {
86
118
  const setStatement: Statement = this.toSetStatement(updateBy.data);
87
119
  const whereStatement: Statement = this.toWhereStatement(updateBy.query);
@@ -708,6 +740,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
708
740
  }
709
741
 
710
742
  if (mapEntry instanceof NotNull) {
743
+ this.appendMapKeyPresenceFilter({
744
+ whereStatement,
745
+ mapColumn: tableColumn,
746
+ mapKey,
747
+ });
711
748
  whereStatement.append(
712
749
  SQL`AND mapContains(${key}, ${{
713
750
  value: mapKey,
@@ -789,12 +826,21 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
789
826
  }
790
827
 
791
828
  if (mapEntry instanceof EqualTo) {
829
+ const equalityValue: string = String(
830
+ (mapEntry as EqualTo<any>).value ?? "",
831
+ );
832
+ this.appendMapKeyPresenceFilter({
833
+ whereStatement,
834
+ mapColumn: tableColumn,
835
+ mapKey,
836
+ skip: equalityValue === "",
837
+ });
792
838
  whereStatement.append(
793
839
  SQL`AND ${key}[${{
794
840
  value: mapKey,
795
841
  type: TableColumnType.Text,
796
842
  }}] = ${{
797
- value: String((mapEntry as EqualTo<any>).value ?? ""),
843
+ value: equalityValue,
798
844
  type: TableColumnType.Text,
799
845
  }}`,
800
846
  );
@@ -809,6 +855,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
809
855
  * threshold and naturally drops those rows.
810
856
  */
811
857
  if (mapEntry instanceof GreaterThan) {
858
+ this.appendMapKeyPresenceFilter({
859
+ whereStatement,
860
+ mapColumn: tableColumn,
861
+ mapKey,
862
+ });
812
863
  whereStatement.append(
813
864
  SQL`AND toFloat64OrNull(${key}[${{
814
865
  value: mapKey,
@@ -822,6 +873,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
822
873
  }
823
874
 
824
875
  if (mapEntry instanceof GreaterThanOrEqual) {
876
+ this.appendMapKeyPresenceFilter({
877
+ whereStatement,
878
+ mapColumn: tableColumn,
879
+ mapKey,
880
+ });
825
881
  whereStatement.append(
826
882
  SQL`AND toFloat64OrNull(${key}[${{
827
883
  value: mapKey,
@@ -835,6 +891,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
835
891
  }
836
892
 
837
893
  if (mapEntry instanceof LessThan) {
894
+ this.appendMapKeyPresenceFilter({
895
+ whereStatement,
896
+ mapColumn: tableColumn,
897
+ mapKey,
898
+ });
838
899
  whereStatement.append(
839
900
  SQL`AND toFloat64OrNull(${key}[${{
840
901
  value: mapKey,
@@ -848,6 +909,11 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
848
909
  }
849
910
 
850
911
  if (mapEntry instanceof LessThanOrEqual) {
912
+ this.appendMapKeyPresenceFilter({
913
+ whereStatement,
914
+ mapColumn: tableColumn,
915
+ mapKey,
916
+ });
851
917
  whereStatement.append(
852
918
  SQL`AND toFloat64OrNull(${key}[${{
853
919
  value: mapKey,
@@ -876,6 +942,12 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
876
942
  if (includesValues.length === 0) {
877
943
  continue;
878
944
  }
945
+ this.appendMapKeyPresenceFilter({
946
+ whereStatement,
947
+ mapColumn: tableColumn,
948
+ mapKey,
949
+ skip: includesValues.includes(""),
950
+ });
879
951
  whereStatement.append(
880
952
  SQL`AND ${key}[${{
881
953
  value: mapKey,
@@ -919,12 +991,19 @@ export default class StatementGenerator<TBaseModel extends AnalyticsBaseModel> {
919
991
  }
920
992
 
921
993
  // Bare string/number/boolean — direct Map subscript.
994
+ const equalityValue: string = String(mapEntry);
995
+ this.appendMapKeyPresenceFilter({
996
+ whereStatement,
997
+ mapColumn: tableColumn,
998
+ mapKey,
999
+ skip: equalityValue === "",
1000
+ });
922
1001
  whereStatement.append(
923
1002
  SQL`AND ${key}[${{
924
1003
  value: mapKey,
925
1004
  type: TableColumnType.Text,
926
1005
  }}] = ${{
927
- value: String(mapEntry),
1006
+ value: equalityValue,
928
1007
  type: TableColumnType.Text,
929
1008
  }}`,
930
1009
  );
@@ -395,6 +395,214 @@ describe("StatementGenerator", () => {
395
395
  expect(statement.query).toBe("");
396
396
  expect(statement.query_params).toStrictEqual({});
397
397
  });
398
+
399
+ describe("map key-array pruning", () => {
400
+ class MapModelWithAttributeKeys extends AnalyticsBaseModel {
401
+ public constructor() {
402
+ super({
403
+ tableName: "<map-table-with-attribute-keys>",
404
+ singularName: "<singular>",
405
+ pluralName: "<plural>",
406
+ tableColumns: [
407
+ new AnalyticsTableColumn({
408
+ key: "_id",
409
+ title: "<title>",
410
+ description: "<description>",
411
+ required: true,
412
+ type: TableColumnType.ObjectID,
413
+ }),
414
+ new AnalyticsTableColumn({
415
+ key: "attributes",
416
+ title: "<title>",
417
+ description: "<description>",
418
+ required: true,
419
+ defaultValue: {},
420
+ type: TableColumnType.MapStringString,
421
+ mapKeysColumn: "attributeKeys",
422
+ }),
423
+ new AnalyticsTableColumn({
424
+ key: "attributeKeys",
425
+ title: "<title>",
426
+ description: "<description>",
427
+ required: true,
428
+ defaultValue: [],
429
+ type: TableColumnType.ArrayText,
430
+ }),
431
+ ],
432
+ crudApiPath: new Route("route"),
433
+ primaryKeys: ["_id"],
434
+ sortKeys: ["_id"],
435
+ partitionKey: "_id",
436
+ tableEngine: AnalyticsTableEngine.MergeTree,
437
+ });
438
+ }
439
+ }
440
+
441
+ class MapModelWithCustomMapKeys extends AnalyticsBaseModel {
442
+ public constructor() {
443
+ super({
444
+ tableName: "<map-table-with-custom-key-column>",
445
+ singularName: "<singular>",
446
+ pluralName: "<plural>",
447
+ tableColumns: [
448
+ new AnalyticsTableColumn({
449
+ key: "_id",
450
+ title: "<title>",
451
+ description: "<description>",
452
+ required: true,
453
+ type: TableColumnType.ObjectID,
454
+ }),
455
+ new AnalyticsTableColumn({
456
+ key: "tags",
457
+ title: "<title>",
458
+ description: "<description>",
459
+ required: true,
460
+ defaultValue: {},
461
+ type: TableColumnType.MapStringString,
462
+ mapKeysColumn: "tagKeys",
463
+ }),
464
+ new AnalyticsTableColumn({
465
+ key: "tagKeys",
466
+ title: "<title>",
467
+ description: "<description>",
468
+ required: true,
469
+ defaultValue: [],
470
+ type: TableColumnType.ArrayText,
471
+ }),
472
+ ],
473
+ crudApiPath: new Route("route"),
474
+ primaryKeys: ["_id"],
475
+ sortKeys: ["_id"],
476
+ partitionKey: "_id",
477
+ tableEngine: AnalyticsTableEngine.MergeTree,
478
+ });
479
+ }
480
+ }
481
+
482
+ let mapGeneratorWithAttributeKeys: StatementGenerator<MapModelWithAttributeKeys>;
483
+
484
+ beforeEach(() => {
485
+ mapGeneratorWithAttributeKeys =
486
+ new StatementGenerator<MapModelWithAttributeKeys>({
487
+ modelType: MapModelWithAttributeKeys,
488
+ database: ClickhouseAppInstance,
489
+ });
490
+ });
491
+
492
+ test("adds linked key-array bloom-index predicate for positive equality filters", () => {
493
+ const statement: Statement =
494
+ mapGeneratorWithAttributeKeys.toWhereStatement({
495
+ attributes: { requestId: "uuid-123" },
496
+ } as any);
497
+
498
+ expect(statement.query).toBe(
499
+ "AND (empty({p0:Identifier}) OR hasAny({p1:Identifier}, {p2:Array(String)})) AND {p3:Identifier}[{p4:String}] = {p5:String}",
500
+ );
501
+ expect(statement.query_params).toStrictEqual({
502
+ p0: "attributeKeys",
503
+ p1: "attributeKeys",
504
+ p2: ["requestId"],
505
+ p3: "attributes",
506
+ p4: "requestId",
507
+ p5: "uuid-123",
508
+ });
509
+ });
510
+
511
+ test("does not add linked key-array predicate for empty equality because missing keys also match", () => {
512
+ const statement: Statement =
513
+ mapGeneratorWithAttributeKeys.toWhereStatement({
514
+ attributes: { requestId: "" },
515
+ } as any);
516
+
517
+ expect(statement.query).toBe(
518
+ "AND {p0:Identifier}[{p1:String}] = {p2:String}",
519
+ );
520
+ expect(statement.query_params).toStrictEqual({
521
+ p0: "attributes",
522
+ p1: "requestId",
523
+ p2: "",
524
+ });
525
+ });
526
+
527
+ test("adds linked key-array bloom-index predicate for positive Includes filters", () => {
528
+ const statement: Statement =
529
+ mapGeneratorWithAttributeKeys.toWhereStatement({
530
+ attributes: {
531
+ "k8s.cluster.name": new Includes(["prod-east", "prod-west"]),
532
+ },
533
+ } as any);
534
+
535
+ expect(statement.query).toBe(
536
+ "AND (empty({p0:Identifier}) OR hasAny({p1:Identifier}, {p2:Array(String)})) AND {p3:Identifier}[{p4:String}] IN {p5:Array(String)}",
537
+ );
538
+ expect(statement.query_params).toStrictEqual({
539
+ p0: "attributeKeys",
540
+ p1: "attributeKeys",
541
+ p2: ["k8s.cluster.name"],
542
+ p3: "attributes",
543
+ p4: "k8s.cluster.name",
544
+ p5: ["prod-east", "prod-west"],
545
+ });
546
+ });
547
+
548
+ test("does not add linked key-array predicate for Includes containing empty string", () => {
549
+ const statement: Statement =
550
+ mapGeneratorWithAttributeKeys.toWhereStatement({
551
+ attributes: {
552
+ "k8s.cluster.name": new Includes(["", "prod-east"]),
553
+ },
554
+ } as any);
555
+
556
+ expect(statement.query).toBe(
557
+ "AND {p0:Identifier}[{p1:String}] IN {p2:Array(String)}",
558
+ );
559
+ expect(statement.query_params).toStrictEqual({
560
+ p0: "attributes",
561
+ p1: "k8s.cluster.name",
562
+ p2: ["", "prod-east"],
563
+ });
564
+ });
565
+
566
+ test("does not add linked key-array predicate for NotEqual because missing keys still match", () => {
567
+ const statement: Statement =
568
+ mapGeneratorWithAttributeKeys.toWhereStatement({
569
+ attributes: { requestId: new NotEqual("uuid-123") },
570
+ } as any);
571
+
572
+ expect(statement.query).toBe(
573
+ "AND {p0:Identifier}[{p1:String}] != {p2:String}",
574
+ );
575
+ expect(statement.query_params).toStrictEqual({
576
+ p0: "attributes",
577
+ p1: "requestId",
578
+ p2: "uuid-123",
579
+ });
580
+ });
581
+
582
+ test("uses mapKeysColumn metadata instead of hardcoded telemetry column names", () => {
583
+ const customMapGenerator: StatementGenerator<MapModelWithCustomMapKeys> =
584
+ new StatementGenerator<MapModelWithCustomMapKeys>({
585
+ modelType: MapModelWithCustomMapKeys,
586
+ database: ClickhouseAppInstance,
587
+ });
588
+
589
+ const statement: Statement = customMapGenerator.toWhereStatement({
590
+ tags: { region: "us-east" },
591
+ } as any);
592
+
593
+ expect(statement.query).toBe(
594
+ "AND (empty({p0:Identifier}) OR hasAny({p1:Identifier}, {p2:Array(String)})) AND {p3:Identifier}[{p4:String}] = {p5:String}",
595
+ );
596
+ expect(statement.query_params).toStrictEqual({
597
+ p0: "tagKeys",
598
+ p1: "tagKeys",
599
+ p2: ["region"],
600
+ p3: "tags",
601
+ p4: "region",
602
+ p5: "us-east",
603
+ });
604
+ });
605
+ });
398
606
  });
399
607
 
400
608
  describe("ArrayText columns", () => {