@lunora/do 1.0.0-alpha.33 → 1.0.0-alpha.35

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.mjs CHANGED
@@ -3,6 +3,7 @@ export { AGGREGATE_SQL_FUNCTION, aggregateSqlFunction, matchesStaticWhere, norma
3
3
  export { aggregateTableName, coerceAggregateNumber, encodeAggregateKey, foldAggregateTally, readAggregateValue } from './packem_shared/aggregateTableName-CxNqY1Sl.mjs';
4
4
  export { CountRlsUnsupportedError, mergeWhere, planAggregateLookup, selectIndexForAggregate, selectIndexForCount, selectIndexForGroupBy } from './packem_shared/CountRlsUnsupportedError-BGxj0pgS.mjs';
5
5
  export { AUTH_METRICS_BUCKETS_TABLE, AUTH_METRICS_BUCKET_MS, AUTH_METRICS_BUCKET_RETENTION, AUTH_METRICS_TABLE, ensureAuthMetricsTables, readAuthMetrics, recordAuthEvent } from './packem_shared/AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
6
+ export { c as createMetrics, a as createTracer, d as dispatchRootSpan } from './packem_shared/context-telemetry-DWfYDxCS.mjs';
6
7
  export { NotUniqueError, assertValidClientId, createShardCtxDb, normalizeIdStructurally } from './packem_shared/NotUniqueError-Ca21uDuU.mjs';
7
8
  export { DATA_MIGRATION_STATE_TABLE, readMigrationStatus, runDataMigration } from './packem_shared/DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs';
8
9
  export { SCAN_DEP, createDependencyTracker, depKey } from './packem_shared/SCAN_DEP-DLJF8dsj.mjs';
@@ -11,7 +12,7 @@ export { diffExternalSource } from './packem_shared/diffExternalSource-CovHfdyo.
11
12
  export { materializeExternalRows, materializeExternalRowsIncremental, readExternalSourceBaseline, runExternalSourceTick } from './packem_shared/materializeExternalRows-DlQWMlw_.mjs';
12
13
  export { isSoftDeleted, isSourceDue, liftSourceId, pullExternalSourceIncrementalTick, pullExternalSourceTick } from './packem_shared/isSoftDeleted-YLKR6JYw.mjs';
13
14
  export { FUNCTION_METRICS_BUCKETS_TABLE, FUNCTION_METRICS_BUCKET_MS, FUNCTION_METRICS_BUCKET_RETENTION, FUNCTION_METRICS_INDEX_TABLE, FUNCTION_METRICS_TABLE, ensureFunctionMetricsTables, readFunctionMetricBuckets, readFunctionMetricIndexHits, readFunctionMetrics, readFunctionMetricsTotals, recordFunctionMetric } from './packem_shared/FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
14
- export { ADMIN_FUNCTIONS, ADMIN_FUNCTION_PREFIX, FLAGS_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, facetColumn, listTables, readTablePage, selectMatchingIds } from './packem_shared/ADMIN_FUNCTIONS-CAHLZMj8.mjs';
15
+ export { ADMIN_FUNCTIONS, ADMIN_FUNCTION_PREFIX, FLAGS_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, facetColumn, listTables, readTablePage, selectMatchingIds } from './packem_shared/ADMIN_FUNCTIONS-CqVmUVNs.mjs';
15
16
  export { LogBuffer } from './packem_shared/LogBuffer-B_Ezju_N.mjs';
16
17
  export { MAIL_RETENTION, MAIL_TABLE, clearCapturedMail, ensureMailTable, readCapturedMail, recordCapturedMail } from './packem_shared/MAIL_RETENTION-CPpgl-dX.mjs';
17
18
  export { default as NotFoundError } from './packem_shared/NotFoundError-C70b9hLw.mjs';
@@ -19,14 +20,14 @@ export { armRestore, readBookmark } from './packem_shared/armRestore-4Px61hHS.mj
19
20
  export { applySelect, buildSeekWhere, decodeCursor, encodeCursor, normalizeOrderKeys, softDeleteScope } from './packem_shared/applySelect-WQY8m62C.mjs';
20
21
  export { RANK_TIEBREAK, encodePartitionKey, matchesRankStaticWhere, rankTableName, resolveRankPartition, sortColumnName } from './packem_shared/RANK_TIEBREAK-CXhdcA1o.mjs';
21
22
  export { ReactiveCache, reactiveCacheKey } from './packem_shared/ReactiveCache-DnSvbjil.mjs';
22
- export { serveRelationFanout } from './packem_shared/serveRelationFanout-BgaNg3Hu.mjs';
23
+ export { serveRelationFanout } from './packem_shared/serveRelationFanout-Yiyx2OQB.mjs';
23
24
  export { DEFAULT_MAX_RELATION_KEYS, assertFlatPredicate, assertShapeShardable, containsRelationPredicate, isRelationPredicate, resolveRelationPredicates } from './packem_shared/DEFAULT_MAX_RELATION_KEYS-BEan1CRD.mjs';
24
25
  export { applyOnDelete, fanOutScalarCounts, resolveWith, runRowValidators } from './packem_shared/applyOnDelete-BXSq3S70.mjs';
25
26
  export { RLS_UNWRAP_SYMBOL, RlsRequiredError, guardWriter } from './packem_shared/RLS_UNWRAP_SYMBOL-DTvHvRzY.mjs';
26
27
  export { buildFtsMatch, ftsTableName, scoreDocument, stringifySearchText, tokenizeSearch } from './packem_shared/buildFtsMatch-BLEMawrp.mjs';
27
28
  export { M as MIN_ADMIN_TOKEN_LENGTH, a as MIN_AUTH_SECRET_LENGTH, b as buildSecurityAudit } from './packem_shared/security-audit-CucgBice.mjs';
28
29
  export { SESSION_DO_TTL_DEFAULT, SessionDO } from './packem_shared/SESSION_DO_TTL_DEFAULT-BnSKgVO4.mjs';
29
- export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-afQHUhyA.mjs';
30
+ export { ROOT_DO_SIZE_WARN_BYTES, ROOT_SHARD_NAME, ShardDO } from './packem_shared/ROOT_DO_SIZE_WARN_BYTES-QSOI2hpe.mjs';
30
31
  export { SHARD_REGISTRY_DO_NAME, ShardRegistryDO } from './packem_shared/SHARD_REGISTRY_DO_NAME-D99roc-r.mjs';
31
32
  export { MAX_SQL_ROWS, assertReadonly, runReadonlySql } from './packem_shared/MAX_SQL_ROWS-iFAA8FbD.mjs';
32
33
  export { createSystemReader } from './packem_shared/createSystemReader-D12eNH13.mjs';
@@ -24,6 +24,7 @@ const ADMIN_FUNCTIONS = {
24
24
  getFanoutMetrics: "__lunora_admin__:getFanoutMetrics",
25
25
  getFunctionStats: "__lunora_admin__:getFunctionStats",
26
26
  getIssues: "__lunora_admin__:getIssues",
27
+ getMetricSeries: "__lunora_admin__:getMetricSeries",
27
28
  listSubscriptions: "__lunora_admin__:listSubscriptions",
28
29
  listTableIndexes: "__lunora_admin__:listTableIndexes",
29
30
  getLogs: "__lunora_admin__:getLogs",
@@ -33,6 +34,7 @@ const ADMIN_FUNCTIONS = {
33
34
  getRequestLog: "__lunora_admin__:getRequestLog",
34
35
  getSecurityAudit: "__lunora_admin__:getSecurityAudit",
35
36
  getSettings: "__lunora_admin__:getSettings",
37
+ getTraces: "__lunora_admin__:getTraces",
36
38
  // eslint-disable-next-line no-secrets/no-secrets -- reserved admin RPC path constant, not a credential
37
39
  getWorkflowInstanceStatus: "__lunora_admin__:getWorkflowInstanceStatus",
38
40
  importShard: "__lunora_admin__:importShard",
@@ -5,12 +5,14 @@ import { j as jsonResponse } from './json-response-BdbtpOhm.mjs';
5
5
  import { e as encodeWire, d as decodeWire } from './wire-codec-CzQc1pvf.mjs';
6
6
  import { parseExportShardArgs, parseImportShardArgs } from './exportShardRows-Dy3oFZ26.mjs';
7
7
  import { recordAuthEvent, readAuthMetrics } from './AUTH_METRICS_BUCKETS_TABLE-CiHHYeJi.mjs';
8
+ import { n as normalizeLogFields, r as resolveTraceAnchor, a as createTracer, c as createMetrics, d as dispatchRootSpan } from './context-telemetry-DWfYDxCS.mjs';
8
9
  import { DATA_MIGRATION_STATE_TABLE, readMigrationStatus } from './DATA_MIGRATION_STATE_TABLE-CYwBpyTr.mjs';
9
10
  import { SCAN_DEP, createDependencyTracker, tableFromDepKey } from './SCAN_DEP-DLJF8dsj.mjs';
10
11
  import { readFunctionMetricsTotals, readFunctionMetricIndexHits, recordFunctionMetric, mergeScanAttribution, readFunctionMetrics, readFunctionMetricBuckets } from './FUNCTION_METRICS_BUCKETS_TABLE-UDNVD7FS.mjs';
11
- import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, summarizeFanoutTopics, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, recordFanoutPass, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-CAHLZMj8.mjs';
12
+ import { createFanoutCounters, ADMIN_FUNCTION_PREFIX, RELATION_FUNCTION_PREFIX, selectMatchingIds, ADMIN_FUNCTIONS, findStorageReferences, listTables, summarizeSubscriptions, summarizeFanoutTopics, readTablePage, facetColumn, FLAGS_FUNCTION_PREFIX, recordFanoutPass, MAX_PAGE_SIZE } from './ADMIN_FUNCTIONS-CqVmUVNs.mjs';
12
13
  import { LogBuffer } from './LogBuffer-B_Ezju_N.mjs';
13
14
  import { recordCapturedMail, clearCapturedMail, readCapturedMail, MAIL_TABLE } from './MAIL_RETENTION-CPpgl-dX.mjs';
15
+ import { stableStringify } from './stableStringify-mC40mZts.mjs';
14
16
  import { readBookmark, armRestore } from './armRestore-4Px61hHS.mjs';
15
17
  import { ReactiveCache, reactiveCacheKey } from './ReactiveCache-DnSvbjil.mjs';
16
18
  import { stableWireKey } from './stableWireKey-DKuXO7T5.mjs';
@@ -22,7 +24,6 @@ import { runReadonlySql } from './MAX_SQL_ROWS-iFAA8FbD.mjs';
22
24
  import { ConflictError } from './ConflictError-CLoq37xH.mjs';
23
25
  import { e as deleteGlobalShapeSnapshotsForConnection, g as readIdempotent, h as writeIdempotent, t as trimIdempotent, r as readClientWatermark, m as migrateClientWatermark, c as advanceClientWatermark, d as deleteGlobalShapeSnapshot, f as readGlobalShapeSnapshot, w as writeGlobalShapeSnapshot } from './ctx-db-idempotency-BdcNpvY4.mjs';
24
26
  import { CDC_LOG_TABLE, readCdcChanges, readCdcCursor, readCdcEpoch, minCdcSeq, bumpCdcEpoch } from './CDC_LOG_TABLE-uwOJxJJZ.mjs';
25
- import { stableStringify } from './stableStringify-mC40mZts.mjs';
26
27
  import { a as selectShapeMemberIds, s as selectShapeRows } from './ctx-db-shapes-Cz9dHyh1.mjs';
27
28
 
28
29
  const MAX_BATCH_ENTRIES = 500;
@@ -182,6 +183,71 @@ const buildBatchEntryRequest = (batchRequest, entry) => {
182
183
  });
183
184
  };
184
185
 
186
+ const DEFAULT_CAPACITY$1 = 256;
187
+ const seriesKey = (event) => `${event.kind}\0${event.name}\0${stableStringify(event.attributes ?? {})}`;
188
+ class MetricBuffer {
189
+ capacity;
190
+ series = /* @__PURE__ */ new Map();
191
+ constructor(capacity = DEFAULT_CAPACITY$1) {
192
+ this.capacity = capacity > 0 ? Math.trunc(capacity) : DEFAULT_CAPACITY$1;
193
+ }
194
+ /** Number of distinct series currently aggregated. */
195
+ get size() {
196
+ return this.series.size;
197
+ }
198
+ /** Drop every aggregated series. */
199
+ clear() {
200
+ this.series.clear();
201
+ }
202
+ /**
203
+ * Snapshot of the aggregated series, most-recently-updated first, each a fresh
204
+ * copy so a caller can't mutate the live aggregate. `series` is kept in
205
+ * update order (tail = newest), so one reverse yields newest-first.
206
+ */
207
+ entries() {
208
+ return [...this.series.values()].toReversed().map((s) => {
209
+ return { ...s };
210
+ });
211
+ }
212
+ /** Fold one measurement into its series, creating or updating the aggregate. */
213
+ push(event) {
214
+ const key = seriesKey(event);
215
+ const existing = this.series.get(key);
216
+ if (existing === void 0) {
217
+ if (this.series.size >= this.capacity) {
218
+ const oldest = this.series.keys().next().value;
219
+ if (oldest !== void 0) {
220
+ this.series.delete(oldest);
221
+ }
222
+ }
223
+ this.series.set(key, {
224
+ ...event.attributes === void 0 ? {} : { attributes: event.attributes },
225
+ count: 1,
226
+ firstTs: event.ts,
227
+ functionPath: event.functionPath,
228
+ kind: event.kind,
229
+ last: event.value,
230
+ lastTs: event.ts,
231
+ max: event.value,
232
+ min: event.value,
233
+ name: event.name,
234
+ ...event.shardKey === void 0 ? {} : { shardKey: event.shardKey },
235
+ sum: event.value
236
+ });
237
+ return;
238
+ }
239
+ this.series.delete(key);
240
+ existing.count += 1;
241
+ existing.sum += event.value;
242
+ existing.min = Math.min(existing.min, event.value);
243
+ existing.max = Math.max(existing.max, event.value);
244
+ existing.last = event.value;
245
+ existing.lastTs = event.ts;
246
+ existing.functionPath = event.functionPath;
247
+ this.series.set(key, existing);
248
+ }
249
+ }
250
+
185
251
  const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
186
252
  const QUERY_METRICS_MAX_SQL_LEN = 512;
187
253
  const QUERY_METRICS_MAX_STATEMENTS = 500;
@@ -1271,18 +1337,40 @@ const renderLogMessage = (args) => args.map((value) => {
1271
1337
  return String(value);
1272
1338
  }
1273
1339
  }).join(" ");
