@mastra/duckdb 1.13.0-alpha.1 → 1.13.0-alpha.3

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.
@@ -3,7 +3,7 @@ name: mastra-duckdb
3
3
  description: Documentation for @mastra/duckdb. Use when working with @mastra/duckdb APIs, configuration, or implementation.
4
4
  metadata:
5
5
  package: "@mastra/duckdb"
6
- version: "1.13.0-alpha.1"
6
+ version: "1.13.0-alpha.3"
7
7
  ---
8
8
 
9
9
  ## When to use
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.13.0-alpha.1",
2
+ "version": "1.13.0-alpha.3",
3
3
  "package": "@mastra/duckdb",
4
4
  "exports": {},
5
5
  "modules": {}
package/dist/index.cjs CHANGED
@@ -556,7 +556,7 @@ var ObservabilityStorageDuckDB = class extends _mastra_core_storage.Observabilit
556
556
  async loadDelegate() {
557
557
  if (this.delegate) return this.delegate;
558
558
  if (this.unavailableError) return null;
559
- if (!this.loadPromise) this.loadPromise = Promise.resolve().then(() => require("./observability-DNuNWZFX.cjs")).then(({ ObservabilityStorageDuckDB }) => {
559
+ if (!this.loadPromise) this.loadPromise = Promise.resolve().then(() => require("./observability-DBm19qBS.cjs")).then(({ ObservabilityStorageDuckDB }) => {
560
560
  const delegate = new ObservabilityStorageDuckDB({ db: this.db });
561
561
  this.delegate = delegate;
562
562
  return delegate;
package/dist/index.js CHANGED
@@ -555,7 +555,7 @@ var ObservabilityStorageDuckDB = class extends ObservabilityStorage {
555
555
  async loadDelegate() {
556
556
  if (this.delegate) return this.delegate;
557
557
  if (this.unavailableError) return null;
558
- if (!this.loadPromise) this.loadPromise = import("./observability-Bk_y3hof.js").then(({ ObservabilityStorageDuckDB }) => {
558
+ if (!this.loadPromise) this.loadPromise = import("./observability-x50aDnBm.js").then(({ ObservabilityStorageDuckDB }) => {
559
559
  const delegate = new ObservabilityStorageDuckDB({ db: this.db });
560
560
  this.delegate = delegate;
561
561
  return delegate;
@@ -688,6 +688,33 @@ function jsonV(val) {
688
688
  return require_features.DuckDBConnection.sqlValue(JSON.stringify(val));
689
689
  }
690
690
  /**
691
+ * `span_events` JSON columns that can hold large payloads. Missing values are
692
+ * stored as the JSON literal `null` instead of SQL NULL.
693
+ *
694
+ * DuckDB only reads the selected rows of a large-string column when the
695
+ * column's validity mask is constant (no NULLs in the row group). Any SQL NULL
696
+ * makes it fall back to reading the whole 2,048-row vector, which loads every
697
+ * large string in it even when the query needs one row. Keeping these columns
698
+ * NULL-free lets point lookups and page reads load only what they return.
699
+ */
700
+ const SPAN_PAYLOAD_COLUMNS = [
701
+ "attributes",
702
+ "input",
703
+ "output",
704
+ "requestContext"
705
+ ];
706
+ /** Stored in {@link SPAN_PAYLOAD_COLUMNS} in place of SQL NULL; read back as missing. */
707
+ const JSON_NULL_PLACEHOLDER = "null";
708
+ /** Like {@link jsonV}, but writes the JSON `null` placeholder instead of SQL NULL. */
709
+ function payloadJsonV(val) {
710
+ if (val === null || val === void 0) return `'${JSON_NULL_PLACEHOLDER}'`;
711
+ return jsonV(val);
712
+ }
713
+ /** SQL for a payload column that maps the JSON `null` placeholder back to SQL NULL. */
714
+ function payloadColumnSql(column) {
715
+ return `NULLIF(${column}, '${JSON_NULL_PLACEHOLDER}')`;
716
+ }
717
+ /**
691
718
  * Trim, drop blank, and dedupe tags before they are written, matching the
692
719
  * PostgreSQL and ClickHouse stores so tag predicates and discovery agree.
693
720
  */
@@ -2935,7 +2962,7 @@ const TRACE_SELECT = `
2935
2962
  r.entityId AS entityId,
2936
2963
  r.parentSpanId AS parentSpanId,
2937
2964
  r.metadata AS metadata,
2938
- r.input AS input,
2965
+ ${payloadColumnSql("r.input")} AS input,
2939
2966
  r.threadId AS threadId,
2940
2967
  r.resourceId AS resourceId,
2941
2968
  r.startedAt AS startedAt,
@@ -3218,6 +3245,11 @@ function compileDuckDBTraceScope(relatedCollections, scope, timeRange, rootPredi
3218
3245
  GROUP BY r.traceId
3219
3246
  HAVING bool_or(r.timestamp >= CAST(? AS TIMESTAMP) AND r.timestamp < CAST(? AS TIMESTAMP))
3220
3247
  AND bool_or(${rowConditions.join("\n AND ")})
3248
+ )`,
3249
+ `root_bounds AS (
3250
+ SELECT min(timestamp) AS minTs, max(timestamp) AS maxTs
3251
+ FROM span_events
3252
+ WHERE parentSpanId IS NULL AND traceId IN (SELECT traceId FROM root_trace_ids)
3221
3253
  )`,
3222
3254
  `root_events AS (
3223
3255
  SELECT
@@ -3228,6 +3260,8 @@ function compileDuckDBTraceScope(relatedCollections, scope, timeRange, rootPredi
3228
3260
  END AS startedAt
3229
3261
  FROM span_events
3230
3262
  WHERE parentSpanId IS NULL
3263
+ AND timestamp >= (SELECT minTs FROM root_bounds)
3264
+ AND timestamp <= (SELECT maxTs FROM root_bounds)
3231
3265
  AND traceId IN (SELECT traceId FROM root_trace_ids)
3232
3266
  )`,
3233
3267
  `current_roots AS (
@@ -3250,7 +3284,15 @@ function compileDuckDBTraceScope(relatedCollections, scope, timeRange, rootPredi
3250
3284
  )`
3251
3285
  ];
3252
3286
  if (relatedCollections.has("spans")) {
3253
- ctes.push(`current_span_rows AS (
3287
+ ctes.push(`span_bounds AS (
3288
+ -- Exact event-time range of the in-scope traces, from a narrow
3289
+ -- (traceId, timestamp) pass. The join below cannot be pushed into the
3290
+ -- scan, so the range keeps it from decompressing every span row.
3291
+ SELECT min(timestamp) AS minTs, max(timestamp) AS maxTs
3292
+ FROM span_events
3293
+ WHERE traceId IN (SELECT traceId FROM root_scope)
3294
+ ),
3295
+ current_span_rows AS (
3254
3296
  SELECT
3255
3297
  e.*,
3256
3298
  coalesce(
@@ -3262,7 +3304,9 @@ function compileDuckDBTraceScope(relatedCollections, scope, timeRange, rootPredi
3262
3304
  ORDER BY CASE WHEN e.endedAt IS NULL THEN 1 ELSE 0 END ASC, e.cursorId DESC
3263
3305
  ) AS currentRank
3264
3306
  FROM span_events e
3265
- INNER JOIN root_scope roots ON roots.traceId = e.traceId${tenantWhere("e")}
3307
+ INNER JOIN root_scope roots ON roots.traceId = e.traceId
3308
+ WHERE e.timestamp >= (SELECT minTs FROM span_bounds)
3309
+ AND e.timestamp <= (SELECT maxTs FROM span_bounds)${tenantConditions("e").map((condition) => `\n AND ${condition}`).join("")}
3266
3310
  ),
3267
3311
  current_spans AS (
3268
3312
  SELECT
@@ -3812,7 +3856,7 @@ async function querySpans(db, plan) {
3812
3856
  return `(${key} AND cursorId IS NOT DISTINCT FROM ? AND endedAt = ?)`;
3813
3857
  }).join(" OR ");
3814
3858
  const payloads = await query(`SELECT DISTINCT ${identities},
3815
- substring(CAST(input AS VARCHAR), 1, ${_mastra_core_storage.SPAN_QUERY_MAX_PREVIEW_CHARACTERS + 1}) AS inputPreview, substring(CAST(output AS VARCHAR), 1, ${_mastra_core_storage.SPAN_QUERY_MAX_PREVIEW_CHARACTERS + 1}) AS outputPreview
3859
+ substring(CAST(${payloadColumnSql("input")} AS VARCHAR), 1, ${_mastra_core_storage.SPAN_QUERY_MAX_PREVIEW_CHARACTERS + 1}) AS inputPreview, substring(CAST(${payloadColumnSql("output")} AS VARCHAR), 1, ${_mastra_core_storage.SPAN_QUERY_MAX_PREVIEW_CHARACTERS + 1}) AS outputPreview
3816
3860
  FROM span_events WHERE ${payloadWhere}`, payloadValues);
3817
3861
  const metricValues = [];
3818
3862
  const metricWhere = keys(page, metricValues);
@@ -3855,18 +3899,7 @@ const COMPARISON_SQL = {
3855
3899
  gt: ">",
3856
3900
  gte: ">="
3857
3901
  };
3858
- /**
3859
- * Compiles a trusted aggregate plan. The plan's caps are enforced by the planner and trusted here.
3860
- *
3861
- * Every candidate root is first reduced to a `facts` row (dimension values, duration, error flag,
3862
- * countDistinct inputs, bucket) so grouping, measures, and joins only reference plain columns.
3863
- * Without `interval`, groups are filtered, ordered, and limited in one grouped select. With
3864
- * `interval`, a `ranked` CTE ranks groups on whole-window measures, then only the top `limit`
3865
- * groups are expanded into per-bucket rows.
3866
- *
3867
- * Parameters are positional, so `values` is appended in the order placeholders appear in the text.
3868
- */
3869
- function compileDuckDBTraceAggregate(plan) {
3902
+ function compileDuckDBTraceAggregate(plan, options = {}) {
3870
3903
  const { ctes, values } = compileDuckDBTraceCandidates(plan, "r.*");
3871
3904
  const dimensionSql = (field) => {
3872
3905
  if (field.startsWith("metadata.")) return {
@@ -3906,10 +3939,31 @@ function compileDuckDBTraceAggregate(plan) {
3906
3939
  if (intervalMs === void 0) throw new Error(`Unsupported trusted trace-aggregate interval: ${plan.interval}`);
3907
3940
  factColumns.push(`epoch_ms((epoch_ms(r.startedAt) // ${intervalMs}) * ${intervalMs}) AS bucket`);
3908
3941
  }
3909
- ctes.push(`facts AS (
3942
+ const measureKinds = new Set(plan.measures.map((measure) => measureRule(measure.name)?.kind));
3943
+ const hasCost = measureKinds.has("cost");
3944
+ if (!hasCost && !measureKinds.has("tokens")) ctes.push(`facts AS (
3910
3945
  SELECT ${factColumns.join(",\n ")}
3911
3946
  FROM candidates r
3912
3947
  )`);
3948
+ else {
3949
+ ctes.push(`base_facts AS (
3950
+ SELECT r.traceId, ${factColumns.join(",\n ")}
3951
+ FROM candidates r
3952
+ )`);
3953
+ ctes.push(...compileUsageCtes(plan, values, {
3954
+ hasCost,
3955
+ dedupe: !options.metricIdsUnique
3956
+ }));
3957
+ const usageColumns = USAGE_METRIC_NAMES.map((_, index) => `CASE WHEN u.traceId IS NOT NULL THEN COALESCE(u.t${index}, 0) END AS t${index}`);
3958
+ usageColumns.push(`u.traceId IS NOT NULL AS usageBearing`);
3959
+ if (hasCost) usageColumns.push(`CASE WHEN u.priced THEN u.cost END AS traceCost`, `COALESCE(u.priced AND NOT u.pricingFailure, FALSE) AS covered`, `u.unitMin AS unitMin`, `u.unitMax AS unitMax`);
3960
+ ctes.push(`facts AS (
3961
+ SELECT b.*,
3962
+ ${usageColumns.join(",\n ")}
3963
+ FROM base_facts b
3964
+ LEFT JOIN usage u ON u.traceId = b.traceId
3965
+ )`);
3966
+ }
3913
3967
  const measureSql = (name, alias = "") => {
3914
3968
  const column = (name) => `${alias}${name}`;
3915
3969
  if (name === "count") return "CAST(count(*) AS DOUBLE)";
@@ -3920,6 +3974,15 @@ function compileDuckDBTraceAggregate(plan) {
3920
3974
  if (name === "duration.max") return `max(${column("durationMs")})`;
3921
3975
  const percentile = PERCENTILES[name];
3922
3976
  if (percentile !== void 0) return `quantile_cont(${column("durationMs")}, ${percentile})`;
3977
+ const rule = measureRule(name);
3978
+ if (rule?.kind === "tokens" && rule.metricNames) {
3979
+ const value = rule.metricNames.map((metricName) => column(`t${usageMetricIndex(metricName)}`)).join(" + ");
3980
+ return `CAST(${rule.statistic === "avg" ? "avg" : "sum"}(${value}) AS DOUBLE)`;
3981
+ }
3982
+ if (rule?.kind === "cost") {
3983
+ const aggregate = rule.statistic === "avg" ? "avg" : "sum";
3984
+ return `CASE WHEN ${mixedUnitsSql(alias)} THEN NULL ELSE CAST(${aggregate}(${column("traceCost")}) AS DOUBLE) END`;
3985
+ }
3923
3986
  const measure = plan.measures.find((candidate) => candidate.type === "countDistinct" && candidate.name === name);
3924
3987
  const distinctColumn = measure && distinctColumns.get(measure.field);
3925
3988
  if (distinctColumn === void 0) throw new Error(`Unsupported trusted trace-aggregate measure: ${name}`);
@@ -3953,7 +4016,11 @@ function compileDuckDBTraceAggregate(plan) {
3953
4016
  const orderSql = [`${orderTarget} ${direction} NULLS LAST`, ...dimensionColumns.map((column) => `${column} ASC NULLS LAST`)].join(", ");
3954
4017
  const groupSql = dimensionColumns.length > 0 ? `\nGROUP BY ${dimensionColumns.join(", ")}` : "";
3955
4018
  if (plan.interval === void 0) {
3956
- const projected = [...dimensionColumns, ...plan.measures.map((measure, index) => `${measureSql(measure.name)} AS m${index}`)];
4019
+ const projected = [
4020
+ ...dimensionColumns,
4021
+ ...plan.measures.map((measure, index) => `${measureSql(measure.name)} AS m${index}`),
4022
+ ...hasCost ? costColumns("") : []
4023
+ ];
3957
4024
  values.push(...havingValues, plan.limit + 1);
3958
4025
  return {
3959
4026
  sql: `WITH ${ctes.join(",\n ")}
@@ -3978,6 +4045,7 @@ LIMIT ?`,
3978
4045
  ...dimensionColumns.map((column) => `f.${column}`),
3979
4046
  "f.bucket",
3980
4047
  ...plan.measures.map((measure, index) => `${measureSql(measure.name, "f.")} AS m${index}`),
4048
+ ...hasCost ? costColumns("f.") : [],
3981
4049
  `(SELECT count(*) FROM ranked) > ? AS truncated`
3982
4050
  ];
3983
4051
  values.push(plan.limit, plan.limit);
@@ -3996,8 +4064,97 @@ ORDER BY g.rank ASC, f.bucket ASC`,
3996
4064
  values
3997
4065
  };
3998
4066
  }
3999
- async function aggregateTraces(db, plan) {
4000
- const compiled = compileDuckDBTraceAggregate(plan);
4067
+ const USAGE_METRIC_NAMES = _mastra_core_storage.TRACE_AGGREGATE_USAGE_METRIC_NAMES;
4068
+ function measureRule(name) {
4069
+ return _mastra_core_storage.isTraceAggregateCanonicalMeasure(name) ? _mastra_core_storage.TRACE_AGGREGATE_MEASURE_REGISTRY[name] : void 0;
4070
+ }
4071
+ function usageMetricIndex(name) {
4072
+ const index = USAGE_METRIC_NAMES.indexOf(name);
4073
+ if (index === -1) throw new Error(`Unsupported trusted trace-aggregate token metric: ${name}`);
4074
+ return index;
4075
+ }
4076
+ /** More than one distinct priced unit in the group; NULL (treated as false) when nothing is priced. */
4077
+ function mixedUnitsSql(alias) {
4078
+ return `min(${alias}unitMin) <> max(${alias}unitMax)`;
4079
+ }
4080
+ /** Row cost fields, projected only for cost requests. */
4081
+ function costColumns(alias) {
4082
+ const mixed = _mastra_core_storage.TRACE_AGGREGATE_MIXED_COST_UNIT.replaceAll("'", "''");
4083
+ return [`CAST(count_if(${alias}covered) AS DOUBLE) / NULLIF(count_if(${alias}usageBearing), 0) AS costCoverage`, `CASE WHEN ${mixedUnitsSql(alias)} THEN '${mixed}' ELSE min(${alias}unitMin) END AS costUnit`];
4084
+ }
4085
+ /**
4086
+ * Builds `usage_bounds`, `usage_rows`, and `usage`: one row per candidate trace that has at least
4087
+ * one token metric row, holding per-name token sums (`t<i>`, indexed by
4088
+ * `TRACE_AGGREGATE_USAGE_METRIC_NAMES`) and, for cost requests, the trace's pricing state. Metric
4089
+ * rows are matched by traceId, usage metric name, tenant scope, and `timestamp >= from` only: a
4090
+ * trace that starts in the window can emit metrics after `to`, and the semi-join bounds the scan.
4091
+ *
4092
+ * DuckDB cannot push the `traceId IN (...)` semi-join into the table scan, so `usage_bounds`
4093
+ * first reads the exact timestamp range of the qualifying rows from narrow columns, and
4094
+ * `usage_rows` decompresses the wide columns only inside that range.
4095
+ *
4096
+ * Without the `metricId` primary key, retried copies are collapsed with `DISTINCT ON`; copies are
4097
+ * identical, so whichever survives is the same.
4098
+ *
4099
+ * Placeholders are bound in text order: `param` pushes each value as its placeholder is emitted.
4100
+ */
4101
+ function compileUsageCtes(plan, values, { hasCost, dedupe }) {
4102
+ const param = (value) => {
4103
+ values.push(value);
4104
+ return "?";
4105
+ };
4106
+ const list = (items) => items.map(param).join(", ");
4107
+ const scope = (alias) => {
4108
+ if (!plan.scope) return "";
4109
+ let sql = `\n AND ${alias}organizationId = ${param(plan.scope.organizationId)}`;
4110
+ if (plan.scope.resourceId !== void 0) sql += `\n AND ${alias}resourceId = ${param(plan.scope.resourceId)}`;
4111
+ return sql;
4112
+ };
4113
+ const costNames = _mastra_core_storage.TRACE_AGGREGATE_COST_METRIC_NAMES;
4114
+ const bounds = `usage_bounds AS (
4115
+ SELECT min(timestamp) AS minTs, max(timestamp) AS maxTs
4116
+ FROM metric_events
4117
+ WHERE traceId IN (SELECT traceId FROM base_facts)
4118
+ AND name IN (${list(USAGE_METRIC_NAMES)})
4119
+ AND timestamp >= CAST(${param(plan.timeRange.from)} AS TIMESTAMP)${scope("")}
4120
+ )`;
4121
+ const costSelect = hasCost ? `,
4122
+ m.estimatedCost,
4123
+ m.costUnit,
4124
+ m.name IN (${list(costNames)}) AS costRow,
4125
+ CASE WHEN m.name IN (${list(costNames)})
4126
+ THEN COALESCE(json_type(m.costMetadata, '$.error'), 'NULL') <> 'NULL'
4127
+ ELSE FALSE
4128
+ END AS hasError` : "";
4129
+ const rows = `usage_rows AS (
4130
+ SELECT ${dedupe ? "DISTINCT ON (m.metricId) " : ""}m.traceId, m.name, m.value${costSelect}
4131
+ FROM metric_events m
4132
+ WHERE m.timestamp >= (SELECT minTs FROM usage_bounds)
4133
+ AND m.timestamp <= (SELECT maxTs FROM usage_bounds)
4134
+ AND m.traceId IN (SELECT traceId FROM base_facts)
4135
+ AND m.name IN (${list(USAGE_METRIC_NAMES)})${scope("m.")}
4136
+ )`;
4137
+ const columns = USAGE_METRIC_NAMES.map((name) => `sum(value) FILTER (WHERE name = ${param(name)})`).map((sql, index) => `${sql} AS t${index}`);
4138
+ if (hasCost) {
4139
+ const priced = "costRow AND estimatedCost IS NOT NULL AND costUnit IS NOT NULL AND NOT hasError";
4140
+ columns.push(`sum(estimatedCost) FILTER (WHERE ${priced}) AS cost`, `bool_or(${priced}) AS priced`, `bool_or(costRow AND (hasError OR (estimatedCost IS NOT NULL AND costUnit IS NULL))) AS pricingFailure`, `min(costUnit) FILTER (WHERE ${priced}) AS unitMin`, `max(costUnit) FILTER (WHERE ${priced}) AS unitMax`);
4141
+ }
4142
+ return [
4143
+ bounds,
4144
+ rows,
4145
+ `usage AS (
4146
+ SELECT traceId,
4147
+ ${columns.join(",\n ")}
4148
+ FROM usage_rows
4149
+ GROUP BY traceId
4150
+ )`
4151
+ ];
4152
+ }
4153
+ function nullableNumber(value) {
4154
+ return value === null || value === void 0 ? null : Number(value);
4155
+ }
4156
+ async function aggregateTraces(db, plan, options = {}) {
4157
+ const compiled = compileDuckDBTraceAggregate(plan, options);
4001
4158
  let result;
4002
4159
  try {
4003
4160
  result = await db.query(compiled.sql, compiled.values);
@@ -4016,7 +4173,11 @@ async function aggregateTraces(db, plan) {
4016
4173
  }
4017
4174
  return {
4018
4175
  rows: resultRows.map((row) => {
4019
- const shaped = { measures: Object.fromEntries(plan.measures.map((measure, index) => [measure.name, Number(row[`m${index}`])])) };
4176
+ const shaped = { measures: Object.fromEntries(plan.measures.map((measure, index) => [measure.name, nullableNumber(row[`m${index}`])])) };
4177
+ if ("costCoverage" in row) shaped.cost = {
4178
+ coverage: nullableNumber(row.costCoverage),
4179
+ unit: row.costUnit ?? null
4180
+ };
4020
4181
  if (plan.dimensions.length > 0) shaped.dimensions = Object.fromEntries(plan.dimensions.map((dimension, index) => {
4021
4182
  const value = row[`d${index}`];
4022
4183
  return [dimension, value == null ? null : String(value)];
@@ -4067,13 +4228,15 @@ const COLUMNS_SQL = [
4067
4228
  "error",
4068
4229
  "requestContext"
4069
4230
  ].join(", ");
4231
+ const PAYLOAD_COLUMN_SET = new Set(SPAN_PAYLOAD_COLUMNS);
4070
4232
  /**
4071
4233
  * Reconstruction query uses `arg_max(field, timestamp) FILTER (WHERE field IS NOT NULL)`
4072
4234
  * so that the final end event supplies the terminal span fields without wiping
4073
- * stable values emitted on the start event.
4235
+ * stable values emitted on the start event. Payload columns also skip the JSON
4236
+ * `null` placeholder, which stands in for a missing value.
4074
4237
  */
4075
4238
  function argMaxNonNull(col) {
4076
- return `arg_max(${col}, timestamp) FILTER (WHERE ${col} IS NOT NULL) as ${col}`;
4239
+ return `arg_max(${col}, timestamp) FILTER (WHERE ${col} IS NOT NULL${PAYLOAD_COLUMN_SET.has(col) ? ` AND ${col} <> '${JSON_NULL_PLACEHOLDER}'` : ""}) as ${col}`;
4077
4240
  }
4078
4241
  const SPAN_RECONSTRUCT_SELECT = `
4079
4242
  SELECT
@@ -4184,28 +4347,55 @@ function buildPostAggReconstructSelect(postAgg, orderByField) {
4184
4347
  `;
4185
4348
  }
4186
4349
  /**
4187
- * Reconstruct spans for the `(traceId, spanId)` pairs selected by `anchorCte`,
4188
- * scanning only events at/after the CTE's earliest `anchorStartedAt`.
4350
+ * Reconstruct the spans of one list page, reading payload columns only for
4351
+ * the page's own events.
4352
+ *
4353
+ * Two queries:
4354
+ * 1. `anchorSql` (a `WITH ...` prefix defining `anchorCte`) picks the page's
4355
+ * `(traceId, spanId)` anchors, and a narrow pass over `traceId`, `spanId`
4356
+ * and `timestamp` finds the exact time range of their events.
4357
+ * 2. Reconstruction scans only that range for exactly those anchors.
4189
4358
  *
4190
- * The `(traceId, spanId) IN (subquery)` semi-join alone cannot be pushed into
4191
- * the table scan, so without a bound DuckDB decompresses every column of the
4192
- * entire table just to emit one page of spans. The time bound is a plain range
4193
- * predicate that zone maps can prune on (insertion order tracks event time).
4359
+ * The `(traceId, spanId) IN (...)` semi-join cannot be pushed into the table
4360
+ * scan, so without a range DuckDB decompresses every payload column of every
4361
+ * row it reads. A lower bound alone (the earliest anchor) prunes nothing for
4362
+ * the oldest pages, which then read the whole table and can run out of memory.
4363
+ * Both bounds are passed as literal parameters so they always reach the scan
4364
+ * as plain range filters that zone maps can prune on.
4194
4365
  *
4195
- * Correctness: span events are only 'start' (timestamp = startedAt) and 'end'
4196
- * (timestamp = endedAt >= startedAt), so every event of an anchored span has
4197
- * timestamp >= its start-row timestamp >= min(anchorStartedAt). An empty
4198
- * anchor set makes the bound NULL, which matches the empty IN-list result.
4366
+ * Anchors and bounds are frozen after the first query, so spans written in
4367
+ * between cannot change which rows are on the page. An event a page span
4368
+ * receives in between is left for the next read, as if the page had been
4369
+ * read a moment earlier.
4199
4370
  */
4200
- function reconstructForAnchors(reconstructSelect, anchorCte) {
4201
- return `
4202
- ${reconstructSelect}
4203
- WHERE timestamp >= (SELECT min(anchorStartedAt) FROM ${anchorCte})
4204
- AND (traceId, spanId) IN (SELECT traceId, spanId FROM ${anchorCte})
4205
- GROUP BY traceId, spanId`;
4371
+ async function reconstructAnchoredPage(db, anchorSql, anchorCte, params, reconstructSelect, orderBySql) {
4372
+ const anchors = await db.query(`
4373
+ ${anchorSql}
4374
+ SELECT anchors.traceId, anchors.spanId, bounds.minTs, bounds.maxTs
4375
+ FROM ${anchorCte} AS anchors,
4376
+ (
4377
+ SELECT min(timestamp) AS minTs, max(timestamp) AS maxTs
4378
+ FROM span_events
4379
+ WHERE (traceId, spanId) IN (SELECT traceId, spanId FROM ${anchorCte})
4380
+ ) AS bounds
4381
+ `, params);
4382
+ if (anchors.length === 0) return [];
4383
+ const { minTs, maxTs } = anchors[0];
4384
+ const anchorValues = anchors.map(() => "(?, ?)").join(", ");
4385
+ return db.query(`
4386
+ ${reconstructSelect}
4387
+ WHERE timestamp >= ? AND timestamp <= ?
4388
+ AND (traceId, spanId) IN (SELECT * FROM (VALUES ${anchorValues}))
4389
+ GROUP BY traceId, spanId
4390
+ ${orderBySql}
4391
+ `, [
4392
+ minTs,
4393
+ maxTs,
4394
+ ...anchors.flatMap((anchor) => [anchor.traceId, anchor.spanId])
4395
+ ]);
4206
4396
  }
4207
4397
  /**
4208
- * Same time-bound trick as {@link reconstructForAnchors}, but with the bound
4398
+ * Same time-bound trick as {@link reconstructAnchoredPage}, but with the bound
4209
4399
  * precomputed in JS and passed as a `?` parameter. The delta-poll query shape
4210
4400
  * references its candidate CTE multiple times, which makes DuckDB materialize
4211
4401
  * it and lose the dynamic-filter pushdown a scalar subquery bound relies on —
@@ -4439,15 +4629,15 @@ function toValuesTuple(row) {
4439
4629
  v(row.environment),
4440
4630
  v(row.source),
4441
4631
  v(row.serviceName),
4442
- jsonV(row.attributes),
4632
+ payloadJsonV(row.attributes),
4443
4633
  jsonV(row.metadata),
4444
4634
  jsonV(row.tags),
4445
4635
  jsonV(row.scope),
4446
4636
  jsonV(row.links),
4447
- jsonV(row.input),
4448
- jsonV(row.output),
4637
+ payloadJsonV(row.input),
4638
+ payloadJsonV(row.output),
4449
4639
  jsonV(row.error),
4450
- jsonV(row.requestContext)
4640
+ payloadJsonV(row.requestContext)
4451
4641
  ].join(", ");
4452
4642
  }
4453
4643
  async function insertSpanEvents(db, rows) {
@@ -4661,7 +4851,7 @@ async function listTraceRows(db, args, reconstructSelect, mapRow, toSpans) {
4661
4851
  },
4662
4852
  spans: []
4663
4853
  };
4664
- const pageSql = `
4854
+ const spans = (await reconstructAnchoredPage(db, `
4665
4855
  WITH page_roots AS (
4666
4856
  SELECT traceId, spanId, min(timestamp) AS anchorStartedAt
4667
4857
  FROM span_events AS ${outerAlias}
@@ -4670,14 +4860,11 @@ async function listTraceRows(db, args, reconstructSelect, mapRow, toSpans) {
4670
4860
  ${prefilterOrderBy}
4671
4861
  LIMIT ? OFFSET ?
4672
4862
  )
4673
- ${reconstructForAnchors(reconstructSelect, "page_roots")}
4674
- ${buildOrderByClause(orderBy)}, traceId, spanId
4675
- `;
4676
- const spans = (await db.query(pageSql, [
4863
+ `, "page_roots", [
4677
4864
  ...prefilterParams,
4678
4865
  perPage,
4679
4866
  offset
4680
- ])).map((row) => mapRow(row));
4867
+ ], reconstructSelect, `${buildOrderByClause(orderBy)}, traceId, spanId`)).map((row) => mapRow(row));
4681
4868
  return {
4682
4869
  pagination: {
4683
4870
  total,
@@ -4726,20 +4913,17 @@ async function listTraceRows(db, args, reconstructSelect, mapRow, toSpans) {
4726
4913
  },
4727
4914
  spans: []
4728
4915
  };
4729
- const dataSql = `
4916
+ const spans = (await reconstructAnchoredPage(db, `
4730
4917
  ${cteSql},
4731
4918
  page_roots AS (
4732
4919
  SELECT traceId, spanId, startedAt AS anchorStartedAt
4733
4920
  FROM root_spans ${postAggWhere} ${orderByClause} ${paginationClause}
4734
4921
  )
4735
- ${reconstructForAnchors(reconstructSelect, "page_roots")}
4736
- ${orderByClause}
4737
- `;
4738
- const spans = (await db.query(dataSql, [
4922
+ `, "page_roots", [
4739
4923
  ...prefilterParams,
4740
4924
  ...postAggParams,
4741
4925
  ...paginationParams
4742
- ])).map((row) => mapRow(row));
4926
+ ], reconstructSelect, orderByClause)).map((row) => mapRow(row));
4743
4927
  return {
4744
4928
  pagination: {
4745
4929
  total,
@@ -5043,7 +5227,7 @@ async function listBranches(db, args) {
5043
5227
  branches: [],
5044
5228
  ...deltaPollingFeatureEnabled() ? { deltaCursor: currentDeltaCursor } : {}
5045
5229
  };
5046
- const pageSql = `
5230
+ const spans = (await reconstructAnchoredPage(db, `
5047
5231
  WITH page_anchors AS (
5048
5232
  SELECT traceId, spanId, min(timestamp) AS anchorStartedAt
5049
5233
  FROM span_events AS ${outerAlias}
@@ -5052,14 +5236,11 @@ async function listBranches(db, args) {
5052
5236
  ${prefilterOrderBy}
5053
5237
  LIMIT ? OFFSET ?
5054
5238
  )
5055
- ${reconstructForAnchors(SPAN_RECONSTRUCT_SELECT, "page_anchors")}
5056
- ${buildOrderByClause(orderBy)}, traceId, spanId
5057
- `;
5058
- const spans = (await db.query(pageSql, [
5239
+ `, "page_anchors", [
5059
5240
  ...prefilterParams,
5060
5241
  perPage,
5061
5242
  offset
5062
- ])).map((row) => rowToSpanRecord(row));
5243
+ ], SPAN_RECONSTRUCT_SELECT, `${buildOrderByClause(orderBy)}, traceId, spanId`)).map((row) => rowToSpanRecord(row));
5063
5244
  return {
5064
5245
  pagination: {
5065
5246
  total,
@@ -5106,20 +5287,17 @@ async function listBranches(db, args) {
5106
5287
  branches: [],
5107
5288
  ...deltaPollingFeatureEnabled() ? { deltaCursor: currentDeltaCursor } : {}
5108
5289
  };
5109
- const dataSql = `
5290
+ const spans = (await reconstructAnchoredPage(db, `
5110
5291
  ${cteSql},
5111
5292
  page_anchors AS (
5112
5293
  SELECT traceId, spanId, startedAt AS anchorStartedAt
5113
5294
  FROM branch_anchors ${postAggWhere} ${orderByClause} ${paginationClause}
5114
5295
  )
5115
- ${reconstructForAnchors(SPAN_RECONSTRUCT_SELECT, "page_anchors")}
5116
- ${orderByClause}
5117
- `;
5118
- const spans = (await db.query(dataSql, [
5296
+ `, "page_anchors", [
5119
5297
  ...prefilterParams,
5120
5298
  ...postAggParams,
5121
5299
  ...paginationParams
5122
- ])).map((row) => rowToSpanRecord(row));
5300
+ ], SPAN_RECONSTRUCT_SELECT, orderByClause)).map((row) => rowToSpanRecord(row));
5123
5301
  return {
5124
5302
  pagination: {
5125
5303
  total,
@@ -5270,6 +5448,8 @@ var ObservabilityStorageDuckDB = class ObservabilityStorageDuckDB extends _mastr
5270
5448
  indexed: false
5271
5449
  }
5272
5450
  };
5451
+ /** Set by `init()`; until then aggregate queries dedupe metric rows on `metricId`. */
5452
+ metricIdsUnique = false;
5273
5453
  db;
5274
5454
  constructor(config) {
5275
5455
  super();
@@ -5306,6 +5486,7 @@ var ObservabilityStorageDuckDB = class ObservabilityStorageDuckDB extends _mastr
5306
5486
  });
5307
5487
  await this.db.executeBatch([...ALL_DDL, ...ALL_MIGRATIONS]);
5308
5488
  await dropLegacyCursorIdDefaults(this.db);
5489
+ this.metricIdsUnique = await hasPrimaryKey(this.db, "metric_events");
5309
5490
  }
5310
5491
  /**
5311
5492
  * Manually migrate legacy signal tables to the signal-ID primary-key schema.
@@ -5419,7 +5600,7 @@ var ObservabilityStorageDuckDB = class ObservabilityStorageDuckDB extends _mastr
5419
5600
  return queryTraces(this.db, plan);
5420
5601
  }
5421
5602
  async aggregateTraces(plan) {
5422
- return aggregateTraces(this.db, plan);
5603
+ return aggregateTraces(this.db, plan, { metricIdsUnique: this.metricIdsUnique });
5423
5604
  }
5424
5605
  async getTraceQueryObservedFields(plan) {
5425
5606
  return getTraceQueryObservedFields(this.db, plan);
@@ -5543,4 +5724,4 @@ var ObservabilityStorageDuckDB = class ObservabilityStorageDuckDB extends _mastr
5543
5724
  //#endregion
5544
5725
  exports.ObservabilityStorageDuckDB = ObservabilityStorageDuckDB;
5545
5726
 
5546
- //# sourceMappingURL=observability-DNuNWZFX.cjs.map
5727
+ //# sourceMappingURL=observability-DBm19qBS.cjs.map