@oneuptime/common 11.2.3 → 11.3.1

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 (32) hide show
  1. package/Models/AnalyticsModels/Span.ts +285 -0
  2. package/Server/Infrastructure/Queue.ts +20 -0
  3. package/Server/Services/ProjectService.ts +429 -300
  4. package/Server/Utils/AnalyticsDatabase/StatementGenerator.ts +26 -4
  5. package/Server/Utils/Monitor/MonitorResource.ts +19 -1
  6. package/Server/Utils/Telemetry/LlmSpan.ts +251 -0
  7. package/Server/Utils/Telemetry.ts +154 -7
  8. package/Tests/Server/Utils/AnalyticsDatabase/ClusterAwareSchema.test.ts +25 -12
  9. package/Tests/Server/Utils/Telemetry/LlmSpan.test.ts +164 -0
  10. package/Tests/Server/Utils/Telemetry.test.ts +251 -0
  11. package/Types/Dashboard/DashboardComponents/DashboardTraceChartComponent.ts +8 -3
  12. package/UI/Components/Navbar/NavBar.tsx +13 -2
  13. package/Utils/Dashboard/Components/DashboardTraceChartComponent.ts +6 -58
  14. package/build/dist/Models/AnalyticsModels/Span.js +231 -0
  15. package/build/dist/Models/AnalyticsModels/Span.js.map +1 -1
  16. package/build/dist/Server/Infrastructure/Queue.js +7 -1
  17. package/build/dist/Server/Infrastructure/Queue.js.map +1 -1
  18. package/build/dist/Server/Services/ProjectService.js +371 -277
  19. package/build/dist/Server/Services/ProjectService.js.map +1 -1
  20. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js +26 -4
  21. package/build/dist/Server/Utils/AnalyticsDatabase/StatementGenerator.js.map +1 -1
  22. package/build/dist/Server/Utils/Monitor/MonitorResource.js +17 -1
  23. package/build/dist/Server/Utils/Monitor/MonitorResource.js.map +1 -1
  24. package/build/dist/Server/Utils/Telemetry/LlmSpan.js +153 -0
  25. package/build/dist/Server/Utils/Telemetry/LlmSpan.js.map +1 -0
  26. package/build/dist/Server/Utils/Telemetry.js +128 -7
  27. package/build/dist/Server/Utils/Telemetry.js.map +1 -1
  28. package/build/dist/UI/Components/Navbar/NavBar.js +4 -1
  29. package/build/dist/UI/Components/Navbar/NavBar.js.map +1 -1
  30. package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js +6 -49
  31. package/build/dist/Utils/Dashboard/Components/DashboardTraceChartComponent.js.map +1 -1
  32. 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
- )} 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);
@@ -188,6 +188,21 @@ export default class MonitorResourceUtil {
188
188
  * below guarantees the lock is released on every exit path (return or
189
189
  * throw); acquireTimeout/retryInterval cap the acquire spin so a contended
190
190
  * lock fails fast instead of polling Redis for 10s.
191
+ *
192
+ * On acquire timeout we DO NOT continue unlocked. Falling through without
193
+ * the lock silently abandons the per-monitor serialization this lock exists
194
+ * to provide — concurrent results for the same monitor would then race
195
+ * incident/alert dedup and status-timeline writes (duplicate
196
+ * incidents/alerts, status flaps) exactly under the high contention the
197
+ * lock is meant to handle. Instead we surface the contention so the work is
198
+ * retried once the lock frees: the Telemetry queue re-runs each ingest job
199
+ * up to 3x with exponential backoff, and the per-monitor crons catch-and-
200
+ * skip to re-evaluate on their next tick. The dominant source of
201
+ * same-monitor contention (an external sender hammering one Incoming
202
+ * Request URL) is collapsed upstream by BullMQ job coalescing at enqueue
203
+ * time (see TelemetryQueueService.addIncomingRequestIngestJob), so this
204
+ * throw is a correctness backstop for the rare residual collision (e.g.
205
+ * cron vs ingest), not the steady-state path.
191
206
  */
192
207
  let mutex: SemaphoreMutex | null = null;
193
208
 
@@ -200,7 +215,10 @@ export default class MonitorResourceUtil {
200
215
  acquireAttemptsLimit: 20,
201
216
  });