1340
+ const isLogFields = (value) => {
1341
+ if (typeof value !== "object" || value === null || Array.isArray(value)) {
1342
+ return false;
1343
+ }
1344
+ const proto = Object.getPrototypeOf(value);
1345
+ return proto === Object.prototype || proto === null;
1346
+ };
1347
+ const parseLogArgs = (args, boundFields) => {
1348
+ if (args.length === 2 && typeof args[0] === "string" && isLogFields(args[1])) {
1349
+ return { fields: normalizeLogFields(args[1], boundFields), message: args[0] };
1350
+ }
1351
+ return { fields: normalizeLogFields(void 0, boundFields), message: renderLogMessage(args) };
1352
+ };
1274
1353
  const emitLogEvent = (input) => {
1275
- const line = JSON.stringify({
1354
+ const payload = {
1355
+ fields: input.fields,
1276
1356
  function: input.functionPath,
1277
1357
  level: input.level,
1278
1358
  message: input.message,
1279
1359
  shard: input.shardKey,
1280
1360
  source: REQUEST_LOG_EVENT_SOURCE,
1361
+ spanId: input.spanId,
1362
+ traceId: input.traceId,
1281
1363
  ts: input.ts,
1282
1364
  type: LOG_EVENT_TYPE,
1283
1365
  userId: input.userId
1284
- });
1285
- if (input.level === "error") {
1366
+ };
1367
+ let line;
1368
+ try {
1369
+ line = JSON.stringify(payload);
1370
+ } catch {
1371
+ line = JSON.stringify({ ...payload, fields: void 0 });
1372
+ }
1373
+ if (input.level === "error" || input.level === "fatal") {
1286
1374
  console.error(line);
1287
1375
  } else if (input.level === "warn") {
1288
1376
  console.warn(line);
@@ -1428,6 +1516,136 @@ const runSocketPool = async (items, processOne, concurrency = 8) => {
1428
1516
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
1429
1517
  };
1430
1518
 
1519
+ const DEFAULT_CAPACITY = 500;
1520
+ class SpanBuffer {
1521
+ buffer = [];
1522
+ capacity;
1523
+ constructor(capacity = DEFAULT_CAPACITY) {
1524
+ this.capacity = capacity > 0 ? Math.trunc(capacity) : DEFAULT_CAPACITY;
1525
+ }
1526
+ /** Number of spans currently buffered. */
1527
+ get size() {
1528
+ return this.buffer.length;
1529
+ }
1530
+ /** Drop every buffered span. */
1531
+ clear() {
1532
+ this.buffer.length = 0;
1533
+ }
1534
+ /** Snapshot of the buffered spans in insertion order. Fresh array per call. */
1535
+ entries() {
1536
+ return [...this.buffer];
1537
+ }
1538
+ /**
1539
+ * Whether any buffered span belongs to `traceId`. A membership test rather
1540
+ * than `entries().some(...)` so the per-dispatch check that decides whether
1541
+ * to record a root span doesn't copy the whole ring on every request.
1542
+ */
1543
+ hasTrace(traceId) {
1544
+ return this.buffer.some((span) => span.traceId === traceId);
1545
+ }
1546
+ /** Append a span, evicting the oldest when at capacity. */
1547
+ push(span) {
1548
+ this.buffer.push(span);
1549
+ if (this.buffer.length > this.capacity) {
1550
+ this.buffer.shift();
1551
+ }
1552
+ }
1553
+ }
1554
+ const DEFAULT_TRACE_LIMIT = 50;
1555
+ const groupByTrace = (spans) => {
1556
+ const byTrace = /* @__PURE__ */ new Map();
1557
+ for (const span of spans) {
1558
+ const group = byTrace.get(span.traceId);
1559
+ if (group === void 0) {
1560
+ byTrace.set(span.traceId, [span]);
1561
+ } else {
1562
+ group.push(span);
1563
+ }
1564
+ }
1565
+ return byTrace;
1566
+ };
1567
+ const selectAnchor = (group, byId) => {
1568
+ const declaredRoot = group.find((span) => span.dispatch === true);
1569
+ if (declaredRoot !== void 0) {
1570
+ return declaredRoot;
1571
+ }
1572
+ const byStart = group.toSorted((a, b) => a.startTs - b.startTs);
1573
+ return byStart.find((span) => !byId.has(span.parentSpanId)) ?? byStart[0];
1574
+ };
1575
+ const depthResolver = (anchor, byId) => {
1576
+ const cache = /* @__PURE__ */ new Map([[anchor.spanId, 0]]);
1577
+ return (span) => {
1578
+ const chain = [];
1579
+ const seen = /* @__PURE__ */ new Set();
1580
+ let current = span;
1581
+ let base = 0;
1582
+ for (; ; ) {
1583
+ const known = cache.get(current.spanId);
1584
+ if (known !== void 0) {
1585
+ base = known;
1586
+ break;
1587
+ }
1588
+ if (seen.has(current.spanId)) {
1589
+ break;
1590
+ }
1591
+ seen.add(current.spanId);
1592
+ chain.push(current);
1593
+ const parent = byId.get(current.parentSpanId);
1594
+ if (parent === void 0) {
1595
+ break;
1596
+ }
1597
+ current = parent;
1598
+ }
1599
+ for (const [index, entry] of chain.toReversed().entries()) {
1600
+ cache.set(entry.spanId, base + index + 1);
1601
+ }
1602
+ return cache.get(span.spanId) ?? base;
1603
+ };
1604
+ };
1605
+ const foldTraces = (spans, limit = DEFAULT_TRACE_LIMIT) => {
1606
+ const byTrace = groupByTrace(spans);
1607
+ const selected = [...byTrace.entries()].map(([traceId, group]) => {
1608
+ return { group, startTs: Math.min(...group.map((span) => span.startTs)), traceId };
1609
+ }).toSorted((a, b) => b.startTs - a.startTs).slice(0, limit);
1610
+ const summaries = [];
1611
+ for (const { group, traceId } of selected) {
1612
+ const byId = new Map(group.map((span) => [span.spanId, span]));
1613
+ const anchor = selectAnchor(group, byId);
1614
+ if (anchor === void 0) {
1615
+ continue;
1616
+ }
1617
+ const depthOf = depthResolver(anchor, byId);
1618
+ const { startTs } = anchor;
1619
+ const endTs = Math.max(...group.map((span) => span.startTs + span.durationMs));
1620
+ const rows = group.map((span) => {
1621
+ return {
1622
+ ...span.attributes === void 0 ? {} : { attributes: span.attributes },
1623
+ depth: depthOf(span),
1624
+ durationMs: span.durationMs,
1625
+ ...span.error === void 0 ? {} : { error: span.error },
1626
+ name: span.name,
1627
+ // Clamped at 0: a child whose parent was evicted can predate the
1628
+ // anchor, and a negative offset would render as a bar off-canvas.
1629
+ offsetMs: Math.max(0, span.startTs - startTs),
1630
+ ok: span.ok,
1631
+ parentSpanId: span.parentSpanId,
1632
+ spanId: span.spanId
1633
+ };
1634
+ }).toSorted((a, b) => a.offsetMs - b.offsetMs || a.depth - b.depth);
1635
+ summaries.push({
1636
+ durationMs: endTs - startTs,
1637
+ functionPath: anchor.functionPath,
1638
+ ok: group.every((span) => span.ok),
1639
+ rootName: anchor.name,
1640
+ ...anchor.shardKey === void 0 ? {} : { shardKey: anchor.shardKey },
1641
+ spans: rows,
1642
+ startTs,
1643
+ traceId
1644
+ });
1645
+ }
1646
+ return { total: byTrace.size, traces: summaries.toSorted((a, b) => b.startTs - a.startTs) };
1647
+ };
1648
+
1431
1649
  const DANGLING_SCAN_CAP = 5e3;
1432
1650
  const DANGLING_RESULT_CAP = 500;
1433
1651
  const DOC_COLUMN = "__doc__";
@@ -2164,6 +2382,14 @@ class ShardDO {
2164
2382
  currentRequestIp;
2165
2383
  /** W3C `traceparent` of the inbound RPC; forwarded onto outbound container fetches. */
2166
2384
  currentRequestTraceparent;
2385
+ /**
2386
+ * Trace ids for the in-flight dispatch, resolved once at entry (see
2387
+ * {@link resolveTraceAnchor}). Shared by `ctx.trace` and by the synthetic
2388
+ * root span recorded on the way out so both agree even with no inbound
2389
+ * `traceparent`. Cleared in the same `finally` as the other per-request
2390
+ * fields.
2391
+ */
2392
+ currentRequestTrace;
2167
2393
  /**
2168
2394
  * Client-issued idempotency key for the in-flight mutation, forwarded via the
2169
2395
  * `x-lunora-mutation-id` header. When set, the dispatch path dedups the call
@@ -2361,6 +2587,19 @@ class ShardDO {
2361
2587
  * "recent RPC errors on this instance" feed, not a general application log.
2362
2588
  */
2363
2589
  logs = new LogBuffer();
2590
+ /**
2591
+ * Recent `ctx.trace` spans (plus the synthetic per-dispatch root), powering
2592
+ * the studio's Traces panel. In-memory and hibernation-volatile like
2593
+ * `logs` — production tracing ships to a collector via `otlpSink`.
2594
+ */
2595
+ spans = new SpanBuffer();
2596
+ /**
2597
+ * Running aggregates of `ctx.metrics.*` measurements, powering the studio's
2598
+ * Metrics panel. In-memory and hibernation-volatile like `logs` and
2599
+ * `spans`, but folds samples into per-series totals rather than ringing
2600
+ * raw events — production aggregation ships to a collector via the sink.
2601
+ */
2602
+ metricSeries = new MetricBuffer();
2364
2603
  /**
2365
2604
  * In-flight dependency tracker for the currently-executing query. Set by
2366
2605
  * `runCachedQuery` so the ctx-db hooks (wired via `onRead`) can
@@ -2452,6 +2691,7 @@ class ShardDO {
2452
2691
  * Worker-side fetch entry point. Handles WebSocket upgrades and the
2453
2692
  * shard-local RPC endpoint forwarded by `@lunora/runtime`.
2454
2693
  */
2694
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- the DO's central request router; #149 added the trace-anchor capture and root-span record, tipping it to 16. Splitting the request lifecycle across helpers would hurt readability more than the +1 costs.
2455
2695
  async fetch(request) {
2456
2696
  const url = new URL(request.url);
2457
2697
  this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
@@ -2483,6 +2723,8 @@ class ShardDO {
2483
2723
  this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
2484
2724
  this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
2485
2725
  this.currentRequestTraceparent = request.headers.get("traceparent") ?? void 0;
2726
+ this.currentRequestTrace = resolveTraceAnchor(this.currentRequestTraceparent);
2727
+ const dispatchTrace = this.currentRequestTrace;
2486
2728
  this.currentRequestReadTables = void 0;
2487
2729
  this.currentRequestCacheHit = void 0;
2488
2730
  this.metrics.requests += 1;
@@ -2490,6 +2732,7 @@ class ShardDO {
2490
2732
  this.currentScannedTables = /* @__PURE__ */ new Set();
2491
2733
  this.currentIndexHits = /* @__PURE__ */ new Set();
2492
2734
  this.currentStmtSamples = [];
2735
+ let dispatchError;
2493
2736
  try {
2494
2737
  if (payload.functionPath.startsWith(RELATION_FUNCTION_PREFIX)) {
2495
2738
  const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
@@ -2521,6 +2764,7 @@ class ShardDO {
2521
2764
  return response;
2522
2765
  } catch (error) {
2523
2766
  this.metrics.errors += 1;
2767
+ dispatchError = { thrown: error };
2524
2768
  const durationMs = Date.now() - dispatchStartedAt;
2525
2769
  const message = error instanceof Error ? error.message : String(error);
2526
2770
  const conflicted = error instanceof ConflictError && error.kind === "occ";
@@ -2540,6 +2784,10 @@ class ShardDO {
2540
2784
  await this.flushChangedTables();
2541
2785
  return this.errorToResponse(error);
2542
2786
  } finally {
2787
+ if (this.spans.hasTrace(dispatchTrace.traceId)) {
2788
+ this.recordDispatchRootSpan(payload.functionPath, dispatchStartedAt, dispatchError, dispatchTrace);
2789
+ }
2790
+ this.currentRequestTrace = void 0;
2543
2791
  this.currentRequestBookmark = void 0;
2544
2792
  this.currentResponseBookmark = void 0;
2545
2793
  this.currentRequestUserId = void 0;
@@ -3009,6 +3257,14 @@ class ShardDO {
3009
3257
  getCurrentTraceparent() {
3010
3258
  return this.currentRequestTraceparent;
3011
3259
  }
3260
+ /**
3261
+ * The in-flight dispatch's trace ids, for the generated `buildCtx` to hand to
3262
+ * {@link makeTracer}. `undefined` outside a dispatch (an alarm, a lifecycle
3263
+ * hook), where the tracer mints its own anchor.
3264
+ */
3265
+ getCurrentTrace() {
3266
+ return this.currentRequestTrace;
3267
+ }
3012
3268
  /**
3013
3269
  * Identity claims (email, name, roles, …) forwarded by the runtime's
3014
3270
  * `resolveIdentity` hook. Returns `undefined` for anonymous requests
@@ -4082,24 +4338,173 @@ class ShardDO {
4082
4338
  * Unlike request-log args, `ctx.log` args are NOT redacted: the developer
4083
4339
  * chose to log them, exactly like a raw `console.log`.
4084
4340
  */
4085
- recordUserLog(functionPath, level, args, sink) {
4341
+ recordUserLog(functionPath, level, args, message, fields, sink) {
4342
+ const trace = this.currentRequestTrace;
4086
4343
  const event = {
4087
4344
  args,
4345
+ fields,
4088
4346
  functionPath,
4089
4347
  level,
4090
- message: renderLogMessage(args),
4348
+ message,
4091
4349
  shardKey: this.state.id?.name,
4350
+ spanId: trace?.rootSpanId,
4351
+ traceId: trace?.traceId,
4092
4352
  ts: Date.now(),
4093
4353
  userId: this.getCurrentUserId()
4094
4354
  };
4095
- this.logs.push({ functionPath, level: level === "log" ? "info" : level, message: event.message, timestamp: event.ts });
4355
+ this.logs.push({ fields, functionPath, level, message, timestamp: event.ts });
4096
4356
  try {
4097
4357
  emitLogEvent(event);
4098
4358
  } catch {
4099
4359
  }
4100
4360
  if (sink?.onLog) {
4101
4361
  try {
4102
- sink.onLog(event);
4362
+ sink.onLog(event, { waitUntil: this.state.waitUntil?.bind(this.state) });
4363
+ } catch {
4364
+ }
4365
+ }
4366
+ }
4367
+ /**
4368
+ * Build the `ctx.log` logger for one dispatched function. Each severity method
4369
+ * accepts either the structured form (`(message, fields)`) or console-style
4370
+ * varargs (see {@link parseLogArgs}); `with(fields)` returns a child that
4371
+ * stamps `fields` onto every line. The generated `buildCtx` calls this once
4372
+ * per dispatch and assigns the result to `ctx.log`.
4373
+ */
4374
+ makeLogger(functionPath, sink, boundFields) {
4375
+ const emit = (level, args) => {
4376
+ const { fields, message } = parseLogArgs(args, boundFields);
4377
+ this.recordUserLog(functionPath, level, args, message, fields, sink);
4378
+ };
4379
+ return {
4380
+ debug: (...args) => {
4381
+ emit("debug", args);
4382
+ },
4383
+ error: (...args) => {
4384
+ emit("error", args);
4385
+ },
4386
+ fatal: (...args) => {
4387
+ emit("fatal", args);
4388
+ },
4389
+ info: (...args) => {
4390
+ emit("info", args);
4391
+ },
4392
+ log: (...args) => {
4393
+ emit("log", args);
4394
+ },
4395
+ trace: (...args) => {
4396
+ emit("trace", args);
4397
+ },
4398
+ warn: (...args) => {
4399
+ emit("warn", args);
4400
+ },
4401
+ with: (fields) => this.makeLogger(functionPath, sink, boundFields ? { ...boundFields, ...fields } : fields)
4402
+ };
4403
+ }
4404
+ /**
4405
+ * Build the `ctx.trace` span factory for one dispatched function. The
4406
+ * generated `buildCtx` calls this once per dispatch and assigns the result to
4407
+ * `ctx.trace`.
4408
+ *
4409
+ * Thin wiring over {@link createTracer}, which owns the span semantics (see
4410
+ * there for why nesting is explicit rather than ambient). Everything it needs
4411
+ * from the shard is passed explicitly.
4412
+ *
4413
+ * `anchor` is the trace this ctx's spans belong to; omit it for a ctx with no
4414
+ * owning dispatch (an alarm, a subscription re-run) to mint a fresh anchor, so
4415
+ * `ctx.trace` still yields a coherent self-contained trace there.
4416
+ */
4417
+ makeTracer(functionPath, sink, anchor) {
4418
+ return createTracer({
4419
+ anchor: anchor ?? resolveTraceAnchor(void 0),
4420
+ functionPath,
4421
+ record: (span) => {
4422
+ this.recordSpan(span, sink);
4423
+ },
4424
+ shardKey: this.state.id?.name,
4425
+ userId: () => this.getCurrentUserId()
4426
+ });
4427
+ }
4428
+ /**
4429
+ * Build the `ctx.metrics` recorder for one dispatched function. Thin wiring
4430
+ * over {@link createMetrics}, which owns the instrument semantics.
4431
+ */
4432
+ makeMetrics(functionPath, sink) {
4433
+ return createMetrics({
4434
+ functionPath,
4435
+ record: (event) => {
4436
+ this.recordMetric(event, sink);
4437
+ },
4438
+ shardKey: this.state.id?.name
4439
+ });
4440
+ }
4441
+ /**
4442
+ * Fold one measurement into the in-memory {@link metricSeries} readout and
4443
+ * hand it to the optional `sink.onMetric`. Best-effort like
4444
+ * {@link recordUserLog} and {@link recordSpan}: recording a measurement must
4445
+ * never break the handler that recorded it.
4446
+ *
4447
+ * The buffer folds rather than rings: a measurement's value is in its
4448
+ * aggregate over a window, which a bounded ring of raw samples can't represent
4449
+ * (it evicts the oldest samples, the ones a running total needs). So the
4450
+ * buffer keeps one running aggregate per series and resets on hibernation like
4451
+ * logs and spans — a "recent metrics on this instance" dev readout. Durable,
4452
+ * cross-instance aggregation is still the sink's job.
4453
+ */
4454
+ recordMetric(event, sink) {
4455
+ try {
4456
+ this.metricSeries.push(event);
4457
+ } catch {
4458
+ }
4459
+ if (sink?.onMetric) {
4460
+ try {
4461
+ sink.onMetric(event, { waitUntil: this.state.waitUntil?.bind(this.state) });
4462
+ } catch {
4463
+ }
4464
+ }
4465
+ }
4466
+ /**
4467
+ * Buffer the synthetic root span for a finished dispatch. The caller gates
4468
+ * this on the dispatch having actually produced spans (the `hasTrace` check at
4469
+ * the call site): every request minting a root would fill the bounded ring
4470
+ * with single-bar traces from uninstrumented handlers and evict the
4471
+ * instrumented ones the panel exists to show.
4472
+ *
4473
+ * `anchor` carries the dispatch's trace ids, captured at entry rather than
4474
+ * read from `this` here — this runs after the handler's awaits, where the
4475
+ * shared field may already belong to an interleaved dispatch.
4476
+ */
4477
+ recordDispatchRootSpan(functionPath, startedAt, failure, anchor) {
4478
+ try {
4479
+ this.spans.push(
4480
+ dispatchRootSpan({
4481
+ anchor,
4482
+ durationMs: Date.now() - startedAt,
4483
+ failure,
4484
+ functionPath,
4485
+ shardKey: this.state.id?.name,
4486
+ startTs: startedAt,
4487
+ userId: this.getCurrentUserId()
4488
+ })
4489
+ );
4490
+ } catch {
4491
+ }
4492
+ }
4493
+ /**
4494
+ * Buffer one span for the studio Traces panel and hand it to the optional
4495
+ * `sink.onSpan`. Best-effort throughout, exactly like {@link recordUserLog}:
4496
+ * a span is recorded *after* its body already settled, so letting a telemetry
4497
+ * failure escape here would turn a succeeded operation into a failed request.
4498
+ */
4499
+ // eslint-disable-next-line @typescript-eslint/member-ordering -- kept in the span-recording cluster (next to recordDispatchRootSpan) for cohesion rather than hoisted above every private member
4500
+ recordSpan(span, sink) {
4501
+ try {
4502
+ this.spans.push(span);
4503
+ } catch {
4504
+ }
4505
+ if (sink?.onSpan) {
4506
+ try {
4507
+ sink.onSpan(span, { waitUntil: this.state.waitUntil?.bind(this.state) });
4103
4508
  } catch {
4104
4509
  }
4105
4510
  }
@@ -5179,6 +5584,7 @@ class ShardDO {
5179
5584
  * wraps each in the {@link ADMIN_WILDCARD} sentinel, keeping that one fact in
5180
5585
  * a single place and `readAdminOp` under its complexity budget.
5181
5586
  */
5587
+ // eslint-disable-next-line sonarjs/cognitive-complexity -- a flat admin-RPC dispatch chain (one trivial branch per function); #149 added the getTraces/getMetrics branches. A lookup table would obscure the 1:1 path→reader mapping this deliberately keeps.
5182
5588
  readAdminWildcardOp(functionPath) {
5183
5589
  if (functionPath === ADMIN_FUNCTIONS.listTables) {
5184
5590
  return listTables(this.state.storage.sql);
@@ -5198,6 +5604,13 @@ class ShardDO {
5198
5604
  if (functionPath === ADMIN_FUNCTIONS.getLogs) {
5199
5605
  return { entries: this.logs.entries() };
5200
5606
  }
5607
+ if (functionPath === ADMIN_FUNCTIONS.getTraces) {
5608
+ const folded = foldTraces(this.spans.entries());
5609
+ return { total: folded.total, traces: folded.traces };
5610
+ }
5611
+ if (functionPath === ADMIN_FUNCTIONS.getMetricSeries) {
5612
+ return { series: this.metricSeries.entries() };
5613
+ }
5201
5614
  if (functionPath === ADMIN_FUNCTIONS.getSettings) {
5202
5615
  return buildSettings(this.env);
5203
5616
  }