@cosmicdrift/kumiko-framework 0.185.0 → 0.186.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": "@cosmicdrift/kumiko-framework",
3
- "version": "0.185.0",
3
+ "version": "0.186.0",
4
4
  "description": "Framework core — engine, pipeline, API, DB, and every other bit that makes Kumiko go.",
5
5
  "license": "BUSL-1.1",
6
6
  "author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
@@ -182,7 +182,7 @@
182
182
  "./package.json": "./package.json"
183
183
  },
184
184
  "dependencies": {
185
- "@cosmicdrift/kumiko-types": "0.185.0",
185
+ "@cosmicdrift/kumiko-types": "0.186.0",
186
186
  "bullmq": "^5.76.7",
187
187
  "bun-types": "^1.3.13",
188
188
  "hono": "^4.12.27",
@@ -198,7 +198,7 @@
198
198
  "zod": "^4.4.3"
199
199
  },
200
200
  "devDependencies": {
201
- "@cosmicdrift/kumiko-dispatcher-live": "0.185.0",
201
+ "@cosmicdrift/kumiko-dispatcher-live": "0.186.0",
202
202
  "bun-types": "^1.3.13",
203
203
  "pino-pretty": "^13.1.3"
204
204
  },
@@ -557,3 +557,83 @@ describe("Observability (integration) — error path", () => {
557
557
  expect(errorCounter?.labels?.["handler"]).toBe("err:write:boom");
558
558
  });
559
559
  });
560
+
561
+ // Simulates shared/library code called from several consumer features
562
+ // (framework#1844's ai-foundation scenario) — the same call site, not a
563
+ // copy-pasted inc() per feature. Feature names use real kebab-case (as
564
+ // declared at defineFeature time and used in QN dispatch types) — buildMetricName
565
+ // normalizes "-" to "_" so registration and lookup resolve to the same name.
566
+ function recordSharedCall(ctx: {
567
+ metricsFor: (featureName: string) => { inc: (n: string) => void };
568
+ }) {
569
+ ctx.metricsFor("shared-lib").inc("call_total");
570
+ }
571
+
572
+ const sharedLibFeature = defineFeature("shared-lib", (r) => {
573
+ r.metric("call_total", { type: "counter" });
574
+ });
575
+
576
+ const consumerAFeature = defineFeature("consumer-a", (r) => {
577
+ r.writeHandler(
578
+ "run",
579
+ z.object({}),
580
+ async (_event, ctx) => {
581
+ recordSharedCall(ctx);
582
+ return { isSuccess: true, data: { ok: true } };
583
+ },
584
+ { access: { openToAll: true } },
585
+ );
586
+ });
587
+
588
+ const consumerBFeature = defineFeature("consumer-b", (r) => {
589
+ r.writeHandler(
590
+ "run",
591
+ z.object({}),
592
+ async (_event, ctx) => {
593
+ recordSharedCall(ctx);
594
+ ctx.metricsFor("unregistered-lib").inc("never_total");
595
+ return { isSuccess: true, data: { ok: true } };
596
+ },
597
+ { access: { openToAll: true } },
598
+ );
599
+ });
600
+
601
+ describe("Observability (integration) — ctx.metricsFor", () => {
602
+ let stack: TestStack;
603
+ let provider: RecordingProvider;
604
+
605
+ beforeEach(async () => {
606
+ provider = createRecordingProvider();
607
+ stack = await setupTestStack({
608
+ features: [sharedLibFeature, consumerAFeature, consumerBFeature],
609
+ observability: provider,
610
+ });
611
+ });
612
+
613
+ afterEach(async () => {
614
+ await stack.cleanup();
615
+ });
616
+
617
+ it("resolves the same library-owned metric name from two different consumer features", async () => {
618
+ await stack.http.command("consumer-a:write:run", {}, adminUser);
619
+ await stack.http.command("consumer-b:write:run", {}, adminUser);
620
+
621
+ const sharedEvents = provider.metricEvents.filter(
622
+ (e) => e.type === "counter.inc" && e.name === "kumiko_shared_lib_call_total",
623
+ );
624
+ expect(sharedEvents).toHaveLength(2);
625
+
626
+ const splinteredEvents = provider.metricEvents.filter(
627
+ (e) => e.type === "counter.inc" && /^kumiko_consumer_(a|b)_call_total$/.test(e.name),
628
+ );
629
+ expect(splinteredEvents).toHaveLength(0);
630
+ });
631
+
632
+ it("does not throw and emits nothing for an unregistered metricsFor name", async () => {
633
+ const res = await stack.http.command("consumer-b:write:run", {}, adminUser);
634
+ expect(res.status).toBeLessThan(300);
635
+
636
+ const neverEvents = provider.metricEvents.filter((e) => e.name.includes("never_total"));
637
+ expect(neverEvents).toHaveLength(0);
638
+ });
639
+ });
@@ -13,6 +13,7 @@ export {
13
13
  export {
14
14
  createMetricsHandle,
15
15
  createNoopMetricsHandle,
16
+ createSafeMetricsHandle,
16
17
  createUnboundMetricsHandle,
17
18
  } from "./metrics-handle";
18
19
  export { createNoopProvider } from "./noop-provider";
@@ -66,11 +66,22 @@ export function validateMetricName(name: string, type: MetricType): void {
66
66
 
67
67
  // Prefix a short feature-local metric name with the Kumiko + feature prefix.
68
68
  // Short name: "created_total". Feature: "orders". Result: "kumiko_orders_created_total".
69
+ //
70
+ // Feature names are kebab-case everywhere else (qualified-name segments,
71
+ // r.metric() is called with `feature.name` as registered at defineFeature
72
+ // time) — normalize "-" to "_" here so a feature like "ai-foundation"
73
+ // resolves to the same "kumiko_ai_foundation_x" on both the registration
74
+ // path (registry-ingest.ts) and the read path (ctx.metrics / ctx.metricsFor),
75
+ // instead of the kebab form being rejected outright (framework#1844).
69
76
  export function buildMetricName(featureName: string, shortName: string): string {
70
- if (!SNAKE_CASE.test(featureName)) {
71
- throw new Error(`[Kumiko Observability] Feature name "${featureName}" must be snake_case.`);
77
+ const normalizedFeatureName = featureName.replace(/-/g, "_");
78
+ if (!SNAKE_CASE.test(normalizedFeatureName)) {
79
+ throw new Error(
80
+ `[Kumiko Observability] Feature name "${featureName}" must be kebab-case or snake_case ` +
81
+ `(a-z, 0-9, "-" or "_").`,
82
+ );
72
83
  }
73
- return `kumiko_${featureName}_${shortName}`;
84
+ return `kumiko_${normalizedFeatureName}_${shortName}`;
74
85
  }
75
86
 
76
87
  // Validate label keys: snake_case, not reserved.
@@ -27,6 +27,43 @@ export function createMetricsHandle(meter: Meter, featureName: string): MetricsH
27
27
  };
28
28
  }
29
29
 
30
+ // Same feature-bound resolution as createMetricsHandle, but for an
31
+ // explicit `featureName` chosen by the caller rather than the dispatching
32
+ // handler's own feature (framework#1844). Meant for shared/library code
33
+ // invoked from many features' HandlerContext (ctx.metricsFor) — the
34
+ // library owns one stable metric name instead of splintering into
35
+ // kumiko_<caller>_x per consumer.
36
+ //
37
+ // Decision (framework#1844 DoD): unlike createMetricsHandle, an
38
+ // unregistered name here is a silent no-op, not a throw. This handle is
39
+ // meant for error/catch-path counters in shared code — a missing
40
+ // registration (consuming feature not mounted, metric not declared yet)
41
+ // must not turn an already-swallowed error into a thrown one. Every other
42
+ // failure (invalid featureName, wrong metric type for the call) still
43
+ // throws — only the "not registered" case is swallowed.
44
+ export function createSafeMetricsHandle(meter: Meter, featureName: string): MetricsHandle {
45
+ return {
46
+ inc(shortName, labels, value) {
47
+ const name = buildMetricName(featureName, shortName);
48
+ // skip: unregistered name is the documented no-op contract of this handle
49
+ if (!meter.definitions().has(name)) return;
50
+ meter.counter(name).inc(value, labels);
51
+ },
52
+ observe(shortName, value, labels) {
53
+ const name = buildMetricName(featureName, shortName);
54
+ // skip: unregistered name is the documented no-op contract of this handle
55
+ if (!meter.definitions().has(name)) return;
56
+ meter.histogram(name).observe(value, labels);
57
+ },
58
+ set(shortName, value, labels) {
59
+ const name = buildMetricName(featureName, shortName);
60
+ // skip: unregistered name is the documented no-op contract of this handle
61
+ if (!meter.definitions().has(name)) return;
62
+ meter.gauge(name).set(value, labels);
63
+ },
64
+ };
65
+ }
66
+
30
67
  // Fallback for contexts where the feature is unknown (e.g. system-hooks,
31
68
  // internal pipeline code). Short names are used verbatim — useful for
32
69
  // framework-level usage, but rejected by the Meter unless pre-registered.
@@ -53,6 +53,7 @@ import { createFileContext } from "../files/file-handle";
53
53
  import {
54
54
  createMetricsHandle,
55
55
  createNoopMetricsHandle,
56
+ createSafeMetricsHandle,
56
57
  emitDispatcherError,
57
58
  emitDispatcherHandler,
58
59
  type getFallbackMeter,
@@ -217,6 +218,13 @@ export async function buildHandlerContext(
217
218
  const featureName = registry.getHandlerFeature(type);
218
219
  const metrics =
219
220
  meter && featureName ? createMetricsHandle(meter, featureName) : createNoopMetricsHandle();
221
+ // ctx.metricsFor(featureName) — shared/library code binds to a feature
222
+ // name of its own choosing instead of the dispatching handler's
223
+ // (framework#1844). Unregistered names no-op rather than throw, see
224
+ // createSafeMetricsHandle.
225
+ const metricsFor = meter
226
+ ? (targetFeatureName: string) => createSafeMetricsHandle(meter, targetFeatureName)
227
+ : () => createNoopMetricsHandle();
220
228
 
221
229
  // Cross-feature bridge. Queries and writes invoked through ctx.* share:
222
230
  // - the current transaction (tx) — nested writes roll back with the parent
@@ -571,6 +579,7 @@ export async function buildHandlerContext(
571
579
  }),
572
580
  tracer,
573
581
  metrics,
582
+ metricsFor,
574
583
  tz,
575
584
  // Cancellation signal flows from the HTTP middleware via
576
585
  // requestContext. Conditional spread so non-HTTP entry-points
@@ -58,6 +58,7 @@ export function bridgeStub(opts?: {
58
58
  | "resolveAuthClaims"
59
59
  | "hasFeature"
60
60
  | "metrics"
61
+ | "metricsFor"
61
62
  | "tracer"
62
63
  | "tz"
63
64
  | "user"
@@ -119,6 +120,7 @@ export function bridgeStub(opts?: {
119
120
  // when no effectiveFeatures resolver is wired (tests without toggles).
120
121
  hasFeature: async () => true,
121
122
  metrics: createNoopMetricsHandle(),
123
+ metricsFor: () => createNoopMetricsHandle(),
122
124
  tracer: noopTracer,
123
125
  // Echter TzContext, kein notAvailable — Test-Code nutzt ctx.tz häufig
124
126
  // ohne dass es ein "Bridge"-Konzept ist. Default UTC.