@lunora/do 1.0.0-alpha.34 → 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.
@@ -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,35 +24,10 @@ 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;
29
30
 
30
- const BUFFER_LEVEL = {
31
- debug: "debug",
32
- error: "error",
33
- fatal: "error",
34
- info: "info",
35
- log: "info",
36
- trace: "debug",
37
- warn: "warn"
38
- };
39
-
40
- const HEX_ONLY = /^[0-9a-f]+$/;
41
- const parseTraceparent = (header) => {
42
- if (header === null || header === void 0) {
43
- return void 0;
44
- }
45
- const parts = header.trim().toLowerCase().split("-");
46
- const [version, traceId, parentSpanId, flags] = parts;
47
- if (parts.length < 4 || version === void 0 || version.length !== 2 || !HEX_ONLY.test(version) || version === "ff" || // Version 00 forbids trailing fields; only a future version may carry them.
48
- version === "00" && parts.length !== 4 || traceId === void 0 || parentSpanId === void 0 || flags === void 0 || flags.length !== 2 || !HEX_ONLY.test(flags) || traceId.length !== 32 || parentSpanId.length !== 16 || !HEX_ONLY.test(traceId) || !HEX_ONLY.test(parentSpanId) || traceId === "00000000000000000000000000000000" || parentSpanId === "0000000000000000") {
49
- return void 0;
50
- }
51
- return { parentSpanId, traceId };
52
- };
53
-
54
31
  const evictOldestEntry = (map, capacity) => {
55
32
  if (map.size < capacity) {
56
33
  return;
@@ -206,6 +183,71 @@ const buildBatchEntryRequest = (batchRequest, entry) => {
206
183
  });
207
184
  };
208
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
+
209
251
  const QUERY_METRICS_TABLE = "__lunora_metrics_queries";
210
252
  const QUERY_METRICS_MAX_SQL_LEN = 512;
211
253
  const QUERY_METRICS_MAX_STATEMENTS = 500;
@@ -1182,35 +1224,6 @@ const createRelayLink = (host) => {
1182
1224
  return parsed === void 0 ? new OwnerRelay(host, name) : new RelayMember(host, parsed.ownerKey, parsed.relayIndex);
1183
1225
  };
1184
1226
 
1185
- const stringifyFieldValue = (value) => {
1186
- if (typeof value === "string") {
1187
- return value;
1188
- }
1189
- try {
1190
- return JSON.stringify(value) ?? String(value);
1191
- } catch {
1192
- return String(value);
1193
- }
1194
- };
1195
- const coerceFieldValue = (value) => typeof value === "boolean" || typeof value === "number" || typeof value === "string" ? value : stringifyFieldValue(value);
1196
- const normalizeLogFields = (perCall, bound) => {
1197
- if (perCall === void 0 && bound === void 0) {
1198
- return void 0;
1199
- }
1200
- const merged = {};
1201
- if (bound !== void 0) {
1202
- for (const [key, value] of Object.entries(bound)) {
1203
- merged[key] = coerceFieldValue(value);
1204
- }
1205
- }
1206
- if (perCall !== void 0) {
1207
- for (const [key, value] of Object.entries(perCall)) {
1208
- merged[key] = coerceFieldValue(value);
1209
- }
1210
- }
1211
- return Object.keys(merged).length === 0 ? void 0 : merged;
1212
- };
1213
-
1214
1227
  const REQUEST_LOG_TABLE = "__lunora_reqlog__";
1215
1228
  const REQUEST_LOG_RETENTION = 1e3;
1216
1229
  const REQUEST_LOG_EVENT_SOURCE = "lunora";
@@ -1503,6 +1516,136 @@ const runSocketPool = async (items, processOne, concurrency = 8) => {
1503
1516
  await Promise.all(Array.from({ length: Math.min(concurrency, items.length) }, () => worker()));
1504
1517
  };
1505
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
+
1506
1649
  const DANGLING_SCAN_CAP = 5e3;
1507
1650
  const DANGLING_RESULT_CAP = 500;
1508
1651
  const DOC_COLUMN = "__doc__";
