@mastra/clickhouse 1.22.1 → 1.23.0-alpha.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -6557,6 +6557,10 @@ const TRACE_STATUS_SQL = `if(isNotNull(r.error), 'error', 'success')`;
6557
6557
  function durationMsSql(startedAt, endedAt) {
6558
6558
  return `dateDiff('millisecond', ${startedAt}, ${endedAt})`;
6559
6559
  }
6560
+ /** Top-level metadata string value on a trace root (alias `r`); `key` is a bound parameter placeholder. */
6561
+ function traceMetadataValueSql(key) {
6562
+ return `coalesce(if(mapContains(r.metadataSearch, ${key}), r.metadataSearch[${key}], NULL), nullIf(trim(JSONExtractString(r.metadataRaw, ${key})), ''))`;
6563
+ }
6560
6564
  const TRACE_FIELDS = {
6561
6565
  traceId: {
6562
6566
  sql: "r.traceId",
@@ -6815,9 +6819,8 @@ function compileScalarPredicate(predicate, registry, parameters, allowMetadata =
6815
6819
  if (predicate.type === "not") return `NOT (${compileScalarPredicate(predicate.arg, registry, parameters, allowMetadata)})`;
6816
6820
  const field = isMetadataField(predicate.field) ? (() => {
6817
6821
  if (!allowMetadata) throw new Error(`Unsupported trusted trace-query field: ${predicate.field}`);
6818
- const key = parameters.add(predicate.field.slice(9), "String");
6819
6822
  return {
6820
- sql: `coalesce(if(mapContains(r.metadataSearch, ${key}), r.metadataSearch[${key}], NULL), nullIf(trim(JSONExtractString(r.metadataRaw, ${key})), ''))`,
6823
+ sql: traceMetadataValueSql(parameters.add(predicate.field.slice(9), "String")),
6821
6824
  parameterType: "String"
6822
6825
  };
6823
6826
  })() : fieldDefinition(registry, predicate.field);
@@ -6910,7 +6913,7 @@ function compileTenantScope(scope, parameters) {
6910
6913
  if (scope.resourceId !== void 0) sql += `\n AND resourceId = ${parameters.add(scope.resourceId, "String")}`;
6911
6914
  return sql;
6912
6915
  }
6913
- function compileClickHouseTraceScope(selection, relationCollections, parameters, scope) {
6916
+ function compileClickHouseTraceScope(selection, relationCollections, parameters, scope, seedConjuncts = []) {
6914
6917
  const from = parameters.add(selection.timeRange.from, "DateTime64(3, 'UTC')");
6915
6918
  const to = parameters.add(selection.timeRange.to, "DateTime64(3, 'UTC')");
6916
6919
  const tenant = compileTenantScope(scope, parameters);
@@ -6920,9 +6923,9 @@ function compileClickHouseTraceScope(selection, relationCollections, parameters,
6920
6923
  FROM ${TABLE_TRACE_ROOTS}
6921
6924
  WHERE traceId IN (
6922
6925
  SELECT traceId
6923
- FROM ${TABLE_TRACE_ROOTS}
6926
+ FROM ${TABLE_TRACE_ROOTS} r
6924
6927
  WHERE startedAt >= ${from}
6925
- AND startedAt < ${to}${tenant}
6928
+ AND startedAt < ${to}${tenant}${seedConjuncts.map((conjunct) => `\n AND (${compilePredicate(conjunct, parameters)})`).join("")}
6926
6929
  )
6927
6930
  ORDER BY dedupeKey
6928
6931
  LIMIT 1 BY dedupeKey
@@ -7015,16 +7018,25 @@ function parseDeltaWatermark(value) {
7015
7018
  throw new coreStorage.TraceQueryCursorError("TRACE_QUERY_CURSOR_MALFORMED");
7016
7019
  }
7017
7020
  }
7018
- function compileClickHouseTraceQuery(plan, deltaHead) {
7019
- const parameters = new ParameterBuilder();
7020
- const ctes = compileClickHouseTraceScope(plan, collectRelationCollections(plan.where), parameters, plan.scope);
7021
- const predicate = plan.where ? compilePredicate(plan.where, parameters) : "1";
7021
+ /**
7022
+ * Builds the CTE chain ending in `candidates`: the completed, current trace roots in the
7023
+ * selection's time range and tenant scope that match its `where` predicate. Trace queries and
7024
+ * trace aggregates both select from this CTE so they always see the same population.
7025
+ */
7026
+ function compileClickHouseTraceCandidates(selection, columns, parameters) {
7027
+ const rootConjuncts = selection.where ? (selection.where.type === "boolean" && selection.where.operator === "and" ? selection.where.args : [selection.where]).filter((conjunct) => collectRelationCollections(conjunct).size === 0) : [];
7028
+ const ctes = compileClickHouseTraceScope(selection, collectRelationCollections(selection.where), parameters, selection.scope, rootConjuncts);
7029
+ const predicate = selection.where ? compilePredicate(selection.where, parameters) : "1";
7022
7030
  ctes.push(`candidates AS (
7023
- SELECT ${TRACE_SELECT}
7031
+ SELECT ${columns}
7024
7032
  FROM root_scope r
7025
7033
  WHERE ${predicate}
7026
7034
  )`);
7027
- const candidates = `WITH ${ctes.join(",\n")}`;
7035
+ return ctes;
7036
+ }
7037
+ function compileClickHouseTraceQuery(plan, deltaHead) {
7038
+ const parameters = new ParameterBuilder();
7039
+ const candidates = `WITH ${compileClickHouseTraceCandidates(plan, TRACE_SELECT, parameters).join(",\n")}`;
7028
7040
  if (plan.result === "groups") return {
7029
7041
  query: `${candidates}
7030
7042
  SELECT threadId
@@ -7200,10 +7212,8 @@ function compileClickHouseTraceQueryValues(plan) {
7200
7212
  const parameters = new ParameterBuilder();
7201
7213
  const ctes = compileClickHouseTraceScope(plan, discoveryCollections(plan.predicateScope), parameters, plan.scope);
7202
7214
  let field;
7203
- if (plan.predicateScope === "trace" && plan.path.startsWith("metadata.")) {
7204
- const key = parameters.add(plan.path.slice(9), "String");
7205
- field = `coalesce(if(mapContains(r.metadataSearch, ${key}), r.metadataSearch[${key}], NULL), nullIf(trim(JSONExtractString(r.metadataRaw, ${key})), ''))`;
7206
- } else if (plan.predicateScope === "trace" && plan.path === "tags") field = `arrayJoin(arrayDistinct(${TRACE_FIELDS.tags.sql}))`;
7215
+ if (plan.predicateScope === "trace" && plan.path.startsWith("metadata.")) field = traceMetadataValueSql(parameters.add(plan.path.slice(9), "String"));
7216
+ else if (plan.predicateScope === "trace" && plan.path === "tags") field = `arrayJoin(arrayDistinct(${TRACE_FIELDS.tags.sql}))`;
7207
7217
  else field = fieldDefinition(discoveryRegistry(plan.predicateScope), plan.path).sql;
7208
7218
  const search = plan.search ? `AND positionCaseInsensitiveUTF8(value, ${parameters.add(plan.search, "String")}) > 0` : "";
7209
7219
  const limit = parameters.add(plan.limit + 1, "UInt64");
@@ -7411,6 +7421,185 @@ async function queryThreads(client, plan, timeoutMs) {
7411
7421
  });
7412
7422
  }
7413
7423
  //#endregion
7424
+ //#region src/storage/domains/observability/v-next/trace-aggregate.ts
7425
+ /** Trace-root column expressions (alias `r`) for each groupable dimension; same values `where` sees. */
7426
+ const DIMENSION_SQL = {
7427
+ entityType: "r.entityType",
7428
+ entityName: "r.entityName",
7429
+ environment: "r.environment",
7430
+ status: TRACE_STATUS_SQL,
7431
+ serviceName: "r.serviceName",
7432
+ executionSource: "r.executionSource",
7433
+ threadId: "r.threadId",
7434
+ resourceId: "r.resourceId",
7435
+ userId: "r.userId",
7436
+ sessionId: "r.sessionId",
7437
+ organizationId: "r.organizationId",
7438
+ experimentId: "r.experimentId"
7439
+ };
7440
+ const PERCENTILES = {
7441
+ "duration.p50": .5,
7442
+ "duration.p90": .9,
7443
+ "duration.p95": .95,
7444
+ "duration.p99": .99
7445
+ };
7446
+ const COMPARISON_SQL = {
7447
+ eq: "=",
7448
+ ne: "!=",
7449
+ lt: "<",
7450
+ lte: "<=",
7451
+ gt: ">",
7452
+ gte: ">="
7453
+ };
7454
+ /**
7455
+ * Compiles a trusted aggregate plan. The plan's caps are enforced by the planner and trusted here.
7456
+ *
7457
+ * Every candidate root is first reduced to a `facts` row (dimension values, duration, error flag,
7458
+ * countDistinct inputs, bucket). Without `interval`, groups are filtered, ordered, and limited in
7459
+ * one grouped select. With `interval`, one `GROUPING SETS` pass computes both the whole-window
7460
+ * group rows and the per-bucket rows: group rows are ranked (having → orderBy), and each bucket
7461
+ * row inherits its group's rank. ClickHouse re-executes a CTE at every reference, so this keeps
7462
+ * the candidate scan to a single pass instead of joining bucket rows back to a ranked CTE.
7463
+ */
7464
+ function compileClickHouseTraceAggregate(plan) {
7465
+ const parameters = new ParameterBuilder();
7466
+ const ctes = compileClickHouseTraceCandidates(plan, "*", parameters);
7467
+ const dimensionSql = (field) => {
7468
+ if (field.startsWith("metadata.")) return traceMetadataValueSql(parameters.add(field.slice(9), "String"));
7469
+ const sql = Object.hasOwn(DIMENSION_SQL, field) ? DIMENSION_SQL[field] : void 0;
7470
+ if (sql === void 0) throw new Error(`Unsupported trusted trace-aggregate field: ${field}`);
7471
+ return sql;
7472
+ };
7473
+ const distinctSql = (field) => field === "traceId" ? "r.traceId" : dimensionSql(field);
7474
+ const dimensionColumns = plan.dimensions.map((_, index) => `d${index}`);
7475
+ const distinctColumns = /* @__PURE__ */ new Map();
7476
+ const factColumns = plan.dimensions.map((dimension, index) => `CAST(${dimensionSql(dimension)} AS Nullable(String)) AS d${index}`);
7477
+ for (const measure of plan.measures) {
7478
+ if (measure.type !== "countDistinct" || distinctColumns.has(measure.field)) continue;
7479
+ const column = `cd${distinctColumns.size}`;
7480
+ distinctColumns.set(measure.field, column);
7481
+ factColumns.push(`CAST(${distinctSql(measure.field)} AS Nullable(String)) AS ${column}`);
7482
+ }
7483
+ factColumns.push(`toFloat64(${durationMsSql("r.startedAt", "r.endedAt")}) AS durationMs`, `isNotNull(r.error) AS isError`, `cityHash64(r.traceId) AS traceSeed`);
7484
+ if (plan.interval !== void 0) {
7485
+ const intervalMs = coreStorage.TRACE_AGGREGATE_INTERVAL_MS[plan.interval];
7486
+ if (intervalMs === void 0) throw new Error(`Unsupported trusted trace-aggregate interval: ${plan.interval}`);
7487
+ factColumns.push(`fromUnixTimestamp64Milli(intDiv(toUnixTimestamp64Milli(r.startedAt), ${intervalMs}) * ${intervalMs}, 'UTC') AS bucket`);
7488
+ }
7489
+ ctes.push(`facts AS (
7490
+ SELECT ${factColumns.join(",\n ")}
7491
+ FROM candidates r
7492
+ )`);
7493
+ const measureSql = (name) => {
7494
+ if (name === "count") return "toFloat64(count())";
7495
+ if (name === "errorCount") return "toFloat64(countIf(isError))";
7496
+ if (name === "errorRate") return "countIf(isError) / count()";
7497
+ if (name === "duration.avg") return "avg(durationMs)";
7498
+ if (name === "duration.min") return "min(durationMs)";
7499
+ if (name === "duration.max") return "max(durationMs)";
7500
+ const percentile = PERCENTILES[name];
7501
+ if (percentile !== void 0) return `quantileDeterministic(${percentile})(durationMs, traceSeed)`;
7502
+ const measure = plan.measures.find((candidate) => candidate.type === "countDistinct" && candidate.name === name);
7503
+ const distinctColumn = measure && distinctColumns.get(measure.field);
7504
+ if (distinctColumn === void 0) throw new Error(`Unsupported trusted trace-aggregate measure: ${name}`);
7505
+ return `toFloat64(uniqExact(${distinctColumn}))`;
7506
+ };
7507
+ const compileHaving = (predicate) => {
7508
+ if (predicate.type === "boolean") return predicate.args.map((arg) => `(${compileHaving(arg)})`).join(predicate.operator === "and" ? " AND " : " OR ");
7509
+ if (predicate.type === "not") return `NOT (${compileHaving(predicate.arg)})`;
7510
+ const measure = measureSql(predicate.measure);
7511
+ if (predicate.type === "membership") {
7512
+ if (predicate.values.length === 0) return predicate.operator === "in" ? "0" : "1";
7513
+ const list = predicate.values.map((value) => parameters.add(value, "Float64")).join(", ");
7514
+ return `${measure} ${predicate.operator === "in" ? "IN" : "NOT IN"} (${list})`;
7515
+ }
7516
+ const operator = COMPARISON_SQL[predicate.operator];
7517
+ if (operator === void 0) throw new Error(`Unsupported trusted trace-aggregate operator: ${predicate.operator}`);
7518
+ return `${measure} ${operator} ${parameters.add(predicate.value, "Float64")}`;
7519
+ };
7520
+ const havingSql = ["count() > 0", ...plan.having ? [`(${compileHaving(plan.having)})`] : []].join(" AND ");
7521
+ const direction = plan.orderBy.direction === "asc" ? "ASC" : "DESC";
7522
+ let orderTarget;
7523
+ if (plan.orderBy.target === "measure") orderTarget = measureSql(plan.orderBy.measure);
7524
+ else {
7525
+ const index = plan.dimensions.indexOf(plan.orderBy.dimension);
7526
+ if (index === -1) throw new Error(`Unsupported trusted trace-aggregate order dimension: ${plan.orderBy.dimension}`);
7527
+ orderTarget = dimensionColumns[index];
7528
+ }
7529
+ const tiebreakSql = dimensionColumns.map((column) => `${column} ASC NULLS LAST`);
7530
+ const measureColumns = plan.measures.map((measure, index) => `${measureSql(measure.name)} AS m${index}`);
7531
+ const measureNames = plan.measures.map((_, index) => `m${index}`);
7532
+ if (plan.interval === void 0) {
7533
+ const limit = parameters.add(plan.limit + 1, "UInt64");
7534
+ return {
7535
+ query: `WITH ${ctes.join(",\n")}
7536
+ SELECT ${[...dimensionColumns, ...measureColumns].join(", ")}
7537
+ FROM facts
7538
+ ${dimensionColumns.length > 0 ? `GROUP BY ${dimensionColumns.join(", ")}\n` : ""}HAVING ${havingSql}
7539
+ ORDER BY ${[`${orderTarget} ${direction} NULLS LAST`, ...tiebreakSql].join(", ")}
7540
+ LIMIT ${limit}`,
7541
+ query_params: parameters.params
7542
+ };
7543
+ }
7544
+ const maxRank = parameters.add(plan.limit, "UInt64");
7545
+ const groupOrderSql = [`__order ${direction} NULLS LAST`, ...tiebreakSql].join(", ");
7546
+ const groupPartition = dimensionColumns.length > 0 ? `PARTITION BY ${dimensionColumns.join(", ")}` : "";
7547
+ ctes.push(`grouped AS (
7548
+ SELECT ${[
7549
+ ...dimensionColumns,
7550
+ "bucket",
7551
+ "grouping(bucket) = 1 AS __isGroup",
7552
+ ...measureColumns,
7553
+ `(${havingSql}) AS __keep`,
7554
+ `${orderTarget} AS __order`
7555
+ ].join(", ")}
7556
+ FROM facts
7557
+ GROUP BY GROUPING SETS ((${[...dimensionColumns, "bucket"].join(", ")}), (${dimensionColumns.join(", ")}))
7558
+ )`, `ranked AS (
7559
+ SELECT *,
7560
+ if(__isGroup AND __keep, row_number() OVER (PARTITION BY __isGroup, __keep ORDER BY ${groupOrderSql}), 0) AS __groupRank,
7561
+ countIf(__isGroup AND __keep) OVER () AS __survivingGroups
7562
+ FROM grouped
7563
+ )`, `expanded AS (
7564
+ SELECT *, maxIf(__groupRank, __isGroup) OVER (${groupPartition}) AS __rank
7565
+ FROM ranked
7566
+ )`);
7567
+ return {
7568
+ query: `WITH ${ctes.join(",\n")}
7569
+ SELECT ${[
7570
+ ...dimensionColumns,
7571
+ "bucket",
7572
+ ...measureNames,
7573
+ `toUInt8(__survivingGroups > ${maxRank}) AS truncated`
7574
+ ].join(", ")}
7575
+ FROM expanded
7576
+ WHERE NOT __isGroup AND __rank BETWEEN 1 AND ${maxRank}
7577
+ ORDER BY __rank ASC, bucket ASC`,
7578
+ query_params: parameters.params
7579
+ };
7580
+ }
7581
+ async function aggregateTraces(client, plan, timeoutMs) {
7582
+ const result = await runWithClickHouseTraceQueryTimeout(client, { timeoutMs }, compileClickHouseTraceAggregate(plan));
7583
+ let truncated;
7584
+ let resultRows;
7585
+ if (plan.interval === void 0) {
7586
+ truncated = result.length > plan.limit;
7587
+ resultRows = result.slice(0, plan.limit);
7588
+ } else {
7589
+ truncated = Number(result[0]?.truncated ?? 0) === 1;
7590
+ resultRows = result;
7591
+ }
7592
+ return {
7593
+ rows: resultRows.map((row) => {
7594
+ const shaped = { measures: Object.fromEntries(plan.measures.map((measure, index) => [measure.name, Number(row[`m${index}`])])) };
7595
+ if (plan.dimensions.length > 0) shaped.dimensions = Object.fromEntries(plan.dimensions.map((dimension, index) => [dimension, row[`d${index}`] ?? null]));
7596
+ if (plan.interval !== void 0) shaped.bucket = new Date(row.bucket).toISOString();
7597
+ return shaped;
7598
+ }),
7599
+ truncated
7600
+ };
7601
+ }
7602
+ //#endregion
7414
7603
  //#region src/storage/domains/observability/v-next/trace-roots.ts
7415
7604
  /**
7416
7605
  * Get the root span for a trace, reading from trace_roots as compatibility path.
@@ -8658,7 +8847,8 @@ var ObservabilityStorageClickhouseVNext = class extends ObservabilityStorage {
8658
8847
  "thread-query",
8659
8848
  "trace-query-tenant-scope",
8660
8849
  "feedback",
8661
- "trace-query-context-ids"
8850
+ "trace-query-context-ids",
8851
+ "trace-aggregate"
8662
8852
  ];
8663
8853
  return [
8664
8854
  "metrics",
@@ -8676,7 +8866,8 @@ var ObservabilityStorageClickhouseVNext = class extends ObservabilityStorage {
8676
8866
  "thread-query",
8677
8867
  "trace-query-tenant-scope",
8678
8868
  "feedback",
8679
- "trace-query-context-ids"
8869
+ "trace-query-context-ids",
8870
+ "trace-aggregate"
8680
8871
  ];
8681
8872
  }
8682
8873
  async createSpan(args) {
@@ -8803,6 +8994,18 @@ var ObservabilityStorageClickhouseVNext = class extends ObservabilityStorage {
8803
8994
  }, error);
8804
8995
  }
8805
8996
  }
8997
+ async aggregateTraces(plan) {
8998
+ try {
8999
+ return await aggregateTraces(this.#client, plan, this.#traceQueryTimeoutMs);
9000
+ } catch (error) {
9001
+ if (error instanceof MastraError || error instanceof coreStorage.TraceQueryExecutionError || error instanceof coreStorage.TraceQueryResourceLimitError) throw error;
9002
+ throw new MastraError({
9003
+ id: createStorageErrorId("CLICKHOUSE", "AGGREGATE_TRACES", "FAILED"),
9004
+ domain: ErrorDomain.STORAGE,
9005
+ category: ErrorCategory.THIRD_PARTY
9006
+ }, error);
9007
+ }
9008
+ }
8806
9009
  async getTraceQueryObservedFields(plan) {
8807
9010
  try {
8808
9011
  return await getTraceQueryObservedFields(this.#client, plan, this.#traceQueryDiscoveryLimits);