202
217
  } catch (err) {
203
- logger.error(err);
218
+ logger.debug(
219
+ `${dataToProcess.monitorId.toString()} - Could not acquire per-monitor processing lock within the acquire window; another worker is processing this monitor. Deferring to retry to preserve serialization.`,
220
+ );
221
+ throw err;
204
222
  }
205
223
 
206
224
  const releaseMutex: () => Promise<void> = async (): Promise<void> => {
@@ -0,0 +1,251 @@
1
+ import Dictionary from "../../../Types/Dictionary";
2
+ import { AttributeType } from "./Telemetry";
3
+
4
+ /*
5
+ * First-class detection of LLM / GenAI / AI-agent spans.
6
+ *
7
+ * OneUptime ingests OpenTelemetry spans generically. To make LLM and agent
8
+ * telemetry a first-class signal (filterable lists, token/cost/latency
9
+ * rollups) we denormalize a small set of values out of the span attributes at
10
+ * ingest time. We recognize the OpenTelemetry GenAI semantic conventions
11
+ * (gen_ai.*) as primary, with cheap fallbacks for the two dominant
12
+ * instrumentation libraries:
13
+ * - OpenLLMetry / Traceloop (gen_ai.* + traceloop.*)
14
+ * - OpenInference / Arize (llm.* + openinference.span.kind)
15
+ *
16
+ * Prompt/completion CONTENT is intentionally NOT denormalized here — it stays
17
+ * in the span's attributes/events map (already captured + scrubbed) and is
18
+ * rendered by the LLM span panel in the dashboard.
19
+ */
20
+
21
+ export interface LlmSpanFields {
22
+ // True when this span looks like an LLM / GenAI / agent operation.
23
+ isLlmSpan: boolean;
24
+ // Provider / system, e.g. "openai", "anthropic", "aws.bedrock".
25
+ llmSystem: string;
26
+ // Operation, e.g. "chat", "embeddings", "execute_tool", "invoke_agent".
27
+ llmOperation: string;
28
+ // Model requested by the caller.
29
+ llmRequestModel: string;
30
+ // Model the provider actually served (often the resolved/pinned model).
31
+ llmResponseModel: string;
32
+ // Token usage. 0 when the instrumentation did not report it.
33
+ llmInputTokens: number;
34
+ llmOutputTokens: number;
35
+ llmTotalTokens: number;
36
+ // Cost in USD. Only populated when the SDK reports it (no built-in pricing).
37
+ llmCost: number;
38
+ // Agent / tool names for agent-framework spans.
39
+ llmAgentName: string;
40
+ llmToolName: string;
41
+ }
42
+
43
+ type SpanAttributes = Dictionary<AttributeType | Array<AttributeType>>;
44
+
45
+ export default class LlmSpanUtil {
46
+ /**
47
+ * Return the empty/default LLM field set (non-LLM span).
48
+ */
49
+ public static empty(): LlmSpanFields {
50
+ return {
51
+ isLlmSpan: false,
52
+ llmSystem: "",
53
+ llmOperation: "",
54
+ llmRequestModel: "",
55
+ llmResponseModel: "",
56
+ llmInputTokens: 0,
57
+ llmOutputTokens: 0,
58
+ llmTotalTokens: 0,
59
+ llmCost: 0,
60
+ llmAgentName: "",
61
+ llmToolName: "",
62
+ };
63
+ }
64
+
65
+ /**
66
+ * Extract first-class LLM fields from a flattened span attribute dictionary.
67
+ * Pure + side-effect free so it can be unit tested in isolation.
68
+ */
69
+ public static extract(attributes: SpanAttributes): LlmSpanFields {
70
+ const fields: LlmSpanFields = this.empty();
71
+
72
+ if (!attributes || typeof attributes !== "object") {
73
+ return fields;
74
+ }
75
+
76
+ const keys: Array<string> = Object.keys(attributes);
77
+
78
+ if (keys.length === 0) {
79
+ return fields;
80
+ }
81
+
82
+ fields.llmSystem = this.getString(attributes, [
83
+ "gen_ai.system",
84
+ "gen_ai.provider.name",
85
+ "llm.system",
86
+ "llm.provider",
87
+ ]);
88
+
89
+ fields.llmOperation = this.getString(attributes, [
90
+ "gen_ai.operation.name",
91
+ "llm.request.type",
92
+ "openinference.span.kind",
93
+ ]);
94
+
95
+ fields.llmRequestModel = this.getString(attributes, [
96
+ "gen_ai.request.model",
97
+ "llm.model_name",
98
+ "llm.request.model",
99
+ ]);
100
+
101
+ fields.llmResponseModel = this.getString(attributes, [
102
+ "gen_ai.response.model",
103
+ "llm.response.model",
104
+ ]);
105
+
106
+ // Fall back to the response model when no request model was reported.
107
+ if (!fields.llmRequestModel && fields.llmResponseModel) {
108
+ fields.llmRequestModel = fields.llmResponseModel;
109
+ }
110
+
111
+ /*
112
+ * Token columns are ClickHouse Int32 — truncate any fractional value a
113
+ * malformed SDK might report, otherwise the JSONEachRow insert would
114
+ * reject the row and fail the whole span batch.
115
+ */
116
+ fields.llmInputTokens = Math.trunc(
117
+ this.getNumber(attributes, [
118
+ "gen_ai.usage.input_tokens",
119
+ "gen_ai.usage.prompt_tokens",
120
+ "llm.token_count.prompt",
121
+ "llm.usage.prompt_tokens",
122
+ ]),
123
+ );
124
+
125
+ fields.llmOutputTokens = Math.trunc(
126
+ this.getNumber(attributes, [
127
+ "gen_ai.usage.output_tokens",
128
+ "gen_ai.usage.completion_tokens",
129
+ "llm.token_count.completion",
130
+ "llm.usage.completion_tokens",
131
+ ]),
132
+ );
133
+
134
+ fields.llmTotalTokens = Math.trunc(
135
+ this.getNumber(attributes, [
136
+ "gen_ai.usage.total_tokens",
137
+ "llm.token_count.total",
138
+ "llm.usage.total_tokens",
139
+ ]),
140
+ );
141
+
142
+ // Derive total when only the parts were reported.
143
+ if (
144
+ fields.llmTotalTokens === 0 &&
145
+ (fields.llmInputTokens > 0 || fields.llmOutputTokens > 0)
146
+ ) {
147
+ fields.llmTotalTokens = fields.llmInputTokens + fields.llmOutputTokens;
148
+ }
149
+
150
+ fields.llmCost = this.getNumber(attributes, [
151
+ "gen_ai.usage.cost",
152
+ "gen_ai.usage.cost_usd",
153
+ "gen_ai.usage.total_cost",
154
+ "llm.usage.total_cost",
155
+ ]);
156
+
157
+ fields.llmAgentName = this.getString(attributes, [
158
+ "gen_ai.agent.name",
159
+ "agent.name",
160
+ ]);
161
+
162
+ fields.llmToolName = this.getString(attributes, [
163
+ "gen_ai.tool.name",
164
+ "tool.name",
165
+ ]);
166
+
167
+ fields.isLlmSpan = this.detectIsLlmSpan(keys, fields);
168
+
169
+ return fields;
170
+ }
171
+
172
+ private static detectIsLlmSpan(
173
+ keys: Array<string>,
174
+ fields: LlmSpanFields,
175
+ ): boolean {
176
+ if (
177
+ fields.llmSystem ||
178
+ fields.llmOperation ||
179
+ fields.llmRequestModel ||
180
+ fields.llmResponseModel ||
181
+ fields.llmAgentName ||
182
+ fields.llmToolName ||
183
+ fields.llmTotalTokens > 0
184
+ ) {
185
+ return true;
186
+ }
187
+
188
+ // Last-resort: any GenAI/LLM-namespaced attribute at all.
189
+ return keys.some((key: string) => {
190
+ return (
191
+ key.startsWith("gen_ai.") ||
192
+ key.startsWith("llm.") ||
193
+ key.startsWith("traceloop.")
194
+ );
195
+ });
196
+ }
197
+
198
+ private static getString(
199
+ attributes: SpanAttributes,
200
+ candidateKeys: Array<string>,
201
+ ): string {
202
+ for (const key of candidateKeys) {
203
+ const value: AttributeType | Array<AttributeType> | undefined =
204
+ attributes[key];
205
+
206
+ if (value === undefined || value === null) {
207
+ continue;
208
+ }
209
+
210
+ if (Array.isArray(value)) {
211
+ continue;
212
+ }
213
+
214
+ const stringValue: string = String(value).trim();
215
+
216
+ if (stringValue) {
217
+ return stringValue;
218
+ }
219
+ }
220
+
221
+ return "";
222
+ }
223
+
224
+ private static getNumber(
225
+ attributes: SpanAttributes,
226
+ candidateKeys: Array<string>,
227
+ ): number {
228
+ for (const key of candidateKeys) {
229
+ const value: AttributeType | Array<AttributeType> | undefined =
230
+ attributes[key];
231
+
232
+ if (value === undefined || value === null || Array.isArray(value)) {
233
+ continue;
234
+ }
235
+
236
+ if (typeof value === "number" && isFinite(value)) {
237
+ return value;
238
+ }
239
+
240
+ if (typeof value === "string" && value.trim() !== "") {
241
+ const parsed: number = Number(value);
242
+
243
+ if (isFinite(parsed)) {
244
+ return parsed;
245
+ }
246
+ }
247
+ }
248
+
249
+ return 0;
250
+ }
251
+ }
@@ -524,15 +524,162 @@ export default class Telemetry {
524
524
  }): void {
525
525
  const { span, exception } = data;
526
526
 
527
- // log the exception as well
528
- logger.error(exception);
527
+ /*
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).
532
+ */
533
+ try {
534
+ const exceptionAttributes: Attributes =
535
+ this.getExceptionAttributes(exception);
536
+
537
+ // log the exception as well
538
+ logger.error(exception);
529
539
 
530
- span.recordException(exception as SpanException);
531
- span.setStatus({
532
- code: SpanStatusCode.ERROR,
533
- });
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
+ }
566
+ }
567
+
568
+ /*
569
+ * Pulls every useful field off an unknown thrown value into OpenTelemetry
570
+ * span attributes. Error message/stack are non-enumerable, so they are read
571
+ * explicitly. For database failures (TypeORM QueryFailedError / pg errors),
572
+ * the Postgres fields (SQLSTATE code, detail, constraint, table, column) live
573
+ * either on the error itself or on `driverError`; these are what tell us which
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.
579
+ */
580
+ private static getExceptionAttributes(exception: unknown): Attributes {
581
+ const attributes: Attributes = {};
582
+
583
+ try {
584
+ if (exception === null || exception === undefined) {
585
+ attributes["exception.message"] =
586
+ "Unknown error: null or undefined was thrown";
587
+ return attributes;
588
+ }
589
+
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
+ );
610
+ }
534
611
 
535
- this.endSpan(span);
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
+ };
622
+
623
+ const error: PotentialDatabaseError = exception as PotentialDatabaseError;
624
+ const databaseError: PotentialDatabaseError = error.driverError || error;
625
+
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
+ };
638
+
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);
646
+
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
+ }
659
+ }
660
+
661
+ return attributes;
662
+ }
663
+
664
+ private static truncate(value: string, maxLength: number): string {
665
+ return value.length > maxLength ? value.substring(0, maxLength) : value;
666
+ }
667
+
668
+ private static safeStringify(value: unknown): string {
669
+ try {
670
+ const serialized: string | undefined = JSON.stringify(value);
671
+ if (serialized) {
672
+ return serialized;
673
+ }
674
+ } catch {
675
+ // fall through to String() below
676
+ }
677
+
678
+ try {
679
+ return String(value);
680
+ } catch {
681
+ return "[unserializable error value]";
682
+ }
536
683
  }
537
684
 
538
685
  public static endSpan(span: Span): void {
@@ -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", () => {