@@ -2239,6 +2382,14 @@ class ShardDO {
2239
2382
  currentRequestIp;
2240
2383
  /** W3C `traceparent` of the inbound RPC; forwarded onto outbound container fetches. */
2241
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;
2242
2393
  /**
2243
2394
  * Client-issued idempotency key for the in-flight mutation, forwarded via the
2244
2395
  * `x-lunora-mutation-id` header. When set, the dispatch path dedups the call
@@ -2436,6 +2587,19 @@ class ShardDO {
2436
2587
  * "recent RPC errors on this instance" feed, not a general application log.
2437
2588
  */
2438
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();
2439
2603
  /**
2440
2604
  * In-flight dependency tracker for the currently-executing query. Set by
2441
2605
  * `runCachedQuery` so the ctx-db hooks (wired via `onRead`) can
@@ -2527,6 +2691,7 @@ class ShardDO {
2527
2691
  * Worker-side fetch entry point. Handles WebSocket upgrades and the
2528
2692
  * shard-local RPC endpoint forwarded by `@lunora/runtime`.
2529
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.
2530
2695
  async fetch(request) {
2531
2696
  const url = new URL(request.url);
2532
2697
  this.shardBinding = request.headers.get("x-lunora-shard-binding") ?? this.shardBinding;
@@ -2558,6 +2723,8 @@ class ShardDO {
2558
2723
  this.currentRequestIp = request.headers.get("x-lunora-client-ip") ?? void 0;
2559
2724
  this.currentRequestSystem = request.headers.get("x-lunora-system") === "1";
2560
2725
  this.currentRequestTraceparent = request.headers.get("traceparent") ?? void 0;
2726
+ this.currentRequestTrace = resolveTraceAnchor(this.currentRequestTraceparent);
2727
+ const dispatchTrace = this.currentRequestTrace;
2561
2728
  this.currentRequestReadTables = void 0;
2562
2729
  this.currentRequestCacheHit = void 0;
2563
2730
  this.metrics.requests += 1;
@@ -2565,6 +2732,7 @@ class ShardDO {
2565
2732
  this.currentScannedTables = /* @__PURE__ */ new Set();
2566
2733
  this.currentIndexHits = /* @__PURE__ */ new Set();
2567
2734
  this.currentStmtSamples = [];
2735
+ let dispatchError;
2568
2736
  try {
2569
2737
  if (payload.functionPath.startsWith(RELATION_FUNCTION_PREFIX)) {
2570
2738
  const value = await this.runRelationFanoutRead(payload.functionPath, payload.args ?? {});
@@ -2596,6 +2764,7 @@ class ShardDO {
2596
2764
  return response;
2597
2765
  } catch (error) {
2598
2766
  this.metrics.errors += 1;
2767
+ dispatchError = { thrown: error };
2599
2768
  const durationMs = Date.now() - dispatchStartedAt;
2600
2769
  const message = error instanceof Error ? error.message : String(error);
2601
2770
  const conflicted = error instanceof ConflictError && error.kind === "occ";
@@ -2615,6 +2784,10 @@ class ShardDO {
2615
2784
  await this.flushChangedTables();
2616
2785
  return this.errorToResponse(error);
2617
2786
  } finally {
2787
+ if (this.spans.hasTrace(dispatchTrace.traceId)) {
2788
+ this.recordDispatchRootSpan(payload.functionPath, dispatchStartedAt, dispatchError, dispatchTrace);
2789
+ }
2790
+ this.currentRequestTrace = void 0;
2618
2791
  this.currentRequestBookmark = void 0;
2619
2792
  this.currentResponseBookmark = void 0;
2620
2793
  this.currentRequestUserId = void 0;
@@ -3084,6 +3257,14 @@ class ShardDO {
3084
3257
  getCurrentTraceparent() {
3085
3258
  return this.currentRequestTraceparent;
3086
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
+ }
3087
3268
  /**
3088
3269
  * Identity claims (email, name, roles, …) forwarded by the runtime's
3089
3270
  * `resolveIdentity` hook. Returns `undefined` for anonymous requests
@@ -4158,7 +4339,7 @@ class ShardDO {
4158
4339
  * chose to log them, exactly like a raw `console.log`.
4159
4340
  */
4160
4341
  recordUserLog(functionPath, level, args, message, fields, sink) {
4161
- const trace = parseTraceparent(this.getCurrentTraceparent());
4342
+ const trace = this.currentRequestTrace;
4162
4343
  const event = {
4163
4344
  args,
4164
4345
  fields,
@@ -4166,12 +4347,12 @@ class ShardDO {
4166
4347
  level,
4167
4348
  message,
4168
4349
  shardKey: this.state.id?.name,
4169
- spanId: trace?.parentSpanId,
4350
+ spanId: trace?.rootSpanId,
4170
4351
  traceId: trace?.traceId,
4171
4352
  ts: Date.now(),
4172
4353
  userId: this.getCurrentUserId()
4173
4354
  };
4174
- this.logs.push({ fields, functionPath, level: BUFFER_LEVEL[level], message, timestamp: event.ts });
4355
+ this.logs.push({ fields, functionPath, level, message, timestamp: event.ts });
4175
4356
  try {
4176
4357
  emitLogEvent(event);
4177
4358
  } catch {
@@ -4196,16 +4377,138 @@ class ShardDO {
4196
4377
  this.recordUserLog(functionPath, level, args, message, fields, sink);
4197
4378
  };
4198
4379
  return {
4199
- debug: (...args) => emit("debug", args),
4200
- error: (...args) => emit("error", args),
4201
- fatal: (...args) => emit("fatal", args),
4202
- info: (...args) => emit("info", args),
4203
- log: (...args) => emit("log", args),
4204
- trace: (...args) => emit("trace", args),
4205
- warn: (...args) => emit("warn", args),
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
+ },
4206
4401
  with: (fields) => this.makeLogger(functionPath, sink, boundFields ? { ...boundFields, ...fields } : fields)
4207
4402
  };
4208
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) });
4508
+ } catch {
4509
+ }
4510
+ }
4511
+ }
4209
4512
  /**
4210
4513
  * Assemble the per-socket {@link LifecycleDispatchInfo} from its attachment:
4211
4514
  * the verified identity to replay and the {@link LifecycleEvent} the hooks
@@ -5281,6 +5584,7 @@ class ShardDO {
5281
5584
  * wraps each in the {@link ADMIN_WILDCARD} sentinel, keeping that one fact in
5282
5585
  * a single place and `readAdminOp` under its complexity budget.
5283
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.
5284
5588
  readAdminWildcardOp(functionPath) {
5285
5589
  if (functionPath === ADMIN_FUNCTIONS.listTables) {
5286
5590
  return listTables(this.state.storage.sql);
@@ -5300,6 +5604,13 @@ class ShardDO {
5300
5604
  if (functionPath === ADMIN_FUNCTIONS.getLogs) {
5301
5605
  return { entries: this.logs.entries() };
5302
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
+ }
5303
5614
  if (functionPath === ADMIN_FUNCTIONS.getSettings) {
5304
5615
  return buildSettings(this.env);
5305
5616
  }