@decocms/start 6.2.1 → 6.3.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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@decocms/start",
3
- "version": "6.2.1",
3
+ "version": "6.3.0",
4
4
  "type": "module",
5
5
  "description": "Deco framework for TanStack Start - CMS bridge, admin protocol, hooks, schema generation",
6
6
  "main": "./src/index.ts",
@@ -253,6 +253,22 @@ export const MetricNames = {
253
253
  * `MIGRATION_TOOLING_PLAN.md` for the rationale.
254
254
  */
255
255
  COMMERCE_REQUEST_DURATION_MS: "commerce_request_duration_ms",
256
+ /**
257
+ * Per-loader execution duration. Emitted by `cachedLoader` for every
258
+ * loader call — cached or not. The `cache_status` label lets dashboards
259
+ * separate origin latency from in-memory hit latency without needing
260
+ * to join on traces.
261
+ *
262
+ * Canonical labels: `loader`, `cache_status`.
263
+ */
264
+ LOADER_DURATION_MS: "loader_duration_ms",
265
+ /**
266
+ * Counter incremented when a loader throws. Complements
267
+ * `loader_duration_ms` for error-rate dashboards.
268
+ *
269
+ * Canonical labels: `loader`.
270
+ */
271
+ LOADER_ERRORS_TOTAL: "loader_errors_total",
256
272
  } as const;
257
273
 
258
274
  /**
@@ -455,6 +471,35 @@ export function recordCommerceMetric(
455
471
  m.histogramRecord?.(MetricNames.COMMERCE_REQUEST_DURATION_MS, durationMs, merged);
456
472
  }
457
473
 
474
+ /**
475
+ * Record a loader execution sample. Call from `cachedLoader` after the
476
+ * loader resolves or rejects. `cache_status` mirrors `CacheDecision` so
477
+ * dashboards can distinguish HIT (fresh) from STALE-HIT (SWR), STALE-ERROR
478
+ * (SIE fallback), MISS (origin fetch), and BYPASS (dev / no-store).
479
+ */
480
+ export function recordLoaderMetric(
481
+ name: string,
482
+ durationMs: number,
483
+ cacheStatus: CacheDecision | "BYPASS",
484
+ ) {
485
+ const m = getState().meter;
486
+ if (!m) return;
487
+ m.histogramRecord?.(MetricNames.LOADER_DURATION_MS, durationMs, {
488
+ loader: name,
489
+ cache_status: cacheStatus,
490
+ });
491
+ }
492
+
493
+ /**
494
+ * Increment the loader error counter. Call when a loader throws and the
495
+ * error is not swallowed by a SIE fallback.
496
+ */
497
+ export function recordLoaderError(name: string) {
498
+ const m = getState().meter;
499
+ if (!m) return;
500
+ m.counterInc(MetricNames.LOADER_ERRORS_TOTAL, 1, { loader: name });
501
+ }
502
+
458
503
  function normalizePath(path: string): string {
459
504
  // Collapse dynamic segments to reduce cardinality
460
505
  return path
@@ -11,7 +11,12 @@
11
11
  * (e.g. "product") which derives timing from the unified profile system.
12
12
  */
13
13
 
14
- import { recordCacheMetric, withTracing } from "../middleware/observability";
14
+ import {
15
+ recordCacheMetric,
16
+ recordLoaderError,
17
+ recordLoaderMetric,
18
+ withTracing,
19
+ } from "../middleware/observability";
15
20
  import { type CacheProfileName, loaderCacheOptions } from "./cacheHeaders";
16
21
 
17
22
  export type CachePolicy = "no-store" | "no-cache" | "stale-while-revalidate";
@@ -100,17 +105,32 @@ export function createCachedLoader<TProps, TResult>(
100
105
  if (inflight) {
101
106
  // Treat in-flight dedup as a cache hit — avoided the origin call.
102
107
  recordCacheMetric(true, name, undefined, "cachedLoader");
103
- return inflight as Promise<TResult>;
108
+ const start = performance.now();
109
+ return inflight.then((r) => {
110
+ recordLoaderMetric(name, performance.now() - start, "HIT");
111
+ return r as TResult;
112
+ });
104
113
  }
105
114
 
106
115
  if (isDev) {
107
116
  // Dev mode: no caching, but still useful to count attempts.
108
117
  recordCacheMetric(false, name, undefined, "cachedLoader");
118
+ const devStart = performance.now();
109
119
  const promise = withTracing(
110
120
  "deco.cachedLoader",
111
- () => loaderFn(props).finally(() => inflightRequests.delete(cacheKey)),
121
+ () => loaderFn(props),
112
122
  { "deco.loader": name, "deco.cache.policy": "no-cache-dev" },
113
- );
123
+ )
124
+ .then((r) => {
125
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
126
+ return r;
127
+ })
128
+ .catch((err) => {
129
+ recordLoaderMetric(name, performance.now() - devStart, "BYPASS");
130
+ recordLoaderError(name);
131
+ throw err;
132
+ })
133
+ .finally(() => inflightRequests.delete(cacheKey));
114
134
  inflightRequests.set(cacheKey, promise);
115
135
  return promise;
116
136
  }
@@ -122,6 +142,7 @@ export function createCachedLoader<TProps, TResult>(
122
142
  if (policy === "no-cache") {
123
143
  if (entry && !isStale) {
124
144
  recordCacheMetric(true, name, "HIT", "cachedLoader");
145
+ recordLoaderMetric(name, 0, "HIT");
125
146
  return entry.value;
126
147
  }
127
148
  }
@@ -129,12 +150,14 @@ export function createCachedLoader<TProps, TResult>(
129
150
  if (policy === "stale-while-revalidate") {
130
151
  if (entry && !isStale) {
131
152
  recordCacheMetric(true, name, "HIT", "cachedLoader");
153
+ recordLoaderMetric(name, 0, "HIT");
132
154
  return entry.value;
133
155
  }
134
156
 
135
157
  if (entry && isStale && !entry.refreshing) {
136
158
  // Stale-while-revalidate hit: serve stale, refresh in background.
137
159
  recordCacheMetric(true, name, "STALE-HIT", "cachedLoader");
160
+ recordLoaderMetric(name, 0, "STALE-HIT");
138
161
  entry.refreshing = true;
139
162
  loaderFn(props)
140
163
  .then((result) => {
@@ -160,6 +183,7 @@ export function createCachedLoader<TProps, TResult>(
160
183
  // the decision as STALE-ERROR so dashboards can distinguish
161
184
  // this from healthy SWR.
162
185
  recordCacheMetric(true, name, "STALE-ERROR", "cachedLoader");
186
+ recordLoaderMetric(name, 0, "STALE-ERROR");
163
187
  return entry.value;
164
188
  }
165
189
  }
@@ -167,11 +191,13 @@ export function createCachedLoader<TProps, TResult>(
167
191
  // Cache miss — emit metric, then run loader inside a span so individual
168
192
  // slow loaders are visible in traces.
169
193
  recordCacheMetric(false, name, "MISS", "cachedLoader");
194
+ const loaderStart = performance.now();
170
195
  const promise = withTracing("deco.cachedLoader", () => loaderFn(props), {
171
196
  "deco.loader": name,
172
197
  "deco.cache.policy": policy,
173
198
  })
174
199
  .then((result) => {
200
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
175
201
  cache.set(cacheKey, {
176
202
  value: result,
177
203
  createdAt: Date.now(),
@@ -190,9 +216,12 @@ export function createCachedLoader<TProps, TResult>(
190
216
  console.warn(
191
217
  `[cachedLoader] ${name}: origin error, serving stale entry (age=${Math.round(age / 1000)}s, sie=${Math.round(staleIfError / 1000)}s)`,
192
218
  );
219
+ recordLoaderMetric(name, performance.now() - loaderStart, "STALE-ERROR");
193
220
  return entry.value;
194
221
  }
195
222
  }
223
+ recordLoaderMetric(name, performance.now() - loaderStart, "MISS");
224
+ recordLoaderError(name);
196
225
  throw err;
197
226
  });
198
227
 
@@ -65,6 +65,8 @@ export {
65
65
  MetricNames,
66
66
  recordCacheMetric,
67
67
  recordCommerceMetric,
68
+ recordLoaderError,
69
+ recordLoaderMetric,
68
70
  recordRequestMetric,
69
71
  type RequestMetricLabels,
70
72
  type RequestStore,