@checkstack/backend-api 0.16.0 → 0.17.1

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/CHANGELOG.md CHANGED
@@ -1,5 +1,57 @@
1
1
  # @checkstack/backend-api
2
2
 
3
+ ## 0.17.1
4
+
5
+ ### Patch Changes
6
+
7
+ - Updated dependencies [ba07ae2]
8
+ - @checkstack/healthcheck-common@1.2.0
9
+ - @checkstack/cache-api@0.3.5
10
+ - @checkstack/queue-api@0.3.5
11
+
12
+ ## 0.17.0
13
+
14
+ ### Minor Changes
15
+
16
+ - f23f3c9: Add `correlationMiddleware` to `@checkstack/backend-api` and apply it
17
+ to every plugin/core router so each request carries a stable
18
+ `x-correlation-id` (read from the inbound header, or freshly minted
19
+ via `crypto.randomUUID()` when absent) and an auto-injected child
20
+ logger bound with `{ correlationId, pluginId, userId? }`. The ID is
21
+ echoed back on the response header so the caller can correlate their
22
+ client-side trace to the server logs.
23
+
24
+ The `Logger` interface in `@checkstack/backend-api` now formally
25
+ documents the structured-metadata convention (`logger.info("msg",
26
+ { ...meta })`) alongside the long-standing varargs shape. Winston's
27
+ splat handling already routes both shapes through the same vararg
28
+ slot, so existing call sites are unaffected. A new optional
29
+ `Logger.child(meta)` method captures the metadata-binding contract the
30
+ new middleware relies on; production loggers always implement it,
31
+ minimal test mocks may omit it (the middleware falls back gracefully).
32
+
33
+ `RpcContext` grew two optional `Headers` bags, `requestHeaders` and
34
+ `responseHeaders`, populated by the outer Hono `/api/*` and `/rest/*`
35
+ handlers in `@checkstack/backend`. They are write-through observation
36
+ points for middleware; an `RpcContext` constructed without them (S2S
37
+ clients, tests) keeps working — the echo is a silent no-op and the ID
38
+ is still bound onto the child logger for server-side correlation.
39
+
40
+ The scaffolding template in `@checkstack/scripts` was updated so any
41
+ new plugin generated via `bun run create` wires the middleware in the
42
+ expected `.use(correlationMiddleware).use(autoAuthMiddleware)` order
43
+ out of the box.
44
+
45
+ ### Patch Changes
46
+
47
+ - Updated dependencies [f23f3c9]
48
+ - Updated dependencies [f23f3c9]
49
+ - @checkstack/common@0.11.0
50
+ - @checkstack/healthcheck-common@1.1.2
51
+ - @checkstack/signal-common@0.2.4
52
+ - @checkstack/cache-api@0.3.4
53
+ - @checkstack/queue-api@0.3.4
54
+
3
55
  ## 0.16.0
4
56
 
5
57
  ### Minor Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@checkstack/backend-api",
3
- "version": "0.16.0",
3
+ "version": "0.17.1",
4
4
  "license": "Elastic-2.0",
5
5
  "type": "module",
6
6
  "main": "./src/index.ts",
@@ -10,11 +10,11 @@
10
10
  "lint:code": "eslint . --max-warnings 0"
11
11
  },
12
12
  "dependencies": {
13
- "@checkstack/common": "0.10.0",
14
- "@checkstack/healthcheck-common": "1.1.0",
15
- "@checkstack/cache-api": "0.3.2",
16
- "@checkstack/queue-api": "0.3.2",
17
- "@checkstack/signal-common": "0.2.3",
13
+ "@checkstack/common": "0.11.0",
14
+ "@checkstack/healthcheck-common": "1.1.2",
15
+ "@checkstack/cache-api": "0.3.4",
16
+ "@checkstack/queue-api": "0.3.4",
17
+ "@checkstack/signal-common": "0.2.4",
18
18
  "@orpc/client": "^1.13.14",
19
19
  "@orpc/contract": "^1.13.14",
20
20
  "@orpc/openapi": "^1.13.2",
@@ -28,7 +28,7 @@
28
28
  "devDependencies": {
29
29
  "@types/bun": "latest",
30
30
  "@checkstack/tsconfig": "0.0.7",
31
- "@checkstack/scripts": "0.3.2"
31
+ "@checkstack/scripts": "0.3.3"
32
32
  },
33
33
  "peerDependencies": {
34
34
  "hono": "^4.12.14",
@@ -0,0 +1,191 @@
1
+ import { describe, expect, it, mock, type Mock } from "bun:test";
2
+ import { call, implement } from "@orpc/server";
3
+ import { z } from "zod";
4
+ import { proc } from "@checkstack/common";
5
+ import {
6
+ autoAuthMiddleware,
7
+ CORRELATION_ID_HEADER,
8
+ correlationMiddleware,
9
+ RpcContext,
10
+ } from "./rpc";
11
+ import { createMockRpcContext } from "./test-utils";
12
+ import { Logger } from "./types";
13
+
14
+ const echoLoggerProcedure = proc({
15
+ userType: "anonymous",
16
+ operationType: "query",
17
+ access: [],
18
+ }).output(
19
+ z.object({
20
+ correlationIdFromMeta: z.string().optional(),
21
+ pluginIdFromMeta: z.string().optional(),
22
+ userIdFromMeta: z.string().optional(),
23
+ }),
24
+ );
25
+
26
+ const testContract = {
27
+ echo: echoLoggerProcedure,
28
+ };
29
+
30
+ type LastChildMeta = Record<string, unknown> | undefined;
31
+
32
+ function buildRouterCapturingChildMeta(captureRef: { value: LastChildMeta }) {
33
+ const os = implement(testContract)
34
+ .$context<RpcContext>()
35
+ .use(correlationMiddleware)
36
+ .use(autoAuthMiddleware);
37
+
38
+ return os.router({
39
+ echo: os.echo.handler(({ context }) => {
40
+ // The capture happens in the child() mock on the test's logger.
41
+ // We just return the metadata that the middleware should have bound.
42
+ void context.logger;
43
+ return {
44
+ correlationIdFromMeta: captureRef.value?.correlationId as
45
+ | string
46
+ | undefined,
47
+ pluginIdFromMeta: captureRef.value?.pluginId as string | undefined,
48
+ userIdFromMeta: captureRef.value?.userId as string | undefined,
49
+ };
50
+ }),
51
+ });
52
+ }
53
+
54
+ function buildContextWithCapture(
55
+ overrides: Partial<RpcContext> = {},
56
+ ): {
57
+ context: RpcContext;
58
+ capture: { value: LastChildMeta };
59
+ childMock: Mock<(meta: Record<string, unknown>) => Logger>;
60
+ } {
61
+ const capture: { value: LastChildMeta } = { value: undefined };
62
+ const childMock = mock((meta: Record<string, unknown>): Logger => {
63
+ capture.value = meta;
64
+ return {
65
+ info: mock(),
66
+ error: mock(),
67
+ warn: mock(),
68
+ debug: mock(),
69
+ };
70
+ });
71
+ const baseContext = createMockRpcContext(overrides);
72
+ // Replace the logger so child() captures the bound metadata.
73
+ baseContext.logger = {
74
+ info: mock(),
75
+ error: mock(),
76
+ warn: mock(),
77
+ debug: mock(),
78
+ child: childMock,
79
+ };
80
+ return { context: baseContext, capture, childMock };
81
+ }
82
+
83
+ const UUID_V4_REGEX =
84
+ /^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
85
+
86
+ describe("correlationMiddleware", () => {
87
+ it("generates a UUID v4 when no x-correlation-id header is present", async () => {
88
+ const { context, capture } = buildContextWithCapture();
89
+ const router = buildRouterCapturingChildMeta(capture);
90
+
91
+ await call(router.echo, {}, { context });
92
+
93
+ expect(typeof capture.value?.correlationId).toBe("string");
94
+ expect(capture.value?.correlationId).toMatch(UUID_V4_REGEX);
95
+ });
96
+
97
+ it("uses the incoming x-correlation-id header when present", async () => {
98
+ const incoming = "11111111-2222-4333-8444-555555555555";
99
+ const { context, capture } = buildContextWithCapture({
100
+ requestHeaders: new Headers({ [CORRELATION_ID_HEADER]: incoming }),
101
+ });
102
+ const router = buildRouterCapturingChildMeta(capture);
103
+
104
+ await call(router.echo, {}, { context });
105
+
106
+ expect(capture.value?.correlationId).toBe(incoming);
107
+ });
108
+
109
+ it("ignores an empty x-correlation-id header and generates a fresh ID", async () => {
110
+ const { context, capture } = buildContextWithCapture({
111
+ requestHeaders: new Headers({ [CORRELATION_ID_HEADER]: "" }),
112
+ });
113
+ const router = buildRouterCapturingChildMeta(capture);
114
+
115
+ await call(router.echo, {}, { context });
116
+
117
+ expect(capture.value?.correlationId).toMatch(UUID_V4_REGEX);
118
+ });
119
+
120
+ it("echoes the correlation ID on responseHeaders when available", async () => {
121
+ const responseHeaders = new Headers();
122
+ const { context, capture } = buildContextWithCapture({
123
+ responseHeaders,
124
+ });
125
+ const router = buildRouterCapturingChildMeta(capture);
126
+
127
+ await call(router.echo, {}, { context });
128
+
129
+ const echoed = responseHeaders.get(CORRELATION_ID_HEADER);
130
+ expect(echoed).not.toBeNull();
131
+ expect(echoed).toBe(capture.value?.correlationId as string);
132
+ });
133
+
134
+ it("binds pluginId from context.pluginMetadata onto the child logger", async () => {
135
+ const { context, capture } = buildContextWithCapture({
136
+ pluginMetadata: { pluginId: "fancy-plugin" },
137
+ });
138
+ const router = buildRouterCapturingChildMeta(capture);
139
+
140
+ await call(router.echo, {}, { context });
141
+
142
+ expect(capture.value?.pluginId).toBe("fancy-plugin");
143
+ });
144
+
145
+ it("binds userId when the user has an id field", async () => {
146
+ const { context, capture } = buildContextWithCapture({
147
+ user: { type: "user", id: "user-abc", accessRules: ["*"] },
148
+ });
149
+ const router = buildRouterCapturingChildMeta(capture);
150
+
151
+ await call(router.echo, {}, { context });
152
+
153
+ expect(capture.value?.userId).toBe("user-abc");
154
+ });
155
+
156
+ it("omits userId when the user is a service (no id field)", async () => {
157
+ const { context, capture } = buildContextWithCapture({
158
+ user: { type: "service", pluginId: "internal-svc" },
159
+ });
160
+ const router = buildRouterCapturingChildMeta(capture);
161
+
162
+ await call(router.echo, {}, { context });
163
+
164
+ expect("userId" in (capture.value ?? {})).toBe(false);
165
+ });
166
+
167
+ it("falls back to the base logger when child() is not implemented", async () => {
168
+ // Reproduce a minimal mock logger without `.child` (e.g. a test mock
169
+ // that hasn't been updated). The middleware must not throw and the
170
+ // request must still complete.
171
+ const context = createMockRpcContext();
172
+ context.logger = {
173
+ info: mock(),
174
+ error: mock(),
175
+ warn: mock(),
176
+ debug: mock(),
177
+ // intentionally no child
178
+ };
179
+
180
+ const os = implement(testContract)
181
+ .$context<RpcContext>()
182
+ .use(correlationMiddleware)
183
+ .use(autoAuthMiddleware);
184
+ const router = os.router({
185
+ echo: os.echo.handler(() => ({})),
186
+ });
187
+
188
+ const result = await call(router.echo, {}, { context });
189
+ expect(result).toEqual({});
190
+ });
191
+ });
package/src/rpc.ts CHANGED
@@ -53,6 +53,21 @@ export interface RpcContext {
53
53
  cacheManager: CacheManager;
54
54
  /** Emit a hook event for cross-plugin communication */
55
55
  emitHook: EmitHookFn;
56
+ /**
57
+ * Inbound HTTP request headers (read-only view). Populated by the
58
+ * `/api/*` and `/rest/*` Hono handlers in `core/backend`. Optional
59
+ * because non-HTTP call sites (S2S clients, tests, scheduled queue
60
+ * jobs) can construct an `RpcContext` without a backing request.
61
+ */
62
+ requestHeaders?: Headers;
63
+ /**
64
+ * Mutable response headers. Middleware (e.g. `correlationMiddleware`)
65
+ * can set headers here, and the Hono handler that drives the oRPC
66
+ * `RPCHandler` / `OpenAPIHandler` merges them onto the actual
67
+ * `Response` after the procedure has run. Optional for the same
68
+ * reason as `requestHeaders`.
69
+ */
70
+ responseHeaders?: Headers;
56
71
  }
57
72
 
58
73
  /** Context with authenticated real user */
@@ -461,6 +476,91 @@ export const autoAuthMiddleware = os.middleware(
461
476
  },
462
477
  );
463
478
 
479
+ // =============================================================================
480
+ // CORRELATION ID MIDDLEWARE
481
+ // =============================================================================
482
+
483
+ /**
484
+ * Name of the inbound and outbound HTTP header that carries the correlation
485
+ * ID. Exported so dev tools, integration tests, and front-end fetch wrappers
486
+ * can refer to the canonical value rather than hard-coding the string.
487
+ */
488
+ export const CORRELATION_ID_HEADER = "x-correlation-id";
489
+
490
+ /**
491
+ * Per-request observability middleware.
492
+ *
493
+ * Behaviour:
494
+ * - Reads `x-correlation-id` from `context.requestHeaders` (populated by the
495
+ * `/api/*` and `/rest/*` Hono handlers in `core/backend`).
496
+ * - Generates a fresh UUID v4 via `crypto.randomUUID()` if absent. This is
497
+ * the ONLY generation site for correlation IDs in the platform — handlers
498
+ * must NOT mint new IDs on their own.
499
+ * - Binds `{ correlationId, pluginId, userId? }` onto a child logger via
500
+ * `ctx.logger.child(...)` so every subsequent log line in the request
501
+ * carries that metadata automatically.
502
+ * - Writes the ID back to `context.responseHeaders` (if available) so the
503
+ * outer Hono handler can echo `x-correlation-id` on the response, letting
504
+ * the caller correlate their own client-side trace to the server log.
505
+ *
506
+ * Note on the echo: oRPC middleware has no direct access to the outgoing
507
+ * `Response` object — the framework constructs it from the procedure's
508
+ * return value AFTER middleware has finished. We use the mutable
509
+ * `responseHeaders` bag on `RpcContext` as a thin write-through: the Hono
510
+ * route handler merges those headers onto the `Response` post-handle. When
511
+ * an `RpcContext` is constructed without `responseHeaders` (S2S clients,
512
+ * tests), the echo silently no-ops; the ID is still bound to the child
513
+ * logger so server-side correlation still works.
514
+ *
515
+ * Order matters: in plugin routers, `.use(correlationMiddleware)` MUST
516
+ * appear BEFORE `.use(autoAuthMiddleware)` so that auth failures still log
517
+ * with the correlation ID attached.
518
+ *
519
+ * Usage:
520
+ *
521
+ * const os = implement(myContract)
522
+ * .$context<RpcContext>()
523
+ * .use(correlationMiddleware)
524
+ * .use(autoAuthMiddleware);
525
+ */
526
+ export const correlationMiddleware = os.middleware(
527
+ async ({ next, context }) => {
528
+ const incoming = context.requestHeaders?.get(CORRELATION_ID_HEADER);
529
+ const correlationId =
530
+ incoming && incoming.length > 0 ? incoming : crypto.randomUUID();
531
+
532
+ context.responseHeaders?.set(CORRELATION_ID_HEADER, correlationId);
533
+
534
+ const meta: Record<string, unknown> = {
535
+ correlationId,
536
+ pluginId: context.pluginMetadata.pluginId,
537
+ };
538
+ if (context.user && "id" in context.user) {
539
+ meta.userId = context.user.id;
540
+ }
541
+
542
+ // `child` is optional on the Logger interface so minimal test-mock
543
+ // loggers don't have to implement it. Production Winston loggers
544
+ // always do; gracefully fall back to the base logger otherwise so
545
+ // the middleware never breaks a request just because metadata
546
+ // binding wasn't possible.
547
+ const boundLogger = context.logger.child
548
+ ? context.logger.child(meta)
549
+ : context.logger;
550
+
551
+ // Partial-merge style (no `...context` spread): oRPC merges the
552
+ // returned context fields onto the existing context. Spreading
553
+ // would widen TypeScript's inferred chain type and surface
554
+ // TS2883 "inferred type cannot be named" errors in downstream
555
+ // packages whose routers compose this middleware.
556
+ return next({
557
+ context: {
558
+ logger: boundLogger,
559
+ },
560
+ });
561
+ },
562
+ );
563
+
464
564
  /**
465
565
  * Extract a nested value from an object using dot notation.
466
566
  * E.g., getNestedValue({ params: { id: "123" } }, "params.id") => "123"
package/src/test-utils.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import { mock } from "bun:test";
2
2
  import { RpcContext, EmitHookFn } from "./rpc";
3
+ import { Logger } from "./types";
3
4
  import { SafeDatabase } from "./plugin-system";
4
5
  import { HealthCheckRegistry } from "./health-check";
5
6
  import { CollectorRegistry } from "./collector-registry";
@@ -9,6 +10,22 @@ import {
9
10
  CacheManager,
10
11
  } from "@checkstack/cache-api";
11
12
 
13
+ /**
14
+ * Build a mock `Logger` whose `.child(...)` returns another mock logger
15
+ * (recursively). Matches the structural contract that
16
+ * `correlationMiddleware` and other binding sites rely on.
17
+ */
18
+ function createMockLogger(): Logger {
19
+ const logger: Logger = {
20
+ info: mock(),
21
+ error: mock(),
22
+ warn: mock(),
23
+ debug: mock(),
24
+ child: mock(() => createMockLogger()),
25
+ };
26
+ return logger;
27
+ }
28
+
12
29
  /**
13
30
  * Creates a mocked oRPC context for testing.
14
31
  * By default provides an authenticated user with wildcard access.
@@ -19,12 +36,7 @@ export function createMockRpcContext(
19
36
  return {
20
37
  pluginMetadata: { pluginId: "test-plugin" },
21
38
  db: mock() as unknown as SafeDatabase<Record<string, unknown>>,
22
- logger: {
23
- info: mock(),
24
- error: mock(),
25
- warn: mock(),
26
- debug: mock(),
27
- },
39
+ logger: createMockLogger(),
28
40
  fetch: {
29
41
  fetch: mock(),
30
42
  forPlugin: mock().mockReturnValue({
package/src/types.ts CHANGED
@@ -1,11 +1,45 @@
1
1
  import { ZodSchema } from "zod";
2
2
  import { ClientDefinition, InferClient } from "@checkstack/common";
3
3
 
4
+ /**
5
+ * Backend logger interface used everywhere in the platform via `RpcContext.logger`
6
+ * and the various `coreServices.logger` accessors.
7
+ *
8
+ * Each method accepts a free-form trailing argument list (`...args: unknown[]`)
9
+ * so the long-standing varargs callsites — `logger.error("…", err)` where `err`
10
+ * is an `Error`, or `logger.info("…", value1, value2)` — keep working unchanged.
11
+ *
12
+ * For NEW code, prefer the structured-metadata shape:
13
+ *
14
+ * logger.info("did something", { userId, durationMs });
15
+ *
16
+ * Winston's `splat` handling treats a single trailing plain object as
17
+ * structured metadata (merged into the log entry), and an `Error` instance as
18
+ * a special-cased error (with stack). Either shape lands in the same vararg
19
+ * slot here, so this signature covers both without overload churn.
20
+ *
21
+ * Auto-injected metadata (when the request flows through
22
+ * `correlationMiddleware`): `{ correlationId, pluginId, userId? }`. Do NOT
23
+ * include secrets in the structured-metadata object — it is forwarded
24
+ * verbatim to the log destination.
25
+ */
4
26
  export interface Logger {
5
27
  info(message: string, ...args: unknown[]): void;
6
28
  error(message: string, ...args: unknown[]): void;
7
29
  warn(message: string, ...args: unknown[]): void;
8
30
  debug(message: string, ...args: unknown[]): void;
31
+ /**
32
+ * Returns a derived logger with the supplied metadata bound to every
33
+ * subsequent log entry. Used by `correlationMiddleware` to attach
34
+ * `{ correlationId, pluginId, userId? }`, and available to handlers that
35
+ * want a tighter scope (e.g. `ctx.logger.child({ jobId })`).
36
+ *
37
+ * Optional only to keep minimal test-mock logger objects compatible with
38
+ * this interface — production loggers (Winston via `core/backend`) always
39
+ * implement it. Call sites that rely on metadata binding should branch
40
+ * on presence and fall back to the base logger when it is not available.
41
+ */
42
+ child?(meta: Record<string, unknown>): Logger;
9
43
  }
10
44
 
11
45
  export interface Fetch {