diff --git a/.changeset/observability-compute-driven.md b/.changeset/observability-compute-driven.md new file mode 100644 index 000000000..1efc9f659 --- /dev/null +++ b/.changeset/observability-compute-driven.md @@ -0,0 +1,25 @@ +--- +"@aws-blocks/core": minor +"@aws-blocks/blocks": minor +"@aws-blocks/bb-lambda-compute": minor +"@aws-blocks/bb-dashboard": minor +"@aws-blocks/bb-logger": minor +"@aws-blocks/bb-tracer": minor +--- + +Make observability **compute-driven** so it composes correctly once an app has more than one compute. Logging, tracing, and the dashboard now key off compute state rather than off the Logger / Tracer / Dashboard blocks poking a single implicit compute. + +**Logging is always on; retention is a compute-level setting.** Every compute captures stdout to its own log group unconditionally — there is no "enable logging" step. The retention of that group is set per compute via a new `logRetention` prop on `LambdaCompute` (`@aws-blocks/bb-lambda-compute`), falling back to `defaults.logRetention`. Log **level** is purely per-instance runtime behavior: set it via a `Logger`'s `level` option (default `'info'`). There is no app-wide log-level default and no `LOG_LEVEL` env var. + +**Tracing is presence-gated.** Creating any `Tracer` in the app now enables X-Ray on **every** compute (X-Ray provisions real, costed infrastructure, so it stays off until the app opts in by constructing a Tracer). This replaces the previous model where a Tracer turned on tracing for one implicit compute. `@aws-blocks/core/cdk` adds `registerTracer()` (records Tracer presence) and `finalizeTracing()` (enables tracing on all computes at finalize); `create()` runs it before finalizing dashboards. `Compute.enableTracing()` is now idempotent. + +**The dashboard is organized by compute, with display toggles.** `DashboardOptions` gains `logs?: boolean` (default `true`) and `traces?: boolean` (default `true`) — app-wide display toggles applied uniformly to every compute section. `logs:false` hides the (always-captured) logs section; `traces:false` hides traces even when tracing is enabled. + +The dashboard covers **every** compute in the app (resolved at finalize, so construction order never matters). Each compute renders a health section always, a logs section (unless `logs:false`), and a traces section only when tracing is enabled on it (unless `traces:false`). Metrics remain app-scoped and are passed explicitly. No `computes` selector is exposed yet — it would leak the internal `Compute` type before customers can construct a compute; it arrives with the multi-compute surface. + +**⚠️ Behavior / API changes:** + +- **`Logger` no longer reconfigures log retention.** The CDK `Logger` is now a no-op placeholder (logging is always on and retention moved to the compute). The `retention` option was removed from `LoggingOptions`; set `logRetention` on the compute instead. +- **A `Tracer` now enables X-Ray on all computes, not one.** Any Tracer in the app turns on tracing fleet-wide. +- **`Logger` no longer reads the `LOG_LEVEL` environment variable.** Log level is set solely via the per-`Logger` `level` option (default `'info'`); the previously supported `LOG_LEVEL` env-var override has been removed, and Blocks stamps no app-wide log-level config. `BlocksDefaults` has no `logLevel` field. +- **Removed the deprecated `LoggerBBRef` / `TracerBBRef` dashboard types.** They were no longer consumed — the dashboard reads compute state directly. Loggers and Tracers were never passed to the Dashboard in this model. diff --git a/.changeset/observability-compute-test-stubs.md b/.changeset/observability-compute-test-stubs.md new file mode 100644 index 000000000..e9f911ef2 --- /dev/null +++ b/.changeset/observability-compute-test-stubs.md @@ -0,0 +1,13 @@ +--- +"@aws-blocks/bb-async-job": patch +"@aws-blocks/bb-cron-job": patch +--- + +test: adapt CDK test doubles to the compute-driven observability contract + +Test-only change: both packages' CDK tests use a stub `Compute` that must satisfy +the `Compute` base class. The compute-driven observability work adds abstract +observability hooks to `Compute` (`healthWidgets` / `loggingWidgets` / +`tracingWidgets`, and `applyTracing`), so the stubs now implement them (as no-ops +that fail the test if the block ever pokes the compute). No runtime or public API +change. diff --git a/package-lock.json b/package-lock.json index 1df2012e5..03cd60023 100644 --- a/package-lock.json +++ b/package-lock.json @@ -360,6 +360,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -376,6 +377,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">= 10" } @@ -392,6 +394,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -408,6 +411,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -424,6 +428,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -440,6 +445,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">= 10" } @@ -456,6 +462,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -472,6 +479,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -488,6 +496,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">= 10" } @@ -24896,6 +24905,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24912,6 +24922,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24928,6 +24939,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24944,6 +24956,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24960,6 +24973,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24976,6 +24990,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -24992,6 +25007,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -25008,6 +25024,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT OR Apache-2.0", "optional": true, "os": [ @@ -29632,6 +29649,7 @@ "os": [ "aix" ], + "peer": true, "engines": { "node": ">=18" } @@ -29648,6 +29666,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -29664,6 +29683,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -29680,6 +29700,7 @@ "os": [ "android" ], + "peer": true, "engines": { "node": ">=18" } @@ -29696,6 +29717,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -29712,6 +29734,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": ">=18" } @@ -29728,6 +29751,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -29744,6 +29768,7 @@ "os": [ "freebsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -29760,6 +29785,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29776,6 +29802,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29792,6 +29819,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29808,6 +29836,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29824,6 +29853,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29840,6 +29870,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29856,6 +29887,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29872,6 +29904,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29888,6 +29921,7 @@ "os": [ "linux" ], + "peer": true, "engines": { "node": ">=18" } @@ -29904,6 +29938,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -29920,6 +29955,7 @@ "os": [ "netbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -29936,6 +29972,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -29952,6 +29989,7 @@ "os": [ "openbsd" ], + "peer": true, "engines": { "node": ">=18" } @@ -29968,6 +30006,7 @@ "os": [ "sunos" ], + "peer": true, "engines": { "node": ">=18" } @@ -29984,6 +30023,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -30000,6 +30040,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -30016,6 +30057,7 @@ "os": [ "win32" ], + "peer": true, "engines": { "node": ">=18" } @@ -47673,6 +47715,7 @@ "os": [ "darwin" ], + "peer": true, "engines": { "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } @@ -51821,6 +51864,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51837,6 +51881,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51853,6 +51898,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51869,6 +51915,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51885,6 +51932,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51901,6 +51949,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51917,6 +51966,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51933,6 +51983,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51949,6 +52000,7 @@ "cpu": [ "arm" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51965,6 +52017,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51981,6 +52034,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -51997,6 +52051,7 @@ "cpu": [ "loong64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52013,6 +52068,7 @@ "cpu": [ "mips64el" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52029,6 +52085,7 @@ "cpu": [ "ppc64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52045,6 +52102,7 @@ "cpu": [ "riscv64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52061,6 +52119,7 @@ "cpu": [ "s390x" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52077,6 +52136,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52093,6 +52153,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52109,6 +52170,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52125,6 +52187,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52141,6 +52204,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52157,6 +52221,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52173,6 +52238,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52189,6 +52255,7 @@ "cpu": [ "arm64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52205,6 +52272,7 @@ "cpu": [ "ia32" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -52221,6 +52289,7 @@ "cpu": [ "x64" ], + "dev": true, "license": "MIT", "optional": true, "os": [ @@ -55905,6 +55974,7 @@ "@aws-blocks/core": "^0.3.0" }, "devDependencies": { + "@aws-blocks/bb-lambda-compute": "^0.3.0", "@types/node": "^20.0.0", "typescript": "^5.3.0" }, @@ -56681,6 +56751,7 @@ "@aws-blocks/hosting": "^0.2.0" }, "bin": { + "blocks-generate-client": "dist/generate-client-cli.js", "blocks-vendorize": "dist/vendorize.js" }, "devDependencies": { diff --git a/packages/bb-async-job/src/index.cdk.test.ts b/packages/bb-async-job/src/index.cdk.test.ts index 0c0286f84..61be7ef74 100644 --- a/packages/bb-async-job/src/index.cdk.test.ts +++ b/packages/bb-async-job/src/index.cdk.test.ts @@ -21,18 +21,19 @@ * synthesize it.) */ -import { test, describe, before, after } from 'node:test'; import assert from 'node:assert'; import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; -import { fileURLToPath } from 'node:url'; import { dirname, join } from 'node:path'; -import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; -import { BlocksStack, BlocksPresets, Scope } from '@aws-blocks/core/cdk'; +import { after, before, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { LambdaCompute } from '@aws-blocks/bb-lambda-compute/cdk'; import { isBlocksError } from '@aws-blocks/core'; +import { BlocksPresets, BlocksStack, Scope } from '@aws-blocks/core/cdk'; import type { DefaultComputeFactory } from '@aws-blocks/core/cdk/internal'; import { Compute } from '@aws-blocks/core/cdk/internal'; -import { LambdaCompute } from '@aws-blocks/bb-lambda-compute/cdk'; +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import { AsyncJob, AsyncJobErrors } from './index.cdk.js'; const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as never, 'DefaultCompute'); @@ -40,6 +41,17 @@ const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as /** A non-Lambda compute, to exercise the "unsupported compute" synth guard. */ class FakeCompute extends Compute { setEnv(_key: string, _value: string): void {} + // Observability hooks are irrelevant here — stub them to satisfy Compute. + protected applyTracing(): void {} + protected healthWidgets(): IWidget[][] { + return []; + } + protected loggingWidgets(): IWidget[][] { + return []; + } + protected tracingWidgets(): IWidget[][] { + return []; + } } const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -261,7 +273,12 @@ describe('AsyncJob option guards', () => { test('CDK guard: maxBatchingWindowSeconds 301 is rejected', async () => { const stack = await makeStack('AsyncGuardWindow301'); assertInvalidOption( - () => new AsyncJob(stack, 'jobs', { handler: async () => {}, maxBatchingWindowSeconds: 301, trackStatus: false }), + () => + new AsyncJob(stack, 'jobs', { + handler: async () => {}, + maxBatchingWindowSeconds: 301, + trackStatus: false, + }), /maxBatchingWindowSeconds/, /got: 301/, ); @@ -270,7 +287,12 @@ describe('AsyncJob option guards', () => { test('CDK guard: a negative maxBatchingWindowSeconds is rejected', async () => { const stack = await makeStack('AsyncGuardWindowNeg'); assertInvalidOption( - () => new AsyncJob(stack, 'jobs', { handler: async () => {}, maxBatchingWindowSeconds: -1, trackStatus: false }), + () => + new AsyncJob(stack, 'jobs', { + handler: async () => {}, + maxBatchingWindowSeconds: -1, + trackStatus: false, + }), /maxBatchingWindowSeconds/, /got: -1/, ); @@ -279,7 +301,12 @@ describe('AsyncJob option guards', () => { test('CDK guard: a fractional maxBatchingWindowSeconds is rejected', async () => { const stack = await makeStack('AsyncGuardWindowFrac'); assertInvalidOption( - () => new AsyncJob(stack, 'jobs', { handler: async () => {}, maxBatchingWindowSeconds: 2.5, trackStatus: false }), + () => + new AsyncJob(stack, 'jobs', { + handler: async () => {}, + maxBatchingWindowSeconds: 2.5, + trackStatus: false, + }), /maxBatchingWindowSeconds/, /got: 2.5/, ); @@ -288,7 +315,12 @@ describe('AsyncJob option guards', () => { test('CDK guard: NaN maxBatchingWindowSeconds is rejected', async () => { const stack = await makeStack('AsyncGuardWindowNaN'); assertInvalidOption( - () => new AsyncJob(stack, 'jobs', { handler: async () => {}, maxBatchingWindowSeconds: NaN, trackStatus: false }), + () => + new AsyncJob(stack, 'jobs', { + handler: async () => {}, + maxBatchingWindowSeconds: NaN, + trackStatus: false, + }), /maxBatchingWindowSeconds/, /got: NaN/, ); diff --git a/packages/bb-cron-job/src/index.cdk.test.ts b/packages/bb-cron-job/src/index.cdk.test.ts index 7bd0b4bc8..f049228b6 100644 --- a/packages/bb-cron-job/src/index.cdk.test.ts +++ b/packages/bb-cron-job/src/index.cdk.test.ts @@ -1,6 +1,9 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import assert from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; /** * CDK-side tests for CronJob: compute targeting + synth-time schedule validation. * @@ -15,18 +18,16 @@ * compute), so an invalid expression fails fast rather than minutes into the * deploy. */ -import { test, describe, before, after } from 'node:test'; -import assert from 'node:assert'; -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { after, before, describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; -import { dirname, join } from 'node:path'; -import * as cdk from 'aws-cdk-lib'; -import { Template } from 'aws-cdk-lib/assertions'; -import { BlocksStack, BlocksPresets, Scope } from '@aws-blocks/core/cdk'; +import { LambdaCompute } from '@aws-blocks/bb-lambda-compute/cdk'; import { isBlocksError } from '@aws-blocks/core'; +import { BlocksPresets, BlocksStack, Scope } from '@aws-blocks/core/cdk'; import type { DefaultComputeFactory } from '@aws-blocks/core/cdk/internal'; import { Compute } from '@aws-blocks/core/cdk/internal'; -import { LambdaCompute } from '@aws-blocks/bb-lambda-compute/cdk'; +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import { CronJob, CronJobErrors } from './index.cdk.js'; const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as never, 'DefaultCompute'); @@ -34,6 +35,17 @@ const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as /** A non-Lambda compute, to exercise the "unsupported compute" synth guard. */ class FakeCompute extends Compute { setEnv(_key: string, _value: string): void {} + // Observability hooks are irrelevant here — stub them to satisfy Compute. + protected applyTracing(): void {} + protected healthWidgets(): IWidget[][] { + return []; + } + protected loggingWidgets(): IWidget[][] { + return []; + } + protected tracingWidgets(): IWidget[][] { + return []; + } } const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -126,9 +138,7 @@ describe('CronJob synth-time schedule validation', () => { test('accepts a valid schedule and synthesizes a CfnSchedule', async () => { const stack = await makeStack('CronValid'); - assert.doesNotThrow( - () => new CronJob(stack, 'job', { schedule: 'rate(5 minutes)', handler: async () => {} }), - ); + assert.doesNotThrow(() => new CronJob(stack, 'job', { schedule: 'rate(5 minutes)', handler: async () => {} })); Template.fromStack(stack).resourceCountIs('AWS::Scheduler::Schedule', 1); }); diff --git a/packages/bb-dashboard/API.md b/packages/bb-dashboard/API.md index 04b509b4a..57aa43316 100644 --- a/packages/bb-dashboard/API.md +++ b/packages/bb-dashboard/API.md @@ -23,18 +23,11 @@ export const DashboardErrors: { export interface DashboardOptions { dashboardName?: string; defaultTimeRange?: string; - logger?: LoggerBBRef; - metricConfigs?: MetricConfig[]; - metrics?: MetricsBBRef; + logs?: boolean; + metrics?: MetricsSource | MetricsSource[]; routePath?: string | false; title?: string; - tracer?: TracerBBRef; -} - -// @public -export interface LoggerBBRef { - // (undocumented) - readonly fullId: string; + traces?: boolean; } // @public @@ -54,9 +47,9 @@ export interface MetricsBBRef { } // @public -export interface TracerBBRef { - // (undocumented) - readonly fullId: string; +export interface MetricsSource { + metricConfigs?: MetricConfig[]; + metrics: MetricsBBRef; } // (No @packageDocumentation comment for this package) diff --git a/packages/bb-dashboard/DESIGN.md b/packages/bb-dashboard/DESIGN.md index 15fc9ef69..b1fba7c37 100644 --- a/packages/bb-dashboard/DESIGN.md +++ b/packages/bb-dashboard/DESIGN.md @@ -10,7 +10,7 @@ Design document for Dashboard. For usage, see [README.md](./README.md). ### D-DB-1: Structural typing for observability BB composition -**Decision:** `options.logger`, `options.metrics`, and `options.tracer` use structural typing. The Dashboard BB accepts any object with `fullId` or `namespace` properties, not specific BB class instances. +**Decision:** `options.metrics` uses structural typing (`MetricsBBRef`: any object with `namespace` and optional `defaultDimensions`), not the Metrics BB class. (Logger/Tracer are no longer dashboard inputs — they attach to a compute, and the compute self-reports; see D-DB-8.) **Rationale:** - **Loose coupling** — Dashboard doesn't depend on Logger, Metrics, or Tracer BB class definitions @@ -59,15 +59,15 @@ Design document for Dashboard. For usage, see [README.md](./README.md). - **UX improvement** — Showing "Insufficient data" is better than widgets missing entirely until first emission - **Opt-in** — Customers who don't use custom metrics leave this empty -### D-DB-6: Log group name from the framework-owned handler group +### D-DB-6: Compute-derived log group name -**Decision:** When a `logger` BB is provided, Dashboard points its log widgets at the shared handler's CloudWatch log group via `scope.handlerLogGroup.logGroupName`. It falls back to the `/aws/lambda/${functionName}` convention only when a group name isn't supplied. +**Decision:** The compute derives its own log group name (`/aws/lambda/${functionName}` for `LambdaCompute`) inside its `loggingWidgets` builder; the Dashboard never computes a log group name. **Rationale:** -- **Framework-owned group** — the BlocksStack/BlocksBackend now provisions a dedicated handler log group (so its retention follows `defaults.logRetention`); that group has a CDK-generated name, **not** `/aws/lambda/{FunctionName}`. Reconstructing the old convention would point the widgets at a group the handler no longer writes to. -- **Zero configuration** — No need to pass `logGroupName` explicitly if a Logger BB is connected -- **Consistency** — If Logger BB exists, the handler's actual logs are automatically queried -- **Fallback** — If no Logger BB is provided, no log widgets appear (expected behavior) +- **Standard pattern** — AWS Lambda always creates logs in `/aws/lambda/{FunctionName}` by default; a container compute would derive its own stream instead +- **Zero configuration** — No `logGroupName` to pass anywhere; attaching a Logger to the compute is the only signal +- **Right owner** — The log group belongs to the compute's physical resources, so only the compute can name it correctly (see D-DB-8) +- **Fallback** — A compute with no Logger attached reports no `logging` section, so no log widgets appear (expected behavior) ### D-DB-7: Scope, composition guidance, and cost model @@ -76,9 +76,115 @@ Design document for Dashboard. For usage, see [README.md](./README.md). **Rationale:** - **When it fits** — Teams that want operational visibility into a deployed application without hand-building CloudWatch dashboards. - **When it does not** — Fully custom widget layouts are better served by the CloudWatch console directly; data-inspection admin UIs belong in `AdminSite`, not here. (complements D-DB-3, which covers why we lean on CloudWatch's native dashboard over a custom UI) -- **Composition guidance** — Connect all three observability BBs (Metrics, Logger, Tracer) for full visibility; use `title` to distinguish dashboards across multi-stage deployments; keep the default widget set for standard apps and use `widgets` only for custom additions. +- **Composition guidance** — Health + logs render for every compute automatically; add a `Tracer` anywhere in the app for the traces sections, and pass Metrics source(s) to the dashboard. Use the `logs` / `traces` toggles to hide sections and `title` to distinguish dashboards across multi-stage deployments. (There is no public `computes` selector yet — the dashboard covers every compute; see D-DB-10.) - **Cost model** — CloudWatch Dashboards are free for up to 3 dashboards (50 metrics each); beyond that they cost $3/dashboard/month. There is no runtime cost — dashboards are read-only views over existing CloudWatch data. This is the concrete pricing behind D-DB-3's "zero runtime cost" claim. +## Multi-Compute Dashboard (Implemented) + +> **Status:** implemented; no compute selector is exposed yet. The dashboard is +> organized **by compute** — it renders each compute as a group (health always; +> logs always; traces only when the app contains a `Tracer`) and app-wide metrics +> sections after them, one per `MetricsSource`. It exposes `logs` / `traces` +> display toggles but **no public `computes` option**: it always covers every +> compute in the app (`getComputes()` at finalize), which is complete today +> because there is exactly one compute. A `computes` selector arrives with the +> multi-compute customer surface — exposing it now would leak the internal +> `Compute` type before customers can construct one (see D-DB-10). There are no +> `logger` / `tracer` options — the dashboard reads compute state directly. + +### The two axes: compute-scoped vs app-scoped observability + +The four sections split cleanly by what they derive from: + +| Section | Scope | Derives from | On the dashboard | +|---|---|---|---| +| Health (Invocations/Errors/Duration, or CPU/Memory for containers) | **compute** | the compute's own service/function metrics | grouped under its compute | +| Logs | **compute** | the compute's log group (`/aws/lambda/{fn}`, or the container's stream) | grouped under its compute | +| Traces | **compute** | X-Ray filtered to the compute's function/service | grouped under its compute | +| Metrics | **app** | a CloudWatch namespace (EMF; defaults to the Metrics BB's `fullId`) | one app-wide section, **not** per compute | + +Health/logs/traces are defined by a compute's *physical resources*, so they belong grouped under their compute. Metrics are a semantic, app-level namespace that any compute can emit into — containers change only the *emission wiring* (a container needs the CloudWatch agent / FireLens to auto-extract EMF, vs Lambda's turnkey stdout path) and optionally invite a per-compute *dimension*; neither binds a namespace to a compute. So metrics stays app-wide. + +### Target layout + +One dashboard, grouped by compute, with metrics as a trailing app-wide section: + +``` +# dashboard +## Compute — api (Lambda) + health (always) + traces (only when the app contains a Tracer) + logs (always) +## Compute — worker (Container) + health + traces + logs +## Metrics (app-wide) + namespace "orders": OrdersPlaced, Latency p99 … + namespace "billing": … +``` + +### D-DB-8: Compute is the grouping unit; the compute self-reports its section + +**Decision:** For compute-scoped sections, the dashboard takes the computes to +render and asks each to self-report through a **single public entry**, +`compute.dashboardSection(region): ComputeDashboardSection` (core), returning +`{ label, health, logging?, tracing? }`. `health` and `logging` are **always** +present (logs are always captured for a compute); `tracing` is present only when +tracing is enabled on the compute. The `tracerEnabled` flag is **private** on +`Compute` — flipped only by `enableTracing()` (which the framework calls on +every compute when the app contains a `Tracer`), never settable from outside — +and the per-kind builders (`healthWidgets` / `loggingWidgets` / `tracingWidgets`) +are `protected`, so a caller cannot obtain trace widgets for an untraced compute. +The dashboard's `logs` / `traces` options are a **display** choice layered on top +(hide an otherwise-present section); they never fabricate one. + +**Rationale:** +- Log group and trace target belong to the compute, not to the Logger/Tracer BB — so the compute is the only thing that can build the right widgets for a given compute. +- Keeps the dashboard a **pure aggregator** (it never computes a query itself), consistent with D-DB-3 and the "thin block" principle. +- Logging is unconditional (every compute captures stdout), so its section is always available; only tracing — which provisions costed X-Ray infra — is gated, and it is gated on compute state, not on a Dashboard parameter. +- Encapsulation: the traces section can't be fabricated or bypassed — the flag and the infra move together through `enableTracing()` (template-method pattern), and the gating lives in one place. + +### D-DB-9: Metrics stays an explicit, app-wide input + +**Decision:** Metrics is **not** part of the per-compute grouping and is **not** auto-discovered. It is an explicit option `metrics?: MetricsSource | MetricsSource[]`, where each `MetricsSource` pairs a Metrics BB with **its own** `metricConfigs` (metric names are namespace-specific, so configs are per-source, not dashboard-wide). Each source renders once as an app-wide section, one per namespace, after the compute groups. + +**Rationale:** +- A namespace is app-level and receives from any compute; auto-including it per compute would duplicate it across every compute group. +- Nothing about a Metrics BB registers against a compute (unlike Logger/Tracer), so the compute has no signal to self-report metrics. +- Pairing configs with their source prevents cross-namespace ambiguity: `OrdersPlaced` belongs to the orders namespace, not billing. +- Per-compute disambiguation, when wanted, is a `defaultDimensions` choice on the Metrics BB — not a namespace-to-compute binding. + +### D-DB-10: No compute selector exposed yet; cover every compute at finalize + +**Decision:** The dashboard exposes **no** `computes` option. Its finalizer always +renders every compute in the app, resolved by enumerating `getComputes()` at the +finalize pass (see D-DB-11). It does expose `logs` / `traces` display toggles. + +**Rationale:** +- **Nothing is lost today.** There is exactly one compute (the default), so "cover every compute" is complete. `getComputes()` at finalize also means a compute constructed after the Dashboard is still included — no construction-order gap. +- **Don't leak an internal type early.** `Compute` is `@internal` and not customer-instantiable. A public `computes?: Compute[]` option would leak that type through the public API before a customer could construct a compute to pass — a worse experience than not having the option. It stays out until the multi-compute customer surface lands. +- **The seam is ready.** Because the body is built at finalize over `getComputes()` (D-DB-11), adding `computes?: Compute[]` later is a pure addition: resolve `options.computes ?? getComputes(this)`, where an explicit list restricts (and orders) the rendered computes and omitting it keeps the default. A `TODO(multi-compute)` in `index.cdk.ts` records this intended behavior. +- Logs/traces are **not** a compute selector — they are per-section display toggles (`logs` / `traces`), applied uniformly to every rendered compute (see D-DB-8). + +### D-DB-11: Build the widget body at finalize, not in the constructor + +**Decision:** The Dashboard does **not** assemble its widgets in its constructor. It creates the `CwDashboard` resource eagerly (so the `url`, redirect route, and config registration never point at a resource that does not exist) and registers a deferred body-build (`registerDashboardFinalizer` from core) that enumerates the app's computes via `getComputes()`, calls `compute.dashboardSection(region)` on each, and adds the widgets via `dashboard.addWidgets(...)`; that runs via `finalizeDashboards()` at the end of `BlocksStack`/`BlocksBackend.create()`, after the backend module has fully imported. Only the widget *body* is deferred; the resource, `dashboardName`, `url`, the redirect route, and the config registration stay in the constructor (they need nothing from other blocks). There is no `options.computes` yet — the finalizer covers every compute in the app (see D-DB-10). + +**Rationale:** +- **Order-independence.** `dashboardSection` gates the traces section on each compute's `tracerEnabled`, which the framework flips at `finalizeTracing()` (when the app contains a `Tracer`) — and the default compute list is `getComputes()`. Building in the Dashboard constructor would miss any compute or Tracer constructed after it, and would run before tracing is finalized. Deferring to finalize means the Dashboard observes the complete app, so `new Dashboard(...)` can appear anywhere in the backend module. (`finalizeTracing` runs before `finalizeDashboards`, so trace flags are set when the dashboard reads them.) +- **Reuses the house pattern.** `finalizeConfigRegistry` already runs at the same `create()` join point; the compute registry's own doc names "dashboards" as an intended finalize consumer. `registerDashboardFinalizer`/`finalizeDashboards` follows it (core owns the seam; the Dashboard supplies a callback, so core keeps no dependency on `bb-dashboard`). It is deliberately Dashboard-specific — the only deferred-build case today — and can be generalized into a finalizer registry if a second use case appears. +- **Enables default-to-all.** With the body built at finalize, the no-arg "cover every compute" default enumerates `getComputes()` with no construction-order gap (see D-DB-10). +- **Cost:** a Dashboard constructed outside `create()` (e.g. directly in a unit test) must call `finalizeDashboards(stack)` before synth — exactly how `config-registry.test.ts` drives `finalizeConfigRegistry`. A Dashboard constructed *after* `create()` has finalized (without a further `finalizeDashboards`) still gets its resource — created eagerly — so its URL/redirect never dangle; only its widget body is left empty. + +### Layout (as implemented, `widgets.ts`) + +Per compute (in the order given): `## 🔧 {label}` header (label = the compute's +scope `id`), health rows always, then `### 🔍 Traces` and `### 📋 Logs` only when +present in the section. Then one `## 📊 Metrics — {namespace}` section per +`MetricsSource`. Single-compute apps render one group — the pre-multi-compute +dashboard plus a header row. + ## Infrastructure (CDK) Creates a single CloudWatch Dashboard resource: @@ -99,11 +205,11 @@ Creates a single CloudWatch Dashboard resource: **When `metrics` is provided:** 5. **Individual Metric Graphs** — One dedicated GraphWidget per MetricConfig entry. Each widget displays the metric with the configured stat and period (defaults: Sum, 60s), titled with metric name or custom title. Dimensions, when specified, narrow the metric scope to specific resources. -**When `logger` is provided:** +**Always (logs are always captured), unless `logs: false`:** 6. **Recent Errors** — Log Insights query: `fields @timestamp, @message | filter @message like /ERROR/ or level = "error" | sort @timestamp desc | limit 20` 7. **Log Volume** — `AWS/Logs` → IncomingLogEvents (Sum, 300s) -**When `tracer` is provided:** +**When tracing is enabled on the compute (the app has a `Tracer`), unless `traces: false`:** 8. **Traces** — X-Ray trace widget showing a list of recent traces ### Widget Layout @@ -113,13 +219,15 @@ CloudWatch Dashboards use a 24-column grid. The auto-generated layout stacks sec ``` Row 0 (y=0): [Lambda Invocations (12w, 6h)] [Lambda Errors (12w, 6h)] Row 1 (y=6): [Lambda Duration (12w, 6h)] [Concurrent Executions (12w, 6h)] -Row 2+: [Metric pairs (12w, 6h each)] ← two metrics per row when `metrics` provided -Row M: [Traces (24w, 9h)] ← only if `tracer` provided (X-Ray trace map) -Row N: [Recent Errors (24w, 6h)] ← only if `logger` provided -Row N+1: [Log Volume (24w, 6h)] ← only if `logger` provided +Row T: [Traces (24w, 9h)] ← only if the app has a Tracer (unless traces:false) +Row N: [Recent Errors (24w, 6h)] ← always (unless logs:false) +Row N+1: [Log Volume (24w, 6h)] ← always (unless logs:false) +Row M+: [Metric pairs (12w, 6h each)] ← two metrics per row, per MetricsSource, after the compute groups ``` -Rows collapse upward when their condition is not met. For example, if only `logger` is provided (no metrics or tracing): +(Section-header text widgets separate the groups; within a compute group the order is health → traces → logs, and app-wide metrics sections follow all compute groups.) + +Rows collapse upward when their condition is not met. For example, with no Tracer and no metrics (logs always render): ``` Row 0 (y=0): [Lambda Invocations (12w, 6h)] [Lambda Errors (12w, 6h)] @@ -204,59 +312,66 @@ Dashboard body is serialized as CloudWatch Dashboard JSON format during CDK synt ### Composition Pattern -Dashboard accepts observability BB instances as constructor parameters. This is **explicit composition** (not auto-discovery) because: +The dashboard is **compute-driven**: it reads observability state off each +compute rather than accepting Logger / Tracer instances. Only **Metrics** is an +explicit BB input (it is app-scoped, not compute-scoped). This keeps the +dashboard deterministic and decoupled from the observability BB classes: -1. **Predictability** — Developers know exactly what's on the dashboard -2. **Type safety** — TypeScript enforces valid BB references -3. **Flexibility** — Multiple dashboards can show different subsets of BBs -4. **Simplicity** — No scope-walking magic; easy to understand and debug +1. **Predictability** — Health + logs always render per compute; traces render when the app has a `Tracer`. Nothing to wire up. +2. **Type safety** — TypeScript enforces valid Metrics references. +3. **Flexibility** — `logs` / `traces` toggles let one app show different section subsets across multiple dashboards. +4. **Simplicity** — No scope-walking magic for logs/traces; the compute self-reports. ### BB Integration via Structural Typing -Dashboard parameters use structural typing. `metrics` accepts any object with a `namespace` property (the resolved CloudWatch namespace) and an optional `defaultDimensions` property; `logger` and `tracer` accept any object with `fullId`. This means the real BB instances satisfy the interfaces via duck typing without importing their exact types, keeping the Dashboard BB decoupled. +The metrics input uses structural typing. Each `MetricsSource.metrics` accepts any object with a `namespace` property (the resolved CloudWatch namespace) and an optional `defaultDimensions` property. Logs and traces are **not** dashboard inputs — the dashboard reads each compute's self-reported `dashboardSection` (logs always present; traces present when the compute is traced). This keeps the Dashboard BB decoupled from the Logger/Tracer classes. **Metrics namespace and dimensions resolution:** -1. `metrics.namespace` → used if metrics BB provided +1. `metrics.namespace` → used if a metrics source is provided 2. `metrics.defaultDimensions` → merged into widget queries so they target the correct dimensioned metric stream (per-metric dimensions from `MetricConfig` take precedence on conflict) -3. No metrics BB → no custom metrics widgets +3. No metrics source → no custom metrics widgets -**Example (full BB composition):** +**Example (full observability):** ```typescript +new Tracer(scope, 'tracer'); // presence-gated → every compute gets a traces section +const metrics = new Metrics(scope, 'metrics'); + const dashboard = new Dashboard(scope, 'dashboard', { - logger, // Logger BB — enables log widgets - metrics, // Metrics BB — uses resolved namespace - tracer, // Tracer BB — enables trace widgets - metricConfigs: [{ name: 'OrdersPlaced' }, { name: 'Latency' }, { name: 'ErrorRate' }], + // covers every compute in the app; logs/traces default on + metrics: { + metrics, + metricConfigs: [{ name: 'OrdersPlaced' }, { name: 'Latency' }, { name: 'ErrorRate' }], + }, }); ``` ### Data Flow ``` -┌──────────────┐ BB instance (namespace) ┌──────────────┐ -│ Metrics │ ──────────────────────────────► │ │ -│ (namespace) │ │ │ -└──────────────┘ │ │ - │ │ -┌──────────────┐ BB instance (fullId) │ Dashboard │──► CloudWatch Dashboard (CDK) -│ Logger │ ──────────────────────────────► │ (CDK only) │──► CfnOutput (URL) -│ (fullId) │ │ │──► Optional API route -└──────────────┘ │ │ - │ │ -┌──────────────┐ BB instance (fullId) │ │ -│ Tracer │ ──────────────────────────────► │ │ -│ (fullId) │ │ │ -└──────────────┘ └──────────────┘ + enableTracing() at finalize (if app has a Tracer) +┌──────────────┐ ┌──────────────┐ +│ Tracer │ ─► │ │ +└──────────────┘ │ Compute │ dashboardSection(region) + │ (per unit) │ ──────────────────────────┐ + logs always on ─► │ │ { label, health, │ + └──────────────┘ logging?, tracing? } ▼ + ┌──────────────┐ + │ Dashboard │──► CloudWatch Dashboard (CDK) +┌──────────────┐ MetricsSource (namespace+configs) │ (CDK only) │──► CfnOutput (URL) +│ Metrics │ ─────────────────────────────────────►│ │──► Optional API route +└──────────────┘ └──────────────┘ ``` -### What Dashboard Reads from Each BB +The Tracer never talks to the Dashboard: it records presence, the framework +enables tracing on every compute at finalize, and the Dashboard asks each +compute for its self-reported section (applying its `logs` / `traces` toggles). + +### What Dashboard Reads from Each Input -| BB | Information Extracted | Used For | +| Input | Information Extracted | Used For | |----|----------------------|----------| -| **Metrics** | `namespace` (resolved CloudWatch namespace), `defaultDimensions` (optional) | Querying custom metrics in the namespace with correct dimension filtering | -| **Logger** | `fullId` (presence → derives log group) | Log Insights query widget | -| **Tracer** | `fullId` (presence → implies X-Ray active) | X-Ray trace list widget | -| **(always)** | Lambda function name (from Scope) | Lambda built-in metrics (Invocations, Errors, Duration) | +| **Compute** (every compute in the app) | `dashboardSection(region)` → `{ label, health, logging?, tracing? }` | The compute's group: header, health widgets, logs widgets (always), plus traces widgets when the compute is traced — subject to the `logs` / `traces` toggles | +| **Metrics** (per `MetricsSource`) | `namespace` (resolved CloudWatch namespace), `defaultDimensions` (optional), per-source `metricConfigs` | Querying custom metrics in the namespace with correct dimension filtering | ### Why Not Auto-Discovery? @@ -298,11 +413,10 @@ Dashboard intentionally does **not** walk the scope tree to auto-discover BBs be ### Unit Tests (`packages/bb-dashboard/src/index.test.ts`) - Widget builder functions produce correct CloudWatch Dashboard JSON format -- Lambda health widgets are always generated regardless of options -- Metrics widgets only appear when `metrics` option is provided -- Logging widgets only appear when `logger` option is provided -- Trace widgets only appear when `tracer` option is provided -- `metricConfigs` option creates pre-configured metric widgets +- Health widgets are always generated for every compute section +- Metrics widgets only appear when the `metrics` option is provided (one section per `MetricsSource`) +- Logging/trace widgets only appear when the compute's section reports them (Logger/Tracer attached) +- Per-source `metricConfigs` create pre-configured metric widgets - Widget layout collapses rows correctly when conditions are not met - Mock logs expected console message and route returns null URL diff --git a/packages/bb-dashboard/README.md b/packages/bb-dashboard/README.md index 9915337ba..0228603aa 100644 --- a/packages/bb-dashboard/README.md +++ b/packages/bb-dashboard/README.md @@ -22,43 +22,57 @@ npm install @aws-blocks/bb-dashboard ## Quick Start -### Minimal (Lambda Health Only) +### Minimal ```typescript import { Dashboard } from '@aws-blocks/bb-dashboard'; const dashboard = new Dashboard(scope, 'dashboard'); -// After deploy: outputs URL to CloudWatch Dashboard with Lambda metrics +// After deploy: a CloudWatch Dashboard with a health + logs section for every +// compute in the app. ``` -### With Observability BBs (Recommended) +### With metrics + a Tracer (Recommended) + +The dashboard is organized **by compute** — each compute in the app is a group. +Every group shows a **health** section and a **logs** section (logs are always +captured). A **traces** section appears when the app contains a `Tracer` (tracing +is presence-gated: any Tracer turns on X-Ray for every compute). You do **not** +pass Logger / Tracer instances to the dashboard. **Metrics** are app-scoped (a +namespace isn't tied to a compute), so they're passed explicitly, one section +per namespace. ```typescript -import { Logger } from '@aws-blocks/bb-logger'; import { Metrics } from '@aws-blocks/bb-metrics'; import { Tracer } from '@aws-blocks/bb-tracer'; -const logger = new Logger(scope, 'logs'); +new Tracer(scope, 'tracing'); // → traces section on every compute const metrics = new Metrics(scope, 'metrics', { namespace: 'MyApp' }); -const tracer = new Tracer(scope, 'tracing'); const dashboard = new Dashboard(scope, 'dashboard', { title: 'MyApp — Production', - logger, - metrics, - tracer, - metricConfigs: [ - { name: 'OrdersPlaced' }, - { name: 'Latency', stat: 'p99', period: 300, title: 'P99 Latency' }, - { name: 'CustomMetric', dimensions: { Service: 'API', Stage: 'prod' } }, - ], + // Display toggles (default true) applied to every compute section. + logs: true, + traces: true, + // app-wide; pair each Metrics BB with its own metric names (per-namespace). + // Also accepts an array of sources, one section per namespace. + metrics: { + metrics, + metricConfigs: [ + { name: 'OrdersPlaced' }, + { name: 'Latency', stat: 'p99', period: 300, title: 'P99 Latency' }, + { name: 'CustomMetric', dimensions: { Service: 'API', Stage: 'prod' } }, + ], + }, }); ``` -The Dashboard extracts configuration directly from BB instances: -- **Metrics**: uses the BB's resolved `namespace` (which defaults to its scope `fullId` unless overridden) and `defaultDimensions` (automatically included in widget queries so they target the correct dimensioned metric stream) -- **Logger**: enables log widgets; log group derived from Lambda handler function name -- **Tracer**: presence implies X-Ray tracing is active +How the dashboard resolves each section: +- **Health** — the compute's health section, always shown. +- **Logs** — shown for every compute (logs are always captured); log group is the compute's own handler log group. Suppress with `logs: false`. +- **Traces** — shown for a compute when tracing is enabled on it (the app contains a `Tracer`). Suppress with `traces: false`. +- **Metrics** — app-wide, from the `metrics` option: uses each BB's resolved `namespace` (defaults to its scope `fullId`) and `defaultDimensions` (included in widget queries so they target the correct dimensioned stream). +- **Compute coverage** — always **every** compute in the app (one today). No compute selector is exposed yet. ## API Reference @@ -83,13 +97,27 @@ Creates a CloudWatch Dashboard with auto-generated widgets. ### `DashboardOptions` -#### Observability BB Composition +#### Display toggles + +Logs and traces are section **display toggles**, not composition inputs — the +dashboard reads each compute's state directly. Logs are always captured (so +`logs` only hides the section); traces exist only when the app has a `Tracer` +(so `traces` only hides an otherwise-present section). + +| Option | Type | Default | Description | +|--------|------|---------|-------------| +| `logs` | `boolean` | `true` | Show the logs section for each compute | +| `traces` | `boolean` | `true` | Show the traces section for each compute (only ever present when the app has a `Tracer`) | + +The dashboard always covers **every** compute in the app. There is no compute +selector yet — one arrives with the multi-compute customer surface (it would +otherwise leak an internal type before customers can construct a compute). + +#### Metrics composition | Option | Type | Description | |--------|------|-------------| -| `logger` | `LoggerBBRef` | Logger BB instance — enables log query widgets | -| `metrics` | `MetricsBBRef` | Metrics BB instance — adds metric widgets (uses resolved `namespace` and `defaultDimensions`) | -| `tracer` | `TracerBBRef` | Tracer BB instance — enables X-Ray trace widgets | +| `metrics` | `MetricsSource \| MetricsSource[]` | Metrics source(s) — each pairs a Metrics BB with its own `metricConfigs`; one app-wide section per namespace | #### Configuration @@ -97,7 +125,6 @@ Creates a CloudWatch Dashboard with auto-generated widgets. |--------|------|---------|-------------| | `title` | `string` | `id` | Dashboard display title | | `dashboardName` | `string` | `scope.fullId` | CloudWatch Dashboard name (max 255 characters, auto-truncated) | -| `metricConfigs` | `MetricConfig[]` | `[]` | Pre-registered metrics with optional custom stat/period/title | | `defaultTimeRange` | `string` | `'-PT3H'` | Default time range (ISO 8601 duration) | | `routePath` | `string \| false` | `'/aws-blocks/dashboard'` | Route path for the redirect. Set to `false` to disable | @@ -129,18 +156,20 @@ DashboardErrors.InvalidMetricConfig // 'InvalidMetricConfigException' The following widgets are always included: +Grouped per compute, then an app-wide metrics section: + | Widget | Source | Condition | |--------|--------|-----------| -| Lambda Invocations | AWS/Lambda | Always | -| Lambda Errors | AWS/Lambda | Always | -| Lambda Duration (Avg + p99) | AWS/Lambda | Always | -| Concurrent Executions | AWS/Lambda | Always | -| Individual Metric Graph (per metric) | User namespace | `metrics` BB + `metricConfigs` | -| X-Ray Trace Table | X-Ray | `tracer` BB provided | -| Recent Errors (Log Insights) | Log group | `logger` BB provided | -| Log Volume | AWS/Logs | `logger` BB provided | +| Lambda Invocations | AWS/Lambda | Per compute, always | +| Lambda Errors | AWS/Lambda | Per compute, always | +| Lambda Duration (Avg + p99) | AWS/Lambda | Per compute, always | +| Concurrent Executions | AWS/Lambda | Per compute, always | +| X-Ray Trace Table | X-Ray | Per compute, when the app has a `Tracer` (unless `traces: false`) | +| Recent Errors (Log Insights) | Log group | Per compute, always (unless `logs: false`) | +| Log Volume | AWS/Logs | Per compute, always (unless `logs: false`) | +| Individual Metric Graph (per metric) | User namespace | App-wide, per `metrics` source + `metricConfigs` | -Rows collapse upward when their condition is not met. +(Health widgets are Lambda-shaped for the default compute; other compute types report their own health metrics.) ## Dashboard Redirect Route @@ -159,16 +188,11 @@ const dashboard = new Dashboard(scope, 'dashboard', { // GET /ops/dashboard → 302 → https://.console.aws.amazon.com/cloudwatch/... ``` -## Auto-Derived Log Group Name - -When a `logger` BB instance is provided, the Dashboard derives the log group -name from the Lambda function name using the standard pattern: - -``` -/aws/lambda/{functionName} -``` +## Log Group -This means log widgets appear automatically when a Logger BB is connected. +Each compute's log section queries that compute's own handler log group (the +framework-owned group the compute provisions). Logs are always captured, so the +log widgets appear for every compute unless you set `logs: false`. ## Local Development @@ -217,10 +241,12 @@ const metrics = new Metrics(scope, 'metrics', { }); const dashboard = new Dashboard(scope, 'dashboard', { - metrics, - metricConfigs: [ - { name: 'OrdersPlaced' }, // queries with { service: 'orders', env: 'prod' } - { name: 'Latency', dimensions: { endpoint: '/api' } }, // { service: 'orders', env: 'prod', endpoint: '/api' } - ], + metrics: { + metrics, + metricConfigs: [ + { name: 'OrdersPlaced' }, // queries with { service: 'orders', env: 'prod' } + { name: 'Latency', dimensions: { endpoint: '/api' } }, // { service: 'orders', env: 'prod', endpoint: '/api' } + ], + }, }); ``` diff --git a/packages/bb-dashboard/package.json b/packages/bb-dashboard/package.json index 768dbaecc..feed9449a 100644 --- a/packages/bb-dashboard/package.json +++ b/packages/bb-dashboard/package.json @@ -32,12 +32,13 @@ }, "scripts": { "build": "tsc --build", - "test": "node --test dist/index.test.js" + "test": "node --test dist/**/*.test.js" }, "dependencies": { "@aws-blocks/core": "^0.3.0" }, "devDependencies": { + "@aws-blocks/bb-lambda-compute": "^0.3.0", "@types/node": "^20.0.0", "typescript": "^5.3.0" }, diff --git a/packages/bb-dashboard/src/index.aws.ts b/packages/bb-dashboard/src/index.aws.ts index c922a8233..dfcf26eed 100644 --- a/packages/bb-dashboard/src/index.aws.ts +++ b/packages/bb-dashboard/src/index.aws.ts @@ -10,16 +10,15 @@ */ import type { ScopeParent } from '@aws-blocks/core'; import { registerSdkIdentifiers } from '@aws-blocks/core'; +import { BB_DASHBOARD_URL_ENV, mountDashboardRoute } from './routes.js'; import type { DashboardOptions } from './types.js'; -import { mountDashboardRoute, BB_DASHBOARD_URL_ENV } from './routes.js'; export { DashboardErrors } from './errors.js'; export type { DashboardOptions, MetricConfig, MetricsBBRef, - LoggerBBRef, - TracerBBRef, + MetricsSource, } from './types.js'; /** @@ -39,7 +38,12 @@ export class Dashboard { readonly fullId: string; constructor(scope: ScopeParent, id: string, options?: DashboardOptions) { - this.fullId = 'fullId' in scope && scope.fullId ? `${scope.fullId}-${id}` : ('id' in scope && scope.id ? `${scope.id}-${id}` : id); + this.fullId = + 'fullId' in scope && scope.fullId + ? `${scope.fullId}-${id}` + : 'id' in scope && scope.id + ? `${scope.id}-${id}` + : id; this.dashboardName = (options?.dashboardName ?? id).replace(/[^A-Za-z0-9\-_]/g, '-').substring(0, 255); this.url = process.env[BB_DASHBOARD_URL_ENV] ?? null; registerSdkIdentifiers(this.fullId, { dashboardName: this.dashboardName }); diff --git a/packages/bb-dashboard/src/index.browser.ts b/packages/bb-dashboard/src/index.browser.ts index 127e385da..692daf542 100644 --- a/packages/bb-dashboard/src/index.browser.ts +++ b/packages/bb-dashboard/src/index.browser.ts @@ -14,8 +14,7 @@ export type { DashboardOptions, MetricConfig, MetricsBBRef, - LoggerBBRef, - TracerBBRef, + MetricsSource, } from './types.js'; export class Dashboard { diff --git a/packages/bb-dashboard/src/index.cdk.test.ts b/packages/bb-dashboard/src/index.cdk.test.ts new file mode 100644 index 000000000..7892c9c77 --- /dev/null +++ b/packages/bb-dashboard/src/index.cdk.test.ts @@ -0,0 +1,171 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * CDK-synth tests for the Dashboard construct against a real compute. + * + * The per-compute Dashboard behavior (organize the body by compute; render the + * health + logs sections always, and the traces section only when the compute + * is traced) is otherwise only exercised by unit tests over + * `buildDashboardWidgets` with hand-built section stubs. These tests build a + * real `Dashboard` on a `BlocksStack`'s default `LambdaCompute` and assert the + * synthesized `AWS::CloudWatch::Dashboard` body, covering the construct ↔ + * compute seam (the dashboard enumerating computes and calling + * `compute.dashboardSection(region)`) end to end, plus the `logs` / `traces` + * display toggles and the `computes` selector. + * + * Tracing turns on via the compute's public `enableTracing()` seam — which the + * framework calls on every compute when the app contains a Tracer. We drive + * that seam directly on the real cdk `LambdaCompute`: the bb-tracer package + * exports its cdk variant only under the `cdk` condition, which cannot be + * activated at ESM import time from inside this shared mock-conditioned test + * process, so importing it here would resolve its local-mock variant and never + * touch the compute. Calling the seam directly is the faithful equivalent. + * (Logging has no enable seam — it is always on.) + */ + +import assert from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import { LambdaCompute } from '@aws-blocks/bb-lambda-compute/cdk'; +import { BlocksPresets, BlocksStack, finalizeDashboards } from '@aws-blocks/core/cdk'; +import type { DefaultComputeFactory } from '@aws-blocks/core/cdk/internal'; +import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; +import { Dashboard } from './index.cdk.js'; + +const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as never, 'DefaultCompute'); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +let handlerPath: string; +let backendPath: string; +let tmpDir: string; + +before(() => { + // Satisfies assertCdkConditionActive() (reads process.env.NODE_OPTIONS), + // which BlocksStack.create() calls. + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --conditions=cdk`; + tmpDir = mkdtempSync(join(__dirname, 'tmp-dashboard-cdk-')); + handlerPath = join(tmpDir, 'handler.mjs'); + writeFileSync(handlerPath, "export const handler = async () => ({ statusCode: 200, body: '{}' });\n"); + backendPath = join(tmpDir, 'backend.mjs'); + writeFileSync(backendPath, 'export default () => {};\n'); +}); + +after(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function makeStack(id: string): Promise { + const app = new cdk.App(); + return BlocksStack.create(app, id, { + backendHandlerPath: handlerPath, + backendCDKPath: backendPath, + defaults: BlocksPresets.production, + defaultComputeFactory: lambdaFactory, + }); +} + +/** The synthesized CloudWatch Dashboard body, as a searchable string. */ +function dashboardBody(stack: BlocksStack): string { + const dashboards = Template.fromStack(stack).findResources('AWS::CloudWatch::Dashboard'); + return JSON.stringify(dashboards); +} + +describe('Dashboard against a real compute (synth)', () => { + test('renders the compute header plus logs (always) + traces (when traced)', async () => { + const stack = await makeStack('DashboardComputeFull'); + + // Enable tracing on the stack's default compute via the exact public seam + // the framework calls when the app contains a Tracer. Logging needs no + // enable — it is always on. + const compute = stack._defaultCompute as LambdaCompute; + compute.enableTracing(); + + // routePath:false — the redirect route uses a process-global registry that + // would collide across the stacks these sibling tests each build; the route + // is not what this test asserts. + new Dashboard(stack, 'dashboard', { routePath: false }); + // The widget body is deferred; create() already ran for this stack, so + // finalize dashboards explicitly to build it before asserting. + finalizeDashboards(stack); + + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::CloudWatch::Dashboard', 1); + + const body = dashboardBody(stack); + assert.ok(body.includes('🔧 DefaultCompute'), 'body has the compute header'); + assert.ok(body.includes('📋 Logs'), 'body has the logs section (logs are always on)'); + assert.ok(body.includes('🔍 Traces'), 'body has the traces section (tracing enabled)'); + }); + + test('renders logs (always) but omits traces when the app has no Tracer', async () => { + const stack = await makeStack('DashboardComputeBare'); + + new Dashboard(stack, 'dashboard', { routePath: false }); + finalizeDashboards(stack); + + const body = dashboardBody(stack); + assert.ok(body.includes('🔧 DefaultCompute'), 'body has the compute header (health always renders)'); + assert.ok(body.includes('📋 Logs'), 'logs section renders (logs are always on)'); + assert.ok(!body.includes('🔍 Traces'), 'no traces section without a Tracer'); + }); + + test('logs:false hides the logs section (logs are still captured)', async () => { + const stack = await makeStack('DashboardLogsOff'); + + new Dashboard(stack, 'dashboard', { routePath: false, logs: false }); + finalizeDashboards(stack); + + const body = dashboardBody(stack); + assert.ok(body.includes('🔧 DefaultCompute'), 'compute header still renders'); + assert.ok(!body.includes('📋 Logs'), 'logs section suppressed by logs:false'); + }); + + test('traces:false hides the traces section even when tracing is enabled', async () => { + const stack = await makeStack('DashboardTracesOff'); + const compute = stack._defaultCompute as LambdaCompute; + compute.enableTracing(); + + new Dashboard(stack, 'dashboard', { routePath: false, traces: false }); + finalizeDashboards(stack); + + const body = dashboardBody(stack); + assert.ok(body.includes('📋 Logs'), 'logs section still renders'); + assert.ok(!body.includes('🔍 Traces'), 'traces section suppressed by traces:false'); + }); + + test('defaults to every compute in the app, resolved at finalize', async () => { + const stack = await makeStack('DashboardDefaultCompute'); + + // No `computes` option — the default selection is every compute in the + // app, resolved at finalize. + new Dashboard(stack, 'dashboard', { routePath: false }); + finalizeDashboards(stack); + + const template = Template.fromStack(stack); + template.resourceCountIs('AWS::CloudWatch::Dashboard', 1); + + const body = dashboardBody(stack); + assert.ok(body.includes('🔧 DefaultCompute'), 'default compute section renders'); + assert.ok(body.includes('📋 Logs'), 'its logs section renders (logs are always on)'); + }); + + test('renders traces when the Dashboard is constructed BEFORE tracing is enabled (order-independent via finalize)', async () => { + const stack = await makeStack('DashboardBeforeTracer'); + const compute = stack._defaultCompute as LambdaCompute; + + // Construct the Dashboard first, then enable tracing. Because the widget + // body is deferred to a finalizer (built after the whole app is + // constructed), the traces section still appears despite the order. + new Dashboard(stack, 'dashboard', { routePath: false }); + compute.enableTracing(); + finalizeDashboards(stack); + + const body = dashboardBody(stack); + assert.ok(body.includes('🔍 Traces'), 'traces section renders despite Dashboard-before-Tracer order'); + }); + +}); diff --git a/packages/bb-dashboard/src/index.cdk.ts b/packages/bb-dashboard/src/index.cdk.ts index 5830ef193..1165b96c6 100644 --- a/packages/bb-dashboard/src/index.cdk.ts +++ b/packages/bb-dashboard/src/index.cdk.ts @@ -1,22 +1,22 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import type { ScopeParent } from '@aws-blocks/core'; +import { registerConfig, registerDashboardFinalizer, Scope } from '@aws-blocks/core/cdk'; +import { type ComputeDashboardSection, getComputes } from '@aws-blocks/core/cdk/internal'; import { CfnOutput, Fn, Stack } from 'aws-cdk-lib'; import { Dashboard as CwDashboard } from 'aws-cdk-lib/aws-cloudwatch'; -import { Scope, registerConfig } from '@aws-blocks/core/cdk'; -import type { ScopeParent } from '@aws-blocks/core'; +import { BB_DASHBOARD_URL_ENV, mountDashboardRoute } from './routes.js'; import type { DashboardOptions } from './types.js'; import { buildDashboardWidgets, resolveConfig } from './widgets.js'; -import { mountDashboardRoute, BB_DASHBOARD_URL_ENV } from './routes.js'; export { DashboardErrors } from './errors.js'; export type { DashboardOptions, - ResolvedDashboardConfig, MetricConfig, MetricsBBRef, - LoggerBBRef, - TracerBBRef, + MetricsSource, + ResolvedDashboardConfig, } from './types.js'; /** @@ -34,21 +34,27 @@ export type { * * @example * ```typescript - * // Minimal — Lambda health widgets only + * // Minimal — a health + logs section for every compute in the app. * const dashboard = new Dashboard(scope, 'dashboard'); * ``` * * @example * ```typescript - * // With observability BB composition + * // Health + logs render for every compute automatically. A traces section + * // appears per compute when the app contains a Tracer (tracing is + * // presence-gated, enabling X-Ray on every compute). Metrics are app-wide and + * // passed explicitly (one section per namespace), with their configs. + * new Tracer(scope, 'tracer'); // → traces section on every compute + * const metrics = new Metrics(scope, 'metrics'); * const dashboard = new Dashboard(scope, 'dashboard', { - * logger, - * metrics, - * tracer, - * metricConfigs: [ - * { name: 'OrdersPlaced' }, - * { name: 'Latency', stat: 'p99', period: 300 }, - * ], + * logs: false, // hide the logs sections (logs are still captured) + * metrics: { + * metrics, + * metricConfigs: [ + * { name: 'OrdersPlaced' }, + * { name: 'Latency', stat: 'p99', period: 300 }, + * ], + * }, * }); * ``` */ @@ -65,20 +71,65 @@ export class Dashboard extends Scope { constructor(scope: ScopeParent, id: string, options?: DashboardOptions) { super(id, { parent: scope }); - const functionName = this.handler.functionName; - // Point log widgets at the framework-owned handler log group (its name is - // generated, not `/aws/lambda/`), so "Recent Errors" / "Log Volume" - // resolve to the group the handler actually writes to. - const config = resolveConfig(id, options, functionName, this.fullId, this.handlerLogGroup.logGroupName); + const config = resolveConfig(id, options, this.fullId); this.dashboardName = config.dashboardName; - const region = Stack.of(this).region; - const widgetRows = buildDashboardWidgets(config, functionName, region); + // Display toggles for the logs / traces sections. Captured here (before the + // finalizer runs) but applied per compute below. Default is to show both. + const showLogs = options?.logs !== false; + const showTraces = options?.traces !== false; - new CwDashboard(this, 'Resource', { + // Create the CloudWatch Dashboard resource eagerly so the URL / redirect + // route / config below always point at a resource that exists — never a + // dangling link if the finalizer somehow doesn't run. Only the widget + // *body* is deferred (added via `addWidgets` at finalize). + const dashboard = new CwDashboard(this, 'Resource', { dashboardName: config.dashboardName, start: config.defaultTimeRange, - widgets: widgetRows, + }); + + // Build the widget body in a finalizer, not here. The body depends on + // which computes are traced (`dashboardSection` gates its traces section on + // each compute's tracing flag), and that flag is flipped when the app's + // `Tracer` is finalized during the backend-module import. This finalizer + // runs after that import completes, so the dashboard observes tracing + // regardless of the order the customer constructed things in — and its + // compute list captures every compute in the app. + registerDashboardFinalizer(this, () => { + const region = Stack.of(this).region; + // The dashboard is organized by compute: each compute is a group — health + // always, plus logs/traces per the compute's state and this dashboard's + // display toggles. Metrics are app-wide (rendered once per namespace, + // after the compute groups). + // + // TODO(multi-compute): the dashboard currently always covers EVERY compute + // in the app (`getComputes(this)`), which is complete today because there + // is exactly one (the default) compute and no customer surface to create + // more. When `Compute` becomes a public, customer-instantiable type, add a + // `computes?: Compute[]` option to `DashboardOptions` and resolve it here + // as `options.computes ?? getComputes(this)` — an explicit list restricts + // the dashboard to just those computes (in the given order); omitting it + // keeps the "cover every compute" default. It is left out of the public + // API until then so we don't leak the internal `Compute` type before a + // customer can construct one to pass. + const computes = getComputes(this); + const computeSections: ComputeDashboardSection[] = computes.map((compute) => { + const section = compute.dashboardSection(region); + // Apply the dashboard-wide display toggles uniformly. `logging` is + // always present on the section (logs are always captured), so the + // `logs` toggle can suppress it; `tracing` is only present when the + // compute is traced, so the `traces` toggle only ever suppresses an + // already-present section — it never fabricates one. + return { + ...section, + logging: showLogs ? section.logging : undefined, + tracing: showTraces ? section.tracing : undefined, + }; + }); + // Each row of widgets is added as its own dashboard row (side-by-side). + for (const row of buildDashboardWidgets(computeSections, config, region)) { + dashboard.addWidgets(...row); + } }); this.url = Fn.join('', [ diff --git a/packages/bb-dashboard/src/index.mock.ts b/packages/bb-dashboard/src/index.mock.ts index a5e3679d2..56d7e83f3 100644 --- a/packages/bb-dashboard/src/index.mock.ts +++ b/packages/bb-dashboard/src/index.mock.ts @@ -10,16 +10,15 @@ */ import type { ScopeParent } from '@aws-blocks/core'; import { registerSdkIdentifiers } from '@aws-blocks/core'; -import type { DashboardOptions } from './types.js'; import { mountDashboardRoute } from './routes.js'; +import type { DashboardOptions } from './types.js'; export { DashboardErrors } from './errors.js'; export type { DashboardOptions, MetricConfig, MetricsBBRef, - LoggerBBRef, - TracerBBRef, + MetricsSource, } from './types.js'; /** @@ -48,7 +47,12 @@ export class Dashboard { constructor(scope: ScopeParent, id: string, options?: DashboardOptions) { const title = options?.title ?? id; - this.fullId = 'fullId' in scope && scope.fullId ? `${scope.fullId}-${id}` : ('id' in scope && scope.id ? `${scope.id}-${id}` : id); + this.fullId = + 'fullId' in scope && scope.fullId + ? `${scope.fullId}-${id}` + : 'id' in scope && scope.id + ? `${scope.id}-${id}` + : id; this.dashboardName = (options?.dashboardName ?? this.fullId).replace(/[^A-Za-z0-9\-_]/g, '-').substring(0, 255); registerSdkIdentifiers(this.fullId, { dashboardName: this.dashboardName }); @@ -60,11 +64,11 @@ export class Dashboard { console.log( `[Dashboard] Dashboard BB: no-op in local mode (CloudWatch Dashboard is a cloud-only resource).\n` + - `Will create CloudWatch Dashboard '${title}' on deploy. Run 'npx cdk deploy' to view.\n\n` + - `📍 Local observability data:\n` + - ` • Logs: Check your terminal output - Logger BB writes structured JSON to stdout\n` + - ` • Metrics: Metrics BB writes EMF-formatted JSON to stdout (visible in terminal)\n` + - ` • Traces: Tracer stores mock traces to .bb-data/ and logs them to stdout` + `Will create CloudWatch Dashboard '${title}' on deploy. Run 'npx cdk deploy' to view.\n\n` + + `📍 Local observability data:\n` + + ` • Logs: Check your terminal output - every compute writes structured JSON to stdout\n` + + ` • Metrics: Metrics BB writes EMF-formatted JSON to stdout (visible in terminal)\n` + + ` • Traces: Tracer stores mock traces to .bb-data/ and logs them to stdout`, ); } } diff --git a/packages/bb-dashboard/src/index.test.ts b/packages/bb-dashboard/src/index.test.ts index 6371685e9..814f95d06 100644 --- a/packages/bb-dashboard/src/index.test.ts +++ b/packages/bb-dashboard/src/index.test.ts @@ -5,16 +5,47 @@ import { describe, it, beforeEach } from 'node:test'; import * as assert from 'node:assert/strict'; import { buildDashboardWidgets, - buildLambdaWidgets, buildMetricsWidgets, - buildLoggingWidgets, - buildTracingWidgets, resolveConfig, - TraceWidget, } from './widgets.js'; +import type { ComputeDashboardSection } from '@aws-blocks/core/cdk/internal'; import type { DashboardOptions } from './types.js'; import { DashboardErrors } from './errors.js'; +import { GraphWidget } from 'aws-cdk-lib/aws-cloudwatch'; import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; + +/** + * Stand-ins for a compute's self-reported widget rows (`Compute.dashboardWidgets` + * / `loggingWidgets` / `tracingWidgets`). The Dashboard forwards whatever the + * resolved compute returns; these use distinct titles so tests can assert the + * rows are passed through without depending on Lambda-specific widget content. + */ +function stubHealthRows(region: string): IWidget[][] { + return [ + [new GraphWidget({ title: 'Compute Health A', width: 12, height: 6, region })], + [new GraphWidget({ title: 'Compute Health B', width: 12, height: 6, region })], + ]; +} +function stubLoggingRows(region: string): IWidget[][] { + return [[new GraphWidget({ title: 'Stub Recent Errors', width: 24, height: 6, region })]]; +} +function stubTracingRows(region: string): IWidget[][] { + return [[new GraphWidget({ title: 'Stub Traces', width: 24, height: 9, region })]]; +} +/** Build a one-element ComputeSection[] from stub rows (the single-compute case). */ +function stubComputeSections( + region: string, + opts?: { label?: string; logging?: boolean; tracing?: boolean }, +): ComputeDashboardSection[] { + return [ + { + label: opts?.label ?? 'DefaultCompute', + health: stubHealthRows(region), + logging: opts?.logging ? stubLoggingRows(region) : undefined, + tracing: opts?.tracing ? stubTracingRows(region) : undefined, + }, + ]; +} import { getRegisteredRoutes, clearRouteRegistry } from '@aws-blocks/core'; import { mountDashboardRoute, BB_DASHBOARD_URL_ENV } from './routes.js'; import type { BlocksContext } from '@aws-blocks/core'; @@ -35,21 +66,18 @@ describe('resolveConfig', () => { const config = resolveConfig('test-dash'); assert.equal(config.title, 'test-dash'); assert.equal(config.dashboardName, 'test-dash'); - assert.equal(config.metricsNamespace, undefined); - assert.equal(config.logGroupName, undefined); - assert.equal(config.tracingEnabled, false); - assert.deepEqual(config.metricConfigs, []); + assert.deepEqual(config.metrics, []); assert.equal(config.defaultTimeRange, '-PT3H'); }); it('uses scopeFullId as default dashboardName when provided', () => { - const config = resolveConfig('dash', undefined, undefined, 'mystack-Blocks-dash'); + const config = resolveConfig('dash', undefined, 'mystack-Blocks-dash'); assert.equal(config.title, 'dash'); assert.equal(config.dashboardName, 'mystack-Blocks-dash'); }); it('explicit dashboardName takes priority over scopeFullId', () => { - const config = resolveConfig('dash', { dashboardName: 'custom-name' }, undefined, 'mystack-Blocks-dash'); + const config = resolveConfig('dash', { dashboardName: 'custom-name' }, 'mystack-Blocks-dash'); assert.equal(config.dashboardName, 'custom-name'); }); @@ -59,7 +87,7 @@ describe('resolveConfig', () => { }); it('title always uses id, not scopeFullId', () => { - const config = resolveConfig('dash', undefined, undefined, 'mystack-Blocks-dash'); + const config = resolveConfig('dash', undefined, 'mystack-Blocks-dash'); assert.equal(config.title, 'dash'); }); @@ -72,13 +100,13 @@ describe('resolveConfig', () => { it('truncates scopeFullId-derived dashboardName to 255 characters', () => { const longScopeFullId = 'mystack-'.repeat(40) + 'dashboard'; - const config = resolveConfig('dash', undefined, undefined, longScopeFullId); + const config = resolveConfig('dash', undefined, longScopeFullId); assert.equal(config.dashboardName.length, 255); assert.equal(config.dashboardName, longScopeFullId.substring(0, 255)); }); it('sanitizes invalid CloudWatch characters in dashboardName', () => { - const config = resolveConfig('dash', undefined, undefined, 'my/stack.scope/dashboard'); + const config = resolveConfig('dash', undefined, 'my/stack.scope/dashboard'); assert.equal(config.dashboardName, 'my-stack-scope-dashboard'); }); @@ -89,48 +117,40 @@ describe('resolveConfig', () => { it('sanitizes before truncating', () => { const longWithDots = 'a.b'.repeat(200); - const config = resolveConfig('dash', undefined, undefined, longWithDots); + const config = resolveConfig('dash', undefined, longWithDots); assert.equal(config.dashboardName.length, 255); assert.ok(/^[A-Za-z0-9\-_]+$/.test(config.dashboardName)); }); it('uses metrics BB namespace', () => { const options: DashboardOptions = { - metrics: { namespace: 'MyApp' }, - metricConfigs: [{ name: 'Latency' }], + metrics: { metrics: { namespace: 'MyApp' }, metricConfigs: [{ name: 'Latency' }] }, }; const config = resolveConfig('dash', options); - assert.equal(config.metricsNamespace, 'MyApp'); - assert.deepEqual(config.metricConfigs, [{ name: 'Latency' }]); + assert.equal(config.metrics.length, 1); + assert.equal(config.metrics[0].namespace, 'MyApp'); + assert.deepEqual(config.metrics[0].metricConfigs, [{ name: 'Latency' }]); }); it('BB instances provide namespace via metrics.namespace', () => { const options: DashboardOptions = { - metrics: { namespace: 'myapp-metrics' }, + metrics: { metrics: { namespace: 'myapp-metrics' } }, }; const config = resolveConfig('dash', options); - assert.equal(config.metricsNamespace, 'myapp-metrics'); - }); - - it('logger BB enables log widgets when functionName provided', () => { - const options: DashboardOptions = { - logger: { fullId: 'myapp-logger' }, - }; - const config = resolveConfig('dash', options, 'my-handler-fn'); - assert.equal(config.logGroupName, '/aws/lambda/my-handler-fn'); + assert.equal(config.metrics[0].namespace, 'myapp-metrics'); }); - it('tracer BB presence enables tracer widgets', () => { + it('accepts an array of metrics sources — one per namespace', () => { const options: DashboardOptions = { - tracer: { fullId: 'myapp-tracer' }, + metrics: [{ metrics: { namespace: 'orders' } }, { metrics: { namespace: 'billing' } }], }; const config = resolveConfig('dash', options); - assert.equal(config.tracingEnabled, true); + assert.deepEqual(config.metrics.map((m) => m.namespace), ['orders', 'billing']); }); - it('no tracer BB means tracer disabled', () => { - const config = resolveConfig('dash', {}); - assert.equal(config.tracingEnabled, false); + it('metrics is empty when no metrics BB provided', () => { + assert.deepEqual(resolveConfig('dash', {}).metrics, []); + assert.deepEqual(resolveConfig('dash').metrics, []); }); it('uses custom title and dashboardName', () => { @@ -139,90 +159,22 @@ describe('resolveConfig', () => { assert.equal(config.dashboardName, 'custom-name'); }); - it('logGroupName is undefined when no logger BB and no functionName', () => { - const config = resolveConfig('dash', undefined, 'my-handler-fn'); - assert.equal(config.logGroupName, undefined); - }); - - it('logGroupName derived from functionName when logger BB present', () => { - const config = resolveConfig('dash', { logger: { fullId: 'myapp-log' } }, 'my-handler-fn'); - assert.equal(config.logGroupName, '/aws/lambda/my-handler-fn'); - }); - - it('logGroupName prefers the supplied handler log-group name over the /aws/lambda convention', () => { - const config = resolveConfig('dash', { logger: { fullId: 'myapp-log' } }, 'my-handler-fn', undefined, 'MyStack-HandlerLogGroupABC123'); - assert.equal(config.logGroupName, 'MyStack-HandlerLogGroupABC123'); - }); - - it('logGroupName remains undefined when no logger BB even with functionName', () => { - const config = resolveConfig('dash', {}, 'my-handler-fn'); - assert.equal(config.logGroupName, undefined); - }); - - it('logGroupName remains undefined when no functionName and no options', () => { - const config = resolveConfig('dash'); - assert.equal(config.logGroupName, undefined); - }); - it('extracts defaultDimensions from metrics BB ref', () => { const options: DashboardOptions = { - metrics: { namespace: 'MyApp', defaultDimensions: { service: 'orders', env: 'prod' } }, + metrics: { metrics: { namespace: 'MyApp', defaultDimensions: { service: 'orders', env: 'prod' } } }, }; const config = resolveConfig('dash', options); - assert.deepEqual(config.metricsDefaultDimensions, { service: 'orders', env: 'prod' }); + assert.deepEqual(config.metrics[0].defaultDimensions, { service: 'orders', env: 'prod' }); }); - it('metricsDefaultDimensions is undefined when metrics BB has no defaultDimensions', () => { - const options: DashboardOptions = { - metrics: { namespace: 'MyApp' }, - }; - const config = resolveConfig('dash', options); - assert.equal(config.metricsDefaultDimensions, undefined); + it('defaultDimensions is undefined when metrics BB has none', () => { + const config = resolveConfig('dash', { metrics: { metrics: { namespace: 'MyApp' } } }); + assert.equal(config.metrics[0].defaultDimensions, undefined); }); - it('metricsDefaultDimensions is undefined when defaultDimensions is empty object', () => { - const options: DashboardOptions = { - metrics: { namespace: 'MyApp', defaultDimensions: {} }, - }; - const config = resolveConfig('dash', options); - assert.equal(config.metricsDefaultDimensions, undefined); - }); - - it('metricsDefaultDimensions is undefined when no metrics BB provided', () => { - const config = resolveConfig('dash', {}); - assert.equal(config.metricsDefaultDimensions, undefined); - }); -}); - -describe('buildLambdaWidgets', () => { - it('produces 4 GraphWidgets in 2 rows', () => { - const rows = buildLambdaWidgets('my-function', 'us-east-1'); - assert.equal(rows.length, 2); - assert.equal(rows[0].length, 2); - assert.equal(rows[1].length, 2); - - const json = flattenWidgetJson(rows); - assert.equal(json.length, 4); - const titles = json.map((w: any) => w.properties.title); - assert.ok(titles.includes('Lambda Invocations')); - assert.ok(titles.includes('Lambda Errors')); - assert.ok(titles.includes('Lambda Duration')); - assert.ok(titles.includes('Lambda Concurrent Executions')); - }); - - it('uses the passed region', () => { - const json = flattenWidgetJson(buildLambdaWidgets('fn', 'eu-west-1')); - for (const widget of json) { - assert.equal(widget.properties.region, 'eu-west-1'); - } - }); - - it('each widget is 12 units wide', () => { - const json = flattenWidgetJson(buildLambdaWidgets('fn', 'us-east-1')); - for (const widget of json) { - assert.equal(widget.width, 12); - assert.equal(widget.height, 6); - } + it('defaultDimensions is undefined when defaultDimensions is empty object', () => { + const config = resolveConfig('dash', { metrics: { metrics: { namespace: 'MyApp', defaultDimensions: {} } } }); + assert.equal(config.metrics[0].defaultDimensions, undefined); }); }); @@ -427,99 +379,42 @@ describe('buildMetricsWidgets', () => { }); }); -describe('buildLoggingWidgets', () => { - it('produces a log query widget and a log volume graph', () => { - const rows = buildLoggingWidgets('/aws/lambda/test-fn', 'us-east-1'); - const json = flattenWidgetJson(rows); - - assert.equal(json.length, 2); - const titles = json.map((w: any) => w.properties.title); - assert.ok(titles.includes('Recent Errors')); - assert.ok(titles.includes('Log Volume')); - }); - - it('log query widget is type "log"', () => { - const json = flattenWidgetJson(buildLoggingWidgets('/aws/lambda/fn', 'us-east-1')); - const logWidget = json.find((w: any) => w.properties.title === 'Recent Errors'); - assert.equal(logWidget.type, 'log'); - }); -}); - -describe('buildTracingWidgets', () => { - it('produces a trace widget', () => { - const rows = buildTracingWidgets('my-function', 'us-east-1'); - const json = flattenWidgetJson(rows); - - assert.equal(json.length, 1); - assert.equal(json[0].type, 'trace'); - assert.equal(json[0].properties.title, 'Traces'); - assert.equal(json[0].width, 24); - assert.equal(json[0].height, 9); - }); - - it('includes correct filter query with function name', () => { - const json = flattenWidgetJson(buildTracingWidgets('handler-fn', 'eu-west-1')); - assert.equal(json[0].properties.region, 'eu-west-1'); - assert.ok(json[0].properties.filters.query.includes('handler-fn')); - assert.ok(json[0].properties.filters.query.includes('AWS::Lambda::Function')); - }); -}); - -describe('TraceWidget', () => { - it('extends ConcreteWidget and implements toJson', () => { - const widget = new TraceWidget({ - title: 'My Traces', - functionName: 'test-fn', - region: 'us-west-2', - width: 24, - height: 9, - }); - - const json = widget.toJson(); - assert.equal(json.length, 1); - assert.equal(json[0].type, 'trace'); - assert.equal(json[0].properties.title, 'My Traces'); - assert.equal(json[0].properties.region, 'us-west-2'); - assert.equal(json[0].width, 24); - assert.equal(json[0].height, 9); - }); - - it('uses default width/height when not specified', () => { - const widget = new TraceWidget({ - functionName: 'fn', - region: 'us-east-1', - }); - assert.equal(widget.width, 24); - assert.equal(widget.height, 9); - }); -}); - describe('buildDashboardWidgets', () => { - it('always includes Lambda handler section with header', () => { + it('includes a per-compute header (the compute label) and forwards its health rows', () => { const config = resolveConfig('dash'); - const rows = buildDashboardWidgets(config, 'my-function', 'us-east-1'); + const rows = buildDashboardWidgets(stubComputeSections('us-east-1', { label: 'api' }), config, 'us-east-1'); const json = flattenWidgetJson(rows); - // First widget should be the section header + // First widget is the compute group header, titled with the compute label. assert.equal(json[0].type, 'text'); - assert.ok(json[0].properties.markdown.includes('Lambda Handler')); + assert.ok(json[0].properties.markdown.includes('api')); - // Should have Lambda widgets + // The compute's self-reported health rows are forwarded verbatim. const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown); - assert.ok(titles.some((t: string) => t?.includes('Lambda Invocations'))); - assert.ok(titles.some((t: string) => t?.includes('Lambda Errors'))); - assert.ok(titles.some((t: string) => t?.includes('Lambda Duration'))); - assert.ok(titles.some((t: string) => t?.includes('Lambda Concurrent Executions'))); + assert.ok(titles.some((t: string) => t?.includes('Compute Health A'))); + assert.ok(titles.some((t: string) => t?.includes('Compute Health B'))); + }); + + it('renders one group per compute, in order', () => { + const config = resolveConfig('dash'); + const computes: ComputeDashboardSection[] = [ + { label: 'api', health: stubHealthRows('us-east-1') }, + { label: 'worker', health: stubHealthRows('us-east-1') }, + ]; + const json = flattenWidgetJson(buildDashboardWidgets(computes, config, 'us-east-1')); + const headers = json + .filter((w: any) => w.type === 'text' && w.properties.markdown?.startsWith('## 🔧')) + .map((w: any) => w.properties.markdown); + assert.deepEqual(headers, ['## 🔧 api', '## 🔧 worker']); }); it('uses the passed region in all widget properties', () => { const config = resolveConfig('dash', { - metrics: { namespace: 'MyApp' }, - metricConfigs: [{ name: 'Latency' }], - logger: { fullId: 'myapp-log' }, - tracer: { fullId: 'myapp-tracer' }, - }, 'fn'); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'my-function', 'eu-west-1')); + metrics: { metrics: { namespace: 'MyApp' }, metricConfigs: [{ name: 'Latency' }] }, + }); + const json = flattenWidgetJson( + buildDashboardWidgets(stubComputeSections('eu-west-1', { logging: true, tracing: true }), config, 'eu-west-1'), + ); const widgetsWithRegion = json.filter((w: any) => w.properties?.region); assert.ok(widgetsWithRegion.length > 0); @@ -528,15 +423,14 @@ describe('buildDashboardWidgets', () => { } }); - it('includes metrics section when metrics BB is provided', () => { + it('includes an app-wide metrics section when metrics BB is provided', () => { const config = resolveConfig('dash', { - metrics: { namespace: 'MyApp' }, - metricConfigs: [ - { name: 'RequestCount' }, - { name: 'Latency' }, - ], + metrics: { + metrics: { namespace: 'MyApp' }, + metricConfigs: [{ name: 'RequestCount' }, { name: 'Latency' }], + }, }); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + const json = flattenWidgetJson(buildDashboardWidgets(stubComputeSections('us-east-1'), config, 'us-east-1')); const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown ?? ''); assert.ok(titles.some((t: string) => t.includes('📊 Metrics'))); @@ -544,69 +438,81 @@ describe('buildDashboardWidgets', () => { assert.ok(titles.includes('Latency')); }); - it('includes logger section when logger BB is provided', () => { + it('includes one metrics section per namespace for an array of metrics', () => { const config = resolveConfig('dash', { - logger: { fullId: 'myapp-log' }, - }, 'test-fn'); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + metrics: [ + { metrics: { namespace: 'orders' }, metricConfigs: [{ name: 'Count' }] }, + { metrics: { namespace: 'billing' }, metricConfigs: [{ name: 'Count' }] }, + ], + }); + const json = flattenWidgetJson(buildDashboardWidgets(stubComputeSections('us-east-1'), config, 'us-east-1')); + const metricHeaders = json + .filter((w: any) => w.type === 'text' && w.properties.markdown?.includes('📊 Metrics')) + .map((w: any) => w.properties.markdown); + assert.equal(metricHeaders.length, 2); + assert.ok(metricHeaders.some((h: string) => h.includes('orders'))); + assert.ok(metricHeaders.some((h: string) => h.includes('billing'))); + }); + + it('includes the logs section (forwarding the compute log rows) when a Logger is attached', () => { + const config = resolveConfig('dash'); + const json = flattenWidgetJson( + buildDashboardWidgets(stubComputeSections('us-east-1', { logging: true }), config, 'us-east-1'), + ); const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown ?? ''); assert.ok(titles.some((t: string) => t.includes('📋 Logs'))); - assert.ok(titles.some((t: string) => t.includes('Recent Errors'))); - assert.ok(titles.some((t: string) => t.includes('Log Volume'))); + assert.ok(titles.some((t: string) => t.includes('Stub Recent Errors'))); }); - it('includes tracer section when tracer BB is provided', () => { - const config = resolveConfig('dash', { tracer: { fullId: 'myapp-tracer' } }); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + it('includes the traces section (forwarding the compute trace rows) when a Tracer is attached', () => { + const config = resolveConfig('dash'); + const json = flattenWidgetJson( + buildDashboardWidgets(stubComputeSections('us-east-1', { tracing: true }), config, 'us-east-1'), + ); const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown ?? ''); assert.ok(titles.some((t: string) => t.includes('🔍 Traces'))); - - const traceWidget = json.find((w: any) => w.type === 'trace'); - assert.ok(traceWidget, 'Should include a trace widget'); - assert.equal(traceWidget.properties.title, 'Traces'); + assert.ok(titles.some((t: string) => t.includes('Stub Traces'))); }); it('does not include metrics section when no metrics config', () => { const config = resolveConfig('dash'); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + const json = flattenWidgetJson(buildDashboardWidgets(stubComputeSections('us-east-1'), config, 'us-east-1')); const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown ?? ''); assert.ok(!titles.some((t: string) => t.includes('Custom Metrics'))); assert.ok(!titles.some((t: string) => t.includes('📊 Metrics'))); }); - it('does not include logger section when no logger config', () => { + it('does not include logs section when the compute has no logging rows', () => { const config = resolveConfig('dash'); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + const json = flattenWidgetJson(buildDashboardWidgets(stubComputeSections('us-east-1'), config, 'us-east-1')); const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown ?? ''); - assert.ok(!titles.some((t: string) => t.includes('Recent Errors'))); + assert.ok(!titles.some((t: string) => t.includes('Stub Recent Errors'))); assert.ok(!titles.some((t: string) => t.includes('📋 Logs'))); }); - it('does not include tracer section when tracer disabled', () => { + it('does not include traces section when the compute has no tracing rows', () => { const config = resolveConfig('dash'); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + const json = flattenWidgetJson(buildDashboardWidgets(stubComputeSections('us-east-1'), config, 'us-east-1')); - const traceWidget = json.find((w: any) => w.type === 'trace'); - assert.ok(!traceWidget, 'Should not include a trace widget'); const titles = json.map((w: any) => w.properties?.title ?? w.properties?.markdown ?? ''); + assert.ok(!titles.some((t: string) => t.includes('Stub Traces'))); assert.ok(!titles.some((t: string) => t.includes('🔍 Traces'))); }); it('section headers use full-width TextWidgets', () => { const config = resolveConfig('dash', { - metrics: { namespace: 'NS' }, - metricConfigs: [{ name: 'A' }], - logger: { fullId: 'myapp-log' }, - tracer: { fullId: 'myapp-tracer' }, - }, 'fn'); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); - - const headers = json.filter((w: any) => w.type === 'text' && w.properties.markdown?.startsWith('##')); - assert.ok(headers.length >= 4); // Lambda, Metrics, Traces, Logs + metrics: { metrics: { namespace: 'NS' }, metricConfigs: [{ name: 'A' }] }, + }); + const json = flattenWidgetJson( + buildDashboardWidgets(stubComputeSections('us-east-1', { logging: true, tracing: true }), config, 'us-east-1'), + ); + + const headers = json.filter((w: any) => w.type === 'text' && w.properties.markdown?.startsWith('#')); + assert.ok(headers.length >= 4); // compute, Traces, Logs, Metrics for (const header of headers) { assert.equal(header.width, 24); } @@ -614,12 +520,14 @@ describe('buildDashboardWidgets', () => { it('passes defaultDimensions from metrics BB through to metric widgets', () => { const config = resolveConfig('dash', { - metrics: { namespace: 'MyApp', defaultDimensions: { service: 'orders', env: 'prod' } }, - metricConfigs: [{ name: 'OrderCount' }], + metrics: { + metrics: { namespace: 'MyApp', defaultDimensions: { service: 'orders', env: 'prod' } }, + metricConfigs: [{ name: 'OrderCount' }], + }, }); - const json = flattenWidgetJson(buildDashboardWidgets(config, 'fn', 'us-east-1')); + const json = flattenWidgetJson(buildDashboardWidgets(stubComputeSections('us-east-1'), config, 'us-east-1')); - // Find the metric widget (not the section header or Lambda widgets) + // Find the metric widget (not the section header or health widgets) const metricWidget = json.find((w: any) => w.properties?.title === 'OrderCount'); assert.ok(metricWidget, 'Should have a metric widget for OrderCount'); diff --git a/packages/bb-dashboard/src/types.ts b/packages/bb-dashboard/src/types.ts index b42843124..fb957ae1a 100644 --- a/packages/bb-dashboard/src/types.ts +++ b/packages/bb-dashboard/src/types.ts @@ -3,11 +3,12 @@ /** * Shared types for the Dashboard Building Block. - * This file has zero runtime dependencies — types only. + * This file has zero runtime dependencies — types only (the imports below are + * all `import type`, erased at compile time). * * Uses structural interfaces so that the real observability BB instances - * (Metrics, Logger, Tracer) satisfy these types via duck typing, while - * tests can pass minimal mock objects. + * (Metrics) satisfy these types via duck typing, while tests can pass minimal + * mock objects. */ // ── Observability BB structural interfaces ────────────────────────────────── @@ -27,23 +28,6 @@ export interface MetricsBBRef { readonly defaultDimensions?: Record; } -/** - * Structural interface satisfied by `@aws-blocks/bb-logger` instances. - * Only requires `fullId` for identification. The log group name is derived - * from the shared Lambda handler's function name. - */ -export interface LoggerBBRef { - readonly fullId: string; -} - -/** - * Structural interface satisfied by `@aws-blocks/bb-tracer` instances. - * Only requires `fullId` for identification. Presence implies tracing is active. - */ -export interface TracerBBRef { - readonly fullId: string; -} - // ── Metric configuration types ────────────────────────────────────────────── /** @@ -89,16 +73,53 @@ export interface MetricConfig { dimensions?: Record; } +/** + * A metrics source for the dashboard: a Metrics Building Block paired with the + * metric names to pre-create widgets for **in that source's namespace**. + * + * Configs are per-source (not dashboard-wide) because metric names are specific + * to a namespace — `OrdersPlaced` lives in the orders namespace, not the billing + * one. With multiple sources, each renders its own metrics section from its own + * configs. + * + * @example + * ```typescript + * metrics: { metrics: ordersMetrics, metricConfigs: [{ name: 'OrdersPlaced' }] } + * // or several namespaces: + * metrics: [ + * { metrics: ordersMetrics, metricConfigs: [{ name: 'OrdersPlaced' }] }, + * { metrics: billingMetrics, metricConfigs: [{ name: 'InvoicesSent' }] }, + * ] + * ``` + */ +export interface MetricsSource { + /** The Metrics Building Block whose resolved `namespace` these widgets query. */ + metrics: MetricsBBRef; + /** + * Metric names to pre-create widgets for, within this source's namespace. + * + * Because metrics are emitted at runtime (via EMF) while widgets are created + * at build time (CDK synth), the construct can't auto-discover them — declare + * them here so widgets are pre-created (they show "Insufficient data" until + * the first emission). Omit for a single placeholder graph. + */ + metricConfigs?: MetricConfig[]; +} + // ── Dashboard configuration types ─────────────────────────────────────────── /** * Configuration options for the Dashboard Building Block. * - * Pass real observability BB instances for automatic, type-safe integration. - * The Dashboard extracts configuration directly from the BB instances: - * - **Metrics**: uses `namespace` (the resolved CloudWatch namespace) - * - **Logger**: presence triggers log widgets; log group derived from Lambda handler - * - **Tracer**: presence implies X-Ray tracing is active + * The dashboard is organized **by compute**. Each selected compute contributes a + * health section always, plus a logs section (logs are always captured) and a + * traces section (only when tracing is enabled on that compute — i.e. the app + * contains a `Tracer`). The compute self-reports what it has; the dashboard's + * {@link logs} / {@link traces} flags decide whether to *display* those sections. + * + * **Metrics** are the exception: they are app-scoped (a CloudWatch namespace is + * not tied to a compute), so they are passed explicitly via {@link metrics} and + * rendered once app-wide. */ export interface DashboardOptions { /** @@ -107,47 +128,55 @@ export interface DashboardOptions { */ title?: string; - // ── Observability BB composition ──────────────────────────────────────── + // ── Display toggles ─────────────────────────────────────────────────────── - /** - * Metrics Building Block instance (or any object with `namespace`). - * When provided, adds metric widgets using the BB's resolved CloudWatch namespace. - */ - metrics?: MetricsBBRef; + // NOTE: there is intentionally no `computes` option yet. The dashboard always + // covers every compute in the app (resolved at finalize). A compute selector + // would leak the internal `Compute` type into the public API before customers + // can construct a compute to pass — it arrives with the multi-compute surface. + // See the TODO in `index.cdk.ts` for the intended behavior when it lands. /** - * Logger Building Block instance (or any object with `fullId`). - * When provided, adds log query widgets using the Lambda handler's log group. + * Whether to render the **logs** section for each compute. Logs are always + * captured, so this is purely a display choice, applied uniformly to every + * compute on the dashboard. + * @default true */ - logger?: LoggerBBRef; + logs?: boolean; /** - * Tracer Building Block instance (or any object with `fullId`). - * When provided, adds X-Ray trace widgets. + * Whether to render the **traces** section for each compute. A compute only + * has traces when tracing is enabled on it (the app contains a `Tracer`), so + * this only suppresses an otherwise-present section; it never fabricates one. + * Applied uniformly to every compute on the dashboard. + * @default true */ - tracer?: TracerBBRef; + traces?: boolean; - // ── Dashboard-specific config ────────────────────────────────────────── + // ── Observability BB composition ──────────────────────────────────────── /** - * Metrics to create dashboard widgets for. + * Metrics source(s) — a Metrics Building Block paired with its metric configs + * ({@link MetricsSource}), or an array of them. Each becomes an app-wide + * metrics section on the dashboard, one per namespace, built from that + * source's own `metricConfigs`. * - * Because metrics are emitted at runtime (via EMF in Lambda) while - * dashboard widgets are created at build time (CDK synth), the construct - * cannot auto-discover what metrics will exist. You must declare them - * here so widgets are pre-created — they will show "Insufficient data" - * until the first emission. + * Metrics are **app-scoped**, not compute-scoped: a CloudWatch namespace is + * a semantic grouping any compute can emit into, so it is rendered once + * app-wide rather than per compute. (Logs and traces, by contrast, are + * compute-scoped: every compute always gets a logs section (stdout is always + * captured), and a traces section whenever tracing is enabled fleet-wide — + * i.e. any `Tracer` exists in the app. Both are subject to the `logs` / + * `traces` display toggles above — see the compute-grouped sections.) * * @example * ```typescript - * metricConfigs: [ - * { name: 'RequestCount' }, - * { name: 'Latency', stat: 'p99', period: 300, title: 'P99 Latency' }, - * { name: 'ErrorRate', stat: 'Average' } - * ] + * metrics: { metrics, metricConfigs: [{ name: 'OrdersPlaced' }, { name: 'Latency', stat: 'p99' }] } * ``` */ - metricConfigs?: MetricConfig[]; + metrics?: MetricsSource | MetricsSource[]; + + // ── Dashboard-specific config ────────────────────────────────────────── /** * Default time range for the dashboard view. @@ -182,16 +211,28 @@ export interface DashboardOptions { } /** - * Resolved configuration after merging BB instances with fallbacks. + * A single app-wide metrics source resolved from a {@link MetricsSource} — its + * namespace, default dimensions, and its own metric configs. + */ +export interface ResolvedMetricsSource { + namespace: string; + defaultDimensions?: Record; + metricConfigs: MetricConfig[]; +} + +/** + * Resolved configuration after normalizing options. * Used internally by the CDK construct. + * + * Logs / traces are not represented here — they are compute-scoped and resolved + * per compute at finalize: logs are always captured for every compute, and + * traces appear when tracing is enabled fleet-wide (any `Tracer` in the app), + * each subject to the `logs` / `traces` display toggles. */ export interface ResolvedDashboardConfig { title: string; dashboardName: string; - metricsNamespace: string | undefined; - metricsDefaultDimensions: Record | undefined; - logGroupName: string | undefined; - tracingEnabled: boolean; - metricConfigs: MetricConfig[]; + /** App-wide metrics sources, one per {@link MetricsSource} passed in. */ + metrics: ResolvedMetricsSource[]; defaultTimeRange: string; } diff --git a/packages/bb-dashboard/src/widgets.ts b/packages/bb-dashboard/src/widgets.ts index 5a0b4a38b..5a93f38c5 100644 --- a/packages/bb-dashboard/src/widgets.ts +++ b/packages/bb-dashboard/src/widgets.ts @@ -6,72 +6,12 @@ * Produces IWidget arrays for the L2 Dashboard construct. */ import { Duration } from 'aws-cdk-lib'; -import { - GraphWidget, - LogQueryWidget, - Metric, - TextWidget, - ConcreteWidget, -} from 'aws-cdk-lib/aws-cloudwatch'; +import { GraphWidget, Metric, TextWidget } from 'aws-cdk-lib/aws-cloudwatch'; import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; -import type { ResolvedDashboardConfig, DashboardOptions, MetricConfig } from './types.js'; +import type { ComputeDashboardSection } from '@aws-blocks/core/cdk/internal'; +import type { ResolvedDashboardConfig, ResolvedMetricsSource, DashboardOptions, MetricConfig } from './types.js'; import { DashboardErrors } from './errors.js'; -// ── Trace widget (no L2 construct exists) ─────────────────────────────────── - -/** - * Custom widget that renders an X-Ray trace list in the CloudWatch Dashboard. - * - * CloudWatch supports a `"type": "trace"` widget, but CDK does not provide - * an L2 construct for it. This class extends `ConcreteWidget` to produce - * the correct JSON. - * - * @example - * ```typescript - * new TraceWidget({ - * title: 'Traces', - * functionName: 'my-handler', - * region: 'us-east-1', - * }); - * ``` - */ -export class TraceWidget extends ConcreteWidget { - private readonly props: TraceWidgetProps; - - constructor(props: TraceWidgetProps) { - super(props.width ?? 24, props.height ?? 9); - this.props = props; - } - - toJson(): any[] { - return [ - { - type: 'trace', - width: this.width, - height: this.height, - x: this.x ?? 0, - y: this.y ?? 0, - properties: { - title: this.props.title ?? 'Traces', - region: this.props.region, - filters: { - query: `service(id(name: "${this.props.functionName}", type: "AWS::Lambda::Function"))`, - group: 'Default', - }, - }, - }, - ]; - } -} - -export interface TraceWidgetProps { - title?: string; - functionName: string; - region: string; - width?: number; - height?: number; -} - // ── Helpers ───────────────────────────────────────────────────────────────── function blocksError(name: string, message: string): Error { @@ -91,92 +31,6 @@ function validateMetricConfig(metric: MetricConfig): void { } } -// ── Lambda health widgets (always included) ───────────────────────────────── - -/** - * Build Lambda health widgets: Invocations, Errors, Duration, ConcurrentExecutions. - * Returns two rows of two 12-wide GraphWidgets each. - */ -export function buildLambdaWidgets(functionName: string, region: string): IWidget[][] { - const invocations = new GraphWidget({ - title: 'Lambda Invocations', - width: 12, - height: 6, - region, - left: [ - new Metric({ - namespace: 'AWS/Lambda', - metricName: 'Invocations', - dimensionsMap: { FunctionName: functionName }, - statistic: 'Sum', - period: Duration.seconds(60), - }), - ], - }); - - const errors = new GraphWidget({ - title: 'Lambda Errors', - width: 12, - height: 6, - region, - left: [ - new Metric({ - namespace: 'AWS/Lambda', - metricName: 'Errors', - dimensionsMap: { FunctionName: functionName }, - statistic: 'Sum', - period: Duration.seconds(60), - }), - ], - }); - - const duration = new GraphWidget({ - title: 'Lambda Duration', - width: 12, - height: 6, - region, - left: [ - new Metric({ - namespace: 'AWS/Lambda', - metricName: 'Duration', - dimensionsMap: { FunctionName: functionName }, - statistic: 'Average', - period: Duration.seconds(60), - label: 'Average', - }), - new Metric({ - namespace: 'AWS/Lambda', - metricName: 'Duration', - dimensionsMap: { FunctionName: functionName }, - statistic: 'p99', - period: Duration.seconds(60), - label: 'p99', - }), - ], - }); - - const concurrency = new GraphWidget({ - title: 'Lambda Concurrent Executions', - width: 12, - height: 6, - region, - left: [ - new Metric({ - namespace: 'AWS/Lambda', - metricName: 'ConcurrentExecutions', - dimensionsMap: { FunctionName: functionName }, - statistic: 'Maximum', - period: Duration.seconds(60), - }), - ], - }); - - return [ - [invocations, errors], - [duration, concurrency], - ]; -} - // ── Metrics widgets ───────────────────────────────────────────────────────── /** @@ -257,62 +111,6 @@ export function buildMetricsWidgets( return [[placeholder]]; } -// ── Logging widgets ───────────────────────────────────────────────────────── - -/** - * Build log widgets: a Log Insights query for recent errors + log volume graph. - */ -export function buildLoggingWidgets(logGroupName: string, region: string): IWidget[][] { - const logQuery = new LogQueryWidget({ - title: 'Recent Errors', - width: 24, - height: 6, - region, - logGroupNames: [logGroupName], - queryLines: [ - 'fields @timestamp, @message', - 'filter @message like /ERROR/ or level = "error"', - 'sort @timestamp desc', - 'limit 20', - ], - }); - - const logVolume = new GraphWidget({ - title: 'Log Volume', - width: 24, - height: 6, - region, - left: [ - new Metric({ - namespace: 'AWS/Logs', - metricName: 'IncomingLogEvents', - dimensionsMap: { LogGroupName: logGroupName }, - statistic: 'Sum', - period: Duration.seconds(300), - }), - ], - }); - - return [[logQuery], [logVolume]]; -} - -// ── Tracing widgets ───────────────────────────────────────────────────────── - -/** - * Build trace widget using the custom TraceWidget class. - */ -export function buildTracingWidgets(functionName: string, region: string): IWidget[][] { - const traceWidget = new TraceWidget({ - title: 'Traces', - functionName, - region, - width: 24, - height: 9, - }); - - return [[traceWidget]]; -} - // ── Section headers ───────────────────────────────────────────────────── function sectionHeader(text: string): IWidget[] { @@ -328,39 +126,61 @@ function sectionHeader(text: string): IWidget[] { // ── Main builder ──────────────────────────────────────────────────────── /** - * Build the complete set of dashboard widget rows from resolved configuration. + * Build the complete set of dashboard widget rows. + * + * The dashboard is organized **by compute**: each compute contributes a group + * (health, plus logs / traces when attached), rendered in the order the + * computes are given. App-wide **metrics** sections follow, one per namespace — + * metrics are not compute-scoped, so they render once at the end. * - * Returns an array of widget rows (each row is an array of IWidget). - * Each row will be added to the Dashboard via `addWidgets()`. + * Returns an array of widget rows (each row is an array of IWidget) to add to + * the Dashboard via `addWidgets()`. * - * @param config - Resolved dashboard configuration. - * @param functionName - Lambda function name for base health widgets. + * @param computes - Per-compute self-reported sections, in display order. + * @param config - Resolved dashboard configuration (metrics + metricConfigs). * @param region - AWS region string. * @returns Array of widget rows for the Dashboard. */ -export function buildDashboardWidgets(config: ResolvedDashboardConfig, functionName: string, region: string): IWidget[][] { +export function buildDashboardWidgets( + computes: ComputeDashboardSection[], + config: ResolvedDashboardConfig, + region: string, +): IWidget[][] { const rows: IWidget[][] = []; - // Lambda handler section - rows.push(sectionHeader('## 🔧 Lambda Handler')); - rows.push(...buildLambdaWidgets(functionName, region)); - - // Metrics section - if (config.metricsNamespace) { - rows.push(sectionHeader('## 📊 Metrics')); - rows.push(...buildMetricsWidgets(config.metricsNamespace, config.metricConfigs, region, config.metricsDefaultDimensions)); - } + // Compute-scoped groups: one per compute, in registration order. Each compute + // self-reports its health/logs/traces rows, so the dashboard makes no + // single-function assumption. For the default single-compute app this is one + // group with the usual Invocations/Errors/Duration/Concurrency widgets. + for (const compute of computes) { + rows.push(sectionHeader(`## 🔧 ${compute.label}`)); + rows.push(...compute.health); + + // Traces — present only when tracing is enabled fleet-wide, i.e. some + // `Tracer` exists in the app (see cdk/tracer-registry.ts). Also cleared + // upstream by the dashboard's `traces: false` display toggle. + if (compute.tracing) { + rows.push(sectionHeader('### 🔍 Traces')); + rows.push(...compute.tracing); + } - // Tracing section - if (config.tracingEnabled) { - rows.push(sectionHeader('## 🔍 Traces')); - rows.push(...buildTracingWidgets(functionName, region)); + // Logs — always captured for every compute (stdout → its own log group, + // no Logger required). Falsy here only when the dashboard's `logs: false` + // display toggle cleared this section. + if (compute.logging) { + rows.push(sectionHeader('### 📋 Logs')); + rows.push(...compute.logging); + } } - // Logging section - if (config.logGroupName) { - rows.push(sectionHeader('## 📋 Logs')); - rows.push(...buildLoggingWidgets(config.logGroupName, region)); + // App-wide metrics sections — not compute-scoped, so rendered once per + // namespace after the compute groups, each from its own metric configs. A + // source with no configs is skipped entirely (nothing to graph) rather than + // rendering a blank placeholder widget with an empty metric name. + for (const metrics of config.metrics) { + if (metrics.metricConfigs.length === 0) continue; + rows.push(sectionHeader(`## 📊 Metrics — ${metrics.namespace}`)); + rows.push(...buildMetricsWidgets(metrics.namespace, metrics.metricConfigs, region, metrics.defaultDimensions)); } return rows; @@ -371,46 +191,33 @@ export function buildDashboardWidgets(config: ResolvedDashboardConfig, functionN * * Resolution: * - **Metrics namespace**: derived from `metrics.namespace` - * - **Log group**: the framework-owned handler log group (`handlerLogGroupName`) - * when the Logger BB is present; falls back to the `/aws/lambda/` - * convention when the group name isn't supplied. - * - **Tracing**: enabled when Tracer BB instance is provided * * @param id - Dashboard construct ID used as fallback for title. * @param options - User-provided dashboard configuration. - * @param functionName - Lambda function name for auto-deriving logGroupName (fallback only). * @param scopeFullId - Fully-qualified scope identifier (includes stack name) used as the * default dashboardName to ensure uniqueness across environments/deployments. - * @param handlerLogGroupName - The shared handler log group's name (`scope.handlerLogGroup.logGroupName`). - * Preferred over the `/aws/lambda/` convention: the framework now owns a - * dedicated handler log group with a generated (non-`/aws/lambda/`) name, - * which the reconstruction would miss. */ -export function resolveConfig(id: string, options?: DashboardOptions, functionName?: string, scopeFullId?: string, handlerLogGroupName?: string): ResolvedDashboardConfig { - const metricsNamespace = options?.metrics ? options.metrics.namespace : undefined; - - const metricsDefaultDimensions = options?.metrics?.defaultDimensions - && Object.keys(options.metrics.defaultDimensions).length > 0 - ? options.metrics.defaultDimensions - : undefined; - - // Point log widgets at the actual handler log group. Prefer the framework-owned - // group's name; fall back to the `/aws/lambda/` convention when it isn't - // supplied (e.g. a caller that only knows the function name). - const logGroupName = options?.logger - ? (handlerLogGroupName ?? (functionName ? `/aws/lambda/${functionName}` : undefined)) - : undefined; - - const tracingEnabled = options?.tracer !== undefined; +export function resolveConfig(id: string, options?: DashboardOptions, scopeFullId?: string): ResolvedDashboardConfig { + // Normalize the single-or-array `metrics` option into a list of app-wide + // sources. Each carries its own metricConfigs (namespace-specific); empty + // defaultDimensions are dropped so widget queries stay clean. + const sources = options?.metrics === undefined + ? [] + : Array.isArray(options.metrics) + ? options.metrics + : [options.metrics]; + const metrics: ResolvedMetricsSource[] = sources.map((source) => ({ + namespace: source.metrics.namespace, + defaultDimensions: source.metrics.defaultDimensions && Object.keys(source.metrics.defaultDimensions).length > 0 + ? source.metrics.defaultDimensions + : undefined, + metricConfigs: source.metricConfigs ?? [], + })); return { title: options?.title ?? id, dashboardName: (options?.dashboardName ?? scopeFullId ?? id).replace(/[^A-Za-z0-9\-_]/g, '-').substring(0, 255), - metricsNamespace, - metricsDefaultDimensions, - logGroupName, - tracingEnabled, - metricConfigs: options?.metricConfigs ?? [], + metrics, defaultTimeRange: options?.defaultTimeRange ?? '-PT3H', }; } diff --git a/packages/bb-lambda-compute/package.json b/packages/bb-lambda-compute/package.json index a1717836f..67ec71b57 100644 --- a/packages/bb-lambda-compute/package.json +++ b/packages/bb-lambda-compute/package.json @@ -39,7 +39,7 @@ "scripts": { "prebuild": "node ../../scripts/generate-version.mjs LambdaCompute", "build": "tsc --build", - "test": "node --test dist/index.cdk.test.js dist/compute-resolution.test.js" + "test": "node --test dist/index.cdk.test.js dist/compute-resolution.test.js dist/tracing.cdk.test.js" }, "dependencies": { "@aws-blocks/core": "^0.3.0" diff --git a/packages/bb-lambda-compute/src/index.cdk.test.ts b/packages/bb-lambda-compute/src/index.cdk.test.ts index 14f42e0c4..df388d56d 100644 --- a/packages/bb-lambda-compute/src/index.cdk.test.ts +++ b/packages/bb-lambda-compute/src/index.cdk.test.ts @@ -19,6 +19,7 @@ import { Compute } from '@aws-blocks/core/cdk/internal'; import * as cdk from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import { Architecture } from 'aws-cdk-lib/aws-lambda'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; import type { Construct } from 'constructs'; import { LambdaCompute } from './index.cdk.js'; @@ -251,13 +252,47 @@ describe('LambdaCompute handler log-group retention (defaults.logRetention)', () }); }); +// Retention is a compute-level setting: the `logRetention` prop overrides the +// stack-wide `defaults.logRetention` on the compute's OWN single handler log +// group (the one the function writes to). Logging itself is always on — there +// is no enable step — so this is purely about the retention policy of that group. +describe('LambdaCompute logRetention prop', () => { + test('overrides the handler group retention without spawning a second group', () => { + const { stack, parent } = setup('LambdaComputeSetRetention', BlocksPresets.production); + new LambdaCompute(parent, 'extra', { logRetention: RetentionDays.ONE_MONTH }); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); + // Still exactly one group — the prop configures the compute's owned group. + template.resourceCountIs('AWS::Logs::LogGroup', 1); + }); + + test('falls back to the stack-wide default retention when the prop is omitted', () => { + const { stack, parent } = setup('LambdaComputeRetentionDefault', BlocksPresets.production); + new LambdaCompute(parent, 'extra'); + // No prop → the group keeps the production default (365). + Template.fromStack(stack).hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 365 }); + }); + + test('the prop wins over the stack-wide default', () => { + const { stack, parent } = setup('LambdaComputeRetentionOverride', BlocksPresets.production); + new LambdaCompute(parent, 'extra', { logRetention: RetentionDays.ONE_WEEK }); + // Production default is 365; the prop narrows it to 7. + Template.fromStack(stack).hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 7 }); + }); +}); + describe('LambdaCompute stage throttling (defaults.throttling)', () => { test('production carries the 1000/2000 rate + burst default', () => { const { stack, parent } = setup('LambdaComputeThrottleProd', BlocksPresets.production); new LambdaCompute(parent, 'extra'); Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::Stage', { MethodSettings: Match.arrayWith([ - Match.objectLike({ HttpMethod: '*', ResourcePath: '/*', ThrottlingRateLimit: 1000, ThrottlingBurstLimit: 2000 }), + Match.objectLike({ + HttpMethod: '*', + ResourcePath: '/*', + ThrottlingRateLimit: 1000, + ThrottlingBurstLimit: 2000, + }), ]), }); }); @@ -279,9 +314,7 @@ describe('LambdaCompute stage throttling (defaults.throttling)', () => { }); new LambdaCompute(parent, 'extra'); Template.fromStack(stack).hasResourceProperties('AWS::ApiGateway::Stage', { - MethodSettings: Match.arrayWith([ - Match.objectLike({ ThrottlingRateLimit: 50, ThrottlingBurstLimit: 75 }), - ]), + MethodSettings: Match.arrayWith([Match.objectLike({ ThrottlingRateLimit: 50, ThrottlingBurstLimit: 75 })]), }); }); }); @@ -345,3 +378,87 @@ describe('LambdaCompute stage access logging (defaults.accessLogging)', () => { template.resourceCountIs('AWS::ApiGateway::Stage', 2); }); }); + +// Observability surface the Dashboard reads off the compute. Logging is always +// on: the compute owns one handler log group (created in its constructor with +// its resolved retention), so `dashboardSection().logging` is always present. +// Tracing is presence-gated: it only turns on (and only then does the traces +// section appear) after `enableTracing()`, which the framework calls on every +// compute when the app contains a Tracer. +describe('LambdaCompute observability', () => { + test('dashboardSection.logging is always present (logs are always captured)', () => { + const { parent } = setup('LambdaComputeLogEnabled'); + + const compute = new LambdaCompute(parent, 'extra'); + + // No enable step for logging — the logs section renders unconditionally. + assert.notStrictEqual(compute.dashboardSection('us-east-1').logging, undefined); + }); + + test('enableTracing turns on the function Active tracing mode', () => { + const { stack, parent } = setup('LambdaComputeTracing'); + + const compute = new LambdaCompute(parent, 'extra'); + compute.enableTracing(); + + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Lambda::Function', { + TracingConfig: { Mode: 'Active' }, + }); + // The X-Ray publish IAM grant is applied once on the shared role by core's + // finalizeTracing (not per compute) — asserted in tracing.cdk.test.ts. + }); + + test('dashboardSection returns the Lambda health widget rows', () => { + const { parent } = setup('LambdaComputeWidgets'); + + const compute = new LambdaCompute(parent, 'extra'); + const rows = compute.dashboardSection('us-east-1').health; + + assert.strictEqual(rows.length, 2, 'two rows'); + assert.strictEqual(rows[0].length, 2, 'first row has two widgets'); + assert.strictEqual(rows[1].length, 2, 'second row has two widgets'); + + const titles = rows.flat().flatMap((w) => w.toJson().map((j: any) => j.properties?.title)); + for (const t of ['Lambda Invocations', 'Lambda Errors', 'Lambda Duration', 'Lambda Concurrent Executions']) { + assert.ok(titles.includes(t), `expected a "${t}" widget`); + } + }); + + test('dashboardSection has logs always but omits traces until tracing is enabled', () => { + const { parent } = setup('LambdaComputeGating'); + + const compute = new LambdaCompute(parent, 'extra'); + const section = compute.dashboardSection('us-east-1'); + assert.notStrictEqual(section.logging, undefined, 'logs section is always present'); + assert.equal(section.tracing, undefined, 'no traces section until tracing is enabled'); + }); + + test("dashboardSection.logging queries this compute's own handler log group", () => { + const { parent } = setup('LambdaComputeLogWidgets'); + + const compute = new LambdaCompute(parent, 'extra'); + const json = (compute.dashboardSection('us-east-1').logging ?? []).flat().flatMap((w) => w.toJson()); + + const titles = json.map((j: any) => j.properties?.title); + assert.ok(titles.includes('Recent Errors'), 'has a recent-errors log query widget'); + assert.ok(titles.includes('Log Volume'), 'has a log-volume widget'); + // The log query targets the compute's own handler log group. + const logWidget = json.find((j: any) => j.properties?.title === 'Recent Errors'); + assert.equal(logWidget.type, 'log'); + }); + + test('dashboardSection.tracing emits an X-Ray trace widget once a Tracer is attached', () => { + const { parent } = setup('LambdaComputeTraceWidgets'); + + const compute = new LambdaCompute(parent, 'extra'); + compute.enableTracing(); + const json = (compute.dashboardSection('eu-west-1').tracing ?? []).flat().flatMap((w) => w.toJson()); + + assert.strictEqual(json.length, 1, 'one trace widget'); + assert.equal(json[0].type, 'trace'); + assert.equal(json[0].properties.title, 'Traces'); + assert.equal(json[0].properties.region, 'eu-west-1'); + assert.ok(json[0].properties.filters.query.includes('AWS::Lambda::Function')); + }); +}); diff --git a/packages/bb-lambda-compute/src/index.cdk.ts b/packages/bb-lambda-compute/src/index.cdk.ts index ec8b7e872..c05b74fd0 100644 --- a/packages/bb-lambda-compute/src/index.cdk.ts +++ b/packages/bb-lambda-compute/src/index.cdk.ts @@ -2,13 +2,20 @@ // SPDX-License-Identifier: Apache-2.0 import type { ScopeParent } from '@aws-blocks/core'; -import { BLOCKS_RPC_PREFIX, DEFAULT_NODE_RUNTIME, blocksNodejsBundling, ensureApiGatewayAccount } from '@aws-blocks/core/cdk'; +import { + BLOCKS_RPC_PREFIX, + blocksNodejsBundling, + DEFAULT_NODE_RUNTIME, + ensureApiGatewayAccount, +} from '@aws-blocks/core/cdk'; import { BLOCKS_NAMESPACE, Compute } from '@aws-blocks/core/cdk/internal'; import * as cdk from 'aws-cdk-lib'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import { Architecture } from 'aws-cdk-lib/aws-lambda'; import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import { LogGroup } from 'aws-cdk-lib/aws-logs'; +import { applyXRayTracing, buildHealthWidgets, buildLoggingWidgets, buildTracingWidgets } from './observability.js'; import type { LambdaComputeProps } from './types.js'; export type { LambdaComputeProps } from './types.js'; @@ -49,10 +56,10 @@ export class LambdaCompute extends Compute { /** The RPC endpoint URL (`{gateway}/aws-blocks/api`). */ readonly apiUrl: string; /** - * The handler's CloudWatch log group. `bb-logger` reconfigures its retention. - * Named `logGroup` (not `handlerLogGroup`) to avoid clashing with the - * inherited {@link Scope.handlerLogGroup} accessor, which resolves back to - * the owning stack/backend's default compute (i.e. this). + * The handler's CloudWatch log group. Logs are always captured here; the + * retention comes from this compute's `logRetention` prop, falling back to the + * stack-wide `defaults.logRetention`. Named `logGroup` (not `handlerLogGroup`) + * to avoid clashing with the inherited {@link Scope.handlerLogGroup} accessor. */ readonly logGroup: LogGroup; @@ -61,11 +68,11 @@ export class LambdaCompute extends Compute { // The single CloudWatch log group for the handler. Owning it (a real // LogGroup passed as the function's `logGroup`) makes its retention follow - // the stack-wide default instead of AWS's infinite default, and gives - // bb-logger one group to reconfigure rather than a second, colliding one. - // Torn down with the stack (logs are not durable state). + // this compute's setting instead of AWS's infinite default. Retention is a + // compute-level prop (per-compute override) falling back to the stack-wide + // default. Torn down with the stack (logs are not durable state). this.logGroup = new LogGroup(this, 'HandlerLogGroup', { - retention: this.defaults.logRetention, + retention: options?.logRetention ?? this.defaults.logRetention, removalPolicy: cdk.RemovalPolicy.DESTROY, }); @@ -116,10 +123,13 @@ export class LambdaCompute extends Compute { if (this.defaults.accessLogging) { apiGatewayAccount = ensureApiGatewayAccount(cdk.Stack.of(this)); accessLogGroup = new LogGroup(this, 'ApiAccessLogs', { - retention: this.defaults.logRetention, + // Same per-compute override / stack-default fallback as the handler log + // group, so `logRetention` uniformly governs this compute's log groups. + retention: options?.logRetention ?? this.defaults.logRetention, // Access logs are the request audit trail — follow the stack-wide removal // policy (production RETAIN) so they survive a teardown, unlike the - // handler's operational stdout log group (always DESTROY). + // handler's operational stdout log group (always DESTROY). This removal + // asymmetry with the handler group is intentional. removalPolicy: this.defaults.removalPolicy, }); } @@ -180,6 +190,34 @@ export class LambdaCompute extends Compute { * app resolve different copies of this package. */ static isLambdaCompute(x: unknown): x is LambdaCompute { - return typeof x === 'object' && x !== null && (x as { [LAMBDA_COMPUTE_BRAND]?: unknown })[LAMBDA_COMPUTE_BRAND] === true; + return ( + typeof x === 'object' && + x !== null && + (x as { [LAMBDA_COMPUTE_BRAND]?: unknown })[LAMBDA_COMPUTE_BRAND] === true + ); + } + + protected applyTracing(): void { + // Flip this function to X-Ray Active mode; the IAM grant to publish + // segments is applied once on the shared role by core's finalizeTracing. + applyXRayTracing(this.fn); + } + + protected healthWidgets(region: string): IWidget[][] { + return buildHealthWidgets(this.fn.functionName, region); + } + + protected loggingWidgets(region: string): IWidget[][] { + // Logs are always captured to this compute's own log group (the one wired + // into the function), so this is always available. Query that group's name + // (CDK-generated) rather than the AWS default `/aws/lambda/` name. + return buildLoggingWidgets(this.logGroup.logGroupName, region); + } + + protected tracingWidgets(region: string): IWidget[][] { + if (!this.isTracerEnabled) { + throw new Error(`Compute "${this.id}": tracingWidgets requires a Tracer — call enableTracing() first`); + } + return buildTracingWidgets(this.fn.functionName, region); } } diff --git a/packages/bb-lambda-compute/src/observability.ts b/packages/bb-lambda-compute/src/observability.ts new file mode 100644 index 000000000..ca21e9c45 --- /dev/null +++ b/packages/bb-lambda-compute/src/observability.ts @@ -0,0 +1,223 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * Observability for a Lambda-backed compute: X-Ray tracing activation plus the + * CloudWatch Dashboard widget builders (health, logs, traces). + * + * `LambdaCompute` keeps its class body thin by delegating here: + * - `applyTracing()` → {@link applyXRayTracing} + * - `dashboardSection(region)` → the `build*Widgets` functions, which let the + * Dashboard Building Block assemble a per-compute section without knowing the + * compute is Lambda-shaped. + */ +import { Duration } from 'aws-cdk-lib'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; +import { ConcreteWidget, GraphWidget, LogQueryWidget, Metric } from 'aws-cdk-lib/aws-cloudwatch'; +import type { CfnFunction, IFunction } from 'aws-cdk-lib/aws-lambda'; + +// ── Tracing (X-Ray) ─────────────────────────────────────────────────────── + +/** + * Flip a Lambda compute's function to X-Ray `Active` tracing mode. Called from + * `LambdaCompute.applyTracing()` (which the framework invokes on every compute + * when the app contains a `Tracer`). + * + * This is per-function only — it does **not** grant IAM. The permission to + * publish trace segments is granted once on the shared execution role by core's + * `finalizeTracing`, rather than once per compute (they all assume the same + * role). X-Ray Active mode traces the function on **every** invocation path — + * API Gateway requests, SQS-driven async jobs, EventBridge-scheduled runs alike. + * + * @param fn - The Lambda function backing the compute. + */ +export function applyXRayTracing(fn: IFunction): void { + (fn.node.defaultChild as CfnFunction).tracingConfig = { mode: 'Active' }; +} + +// ── Health widgets ────────────────────────────────────────────────────────── + +/** + * Build Lambda health widgets: Invocations, Errors, Duration, ConcurrentExecutions. + * Returns two rows of two 12-wide GraphWidgets each. + * + * @param functionName - The Lambda function name the widgets query metrics for. + * @param region - AWS region the widgets query metrics in. + */ +export function buildHealthWidgets(functionName: string, region: string): IWidget[][] { + const invocations = new GraphWidget({ + title: 'Lambda Invocations', + width: 12, + height: 6, + region, + left: [ + new Metric({ + namespace: 'AWS/Lambda', + metricName: 'Invocations', + dimensionsMap: { FunctionName: functionName }, + statistic: 'Sum', + period: Duration.seconds(60), + }), + ], + }); + + const errors = new GraphWidget({ + title: 'Lambda Errors', + width: 12, + height: 6, + region, + left: [ + new Metric({ + namespace: 'AWS/Lambda', + metricName: 'Errors', + dimensionsMap: { FunctionName: functionName }, + statistic: 'Sum', + period: Duration.seconds(60), + }), + ], + }); + + const duration = new GraphWidget({ + title: 'Lambda Duration', + width: 12, + height: 6, + region, + left: [ + new Metric({ + namespace: 'AWS/Lambda', + metricName: 'Duration', + dimensionsMap: { FunctionName: functionName }, + statistic: 'Average', + period: Duration.seconds(60), + label: 'Average', + }), + new Metric({ + namespace: 'AWS/Lambda', + metricName: 'Duration', + dimensionsMap: { FunctionName: functionName }, + statistic: 'p99', + period: Duration.seconds(60), + label: 'p99', + }), + ], + }); + + const concurrency = new GraphWidget({ + title: 'Lambda Concurrent Executions', + width: 12, + height: 6, + region, + left: [ + new Metric({ + namespace: 'AWS/Lambda', + metricName: 'ConcurrentExecutions', + dimensionsMap: { FunctionName: functionName }, + statistic: 'Maximum', + period: Duration.seconds(60), + }), + ], + }); + + return [ + [invocations, errors], + [duration, concurrency], + ]; +} + +// ── Log widgets ───────────────────────────────────────────────────────────── + +/** + * Build log widgets for a Lambda log group: a Log Insights recent-errors query + * plus a log-volume graph. Returns one widget per row (each 24 wide). + * + * @param logGroupName - The CloudWatch log group name (e.g. `/aws/lambda/`). + * @param region - AWS region the widgets query in. + */ +export function buildLoggingWidgets(logGroupName: string, region: string): IWidget[][] { + const logQuery = new LogQueryWidget({ + title: 'Recent Errors', + width: 24, + height: 6, + region, + logGroupNames: [logGroupName], + queryLines: [ + 'fields @timestamp, @message', + 'filter @message like /ERROR/ or level = "error"', + 'sort @timestamp desc', + 'limit 20', + ], + }); + + const logVolume = new GraphWidget({ + title: 'Log Volume', + width: 24, + height: 6, + region, + left: [ + new Metric({ + namespace: 'AWS/Logs', + metricName: 'IncomingLogEvents', + dimensionsMap: { LogGroupName: logGroupName }, + statistic: 'Sum', + period: Duration.seconds(300), + }), + ], + }); + + return [[logQuery], [logVolume]]; +} + +// ── Trace widget (no L2 construct exists) ──────────────────────────────────── + +export interface TraceWidgetProps { + title?: string; + functionName: string; + region: string; + width?: number; + height?: number; +} + +/** + * Custom widget that renders an X-Ray trace list in the CloudWatch Dashboard. + * + * CloudWatch supports a `"type": "trace"` widget, but CDK provides no L2 + * construct for it, so this extends `ConcreteWidget` to emit the correct JSON. + */ +export class TraceWidget extends ConcreteWidget { + private readonly props: TraceWidgetProps; + + constructor(props: TraceWidgetProps) { + super(props.width ?? 24, props.height ?? 9); + this.props = props; + } + + toJson(): any[] { + return [ + { + type: 'trace', + width: this.width, + height: this.height, + x: this.x ?? 0, + y: this.y ?? 0, + properties: { + title: this.props.title ?? 'Traces', + region: this.props.region, + filters: { + query: `service(id(name: "${this.props.functionName}", type: "AWS::Lambda::Function"))`, + group: 'Default', + }, + }, + }, + ]; + } +} + +/** + * Build the X-Ray trace widget for a Lambda function. Returns a single row. + * + * @param functionName - The Lambda function name the trace list filters to. + * @param region - AWS region the widget queries in. + */ +export function buildTracingWidgets(functionName: string, region: string): IWidget[][] { + return [[new TraceWidget({ title: 'Traces', functionName, region, width: 24, height: 9 })]]; +} diff --git a/packages/bb-lambda-compute/src/tracing.cdk.test.ts b/packages/bb-lambda-compute/src/tracing.cdk.test.ts new file mode 100644 index 000000000..3cfecd50f --- /dev/null +++ b/packages/bb-lambda-compute/src/tracing.cdk.test.ts @@ -0,0 +1,102 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * CDK-synth tests for presence-gated, fleet-wide tracing. + * + * Tracing is driven by core's `registerTracer` (called by a `Tracer`'s + * constructor) + `finalizeTracing` (run at the end of `create()`), which enables + * X-Ray on every compute in the stack. We drive those core seams + * directly rather than constructing a real `Tracer`: bb-tracer exports its cdk + * variant only under the `cdk` condition, which can't be activated at ESM import + * time from this shared mock-conditioned test process, so importing it would + * resolve the local-mock variant and never touch the compute. Calling + * `registerTracer(...)` is the faithful equivalent of what the Tracer does — the + * same approach the dashboard cdk test takes for `enableTracing`. + */ +import assert from 'node:assert'; +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { dirname, join } from 'node:path'; +import { after, before, describe, test } from 'node:test'; +import { fileURLToPath } from 'node:url'; +import type { BlocksDefaults } from '@aws-blocks/core/cdk'; +import { BlocksPresets, BlocksStack, finalizeTracing, registerTracer } from '@aws-blocks/core/cdk'; +import type { DefaultComputeFactory } from '@aws-blocks/core/cdk/internal'; +import * as cdk from 'aws-cdk-lib'; +import { Match, Template } from 'aws-cdk-lib/assertions'; +import { LambdaCompute } from './index.cdk.js'; + +const lambdaFactory: DefaultComputeFactory = (root) => new LambdaCompute(root as never, 'DefaultCompute'); + +const __dirname = dirname(fileURLToPath(import.meta.url)); +let handlerPath: string; +let backendPath: string; +let tmpDir: string; + +before(() => { + process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --conditions=cdk`; + tmpDir = mkdtempSync(join(__dirname, 'tmp-tracing-cdk-')); + handlerPath = join(tmpDir, 'handler.mjs'); + writeFileSync(handlerPath, "export const handler = async () => ({ statusCode: 200, body: '{}' });\n"); + backendPath = join(tmpDir, 'backend.mjs'); + writeFileSync(backendPath, 'export default () => {};\n'); +}); + +after(() => { + rmSync(tmpDir, { recursive: true, force: true }); +}); + +async function makeStack(id: string, defaults: BlocksDefaults = BlocksPresets.production): Promise { + const app = new cdk.App(); + return BlocksStack.create(app, id, { + backendHandlerPath: handlerPath, + backendCDKPath: backendPath, + defaults, + defaultComputeFactory: lambdaFactory, + }); +} + +/** Lambda functions synthesized with X-Ray Active tracing. */ +function activeFunctions(template: Template): unknown[] { + return Object.values( + template.findResources('AWS::Lambda::Function', { + Properties: { TracingConfig: { Mode: 'Active' } }, + }), + ); +} + +describe('presence-gated tracing (registerTracer + finalizeTracing)', () => { + test('a registered Tracer flips the compute to X-Ray Active + grants the role X-Ray publish', async () => { + const stack = await makeStack('TracingOn'); + registerTracer(stack); // stands in for `new Tracer(scope, id)` + finalizeTracing(stack, stack.executionRole); + + const template = Template.fromStack(stack); + assert.strictEqual(activeFunctions(template).length, 1, 'the compute is traced'); + template.hasResourceProperties( + 'AWS::IAM::Policy', + Match.objectLike({ + PolicyDocument: { + Statement: Match.arrayWith([Match.objectLike({ Action: Match.arrayWith(['xray:PutTraceSegments']) })]), + }, + }), + ); + }); + + test('no Tracer → no compute is traced', async () => { + const stack = await makeStack('TracingOff'); + finalizeTracing(stack, stack.executionRole); // no registerTracer + + assert.strictEqual(activeFunctions(Template.fromStack(stack)).length, 0, 'nothing is traced without a Tracer'); + }); + + test('fleet-wide: one Tracer traces every compute in the app', async () => { + const stack = await makeStack('TracingFleet'); + // A second compute in the same app (multi-compute path). + new LambdaCompute(stack, 'worker'); + registerTracer(stack); + finalizeTracing(stack, stack.executionRole); + + assert.strictEqual(activeFunctions(Template.fromStack(stack)).length, 2, 'both computes are traced'); + }); +}); diff --git a/packages/bb-lambda-compute/src/types.ts b/packages/bb-lambda-compute/src/types.ts index 0f4e4b309..b8e3582f6 100644 --- a/packages/bb-lambda-compute/src/types.ts +++ b/packages/bb-lambda-compute/src/types.ts @@ -2,11 +2,19 @@ // SPDX-License-Identifier: Apache-2.0 import type { Architecture } from 'aws-cdk-lib/aws-lambda'; +import type { RetentionDays } from 'aws-cdk-lib/aws-logs'; /** * Options for constructing a `LambdaCompute`. */ export interface LambdaComputeProps { + /** + * CloudWatch Logs retention for this compute's handler log group. Logs are + * always captured; this only bounds how long they're kept. Per-compute + * override of the stack-wide `defaults.logRetention` (used when omitted). + */ + logRetention?: RetentionDays; + /** * The instruction-set architecture for the compute's Lambda function. * Defaults to **`Architecture.ARM_64`** (AWS Graviton), which is ~20% cheaper diff --git a/packages/bb-logger/API.md b/packages/bb-logger/API.md index 1116d3cd1..588634667 100644 --- a/packages/bb-logger/API.md +++ b/packages/bb-logger/API.md @@ -58,15 +58,11 @@ export const LoggingErrors: { export interface LoggingOptions { defaultContext?: Record; level?: LogLevel; - retention?: RetentionDays; } // @public export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -// @public -export type RetentionDays = 1 | 3 | 5 | 7 | 14 | 30 | 60 | 90 | 120 | 150 | 180 | 365 | 400 | 545 | 731 | 1096 | 1827 | 2192 | 2557 | 2922 | 3288 | 3653; - // (No @packageDocumentation comment for this package) ``` diff --git a/packages/bb-logger/DESIGN.md b/packages/bb-logger/DESIGN.md index 2abedfd14..fb58e1233 100644 --- a/packages/bb-logger/DESIGN.md +++ b/packages/bb-logger/DESIGN.md @@ -8,23 +8,27 @@ Design document for Logger. For usage, see [README.md](./README.md). ## Infrastructure (CDK) -The framework owns a single CloudWatch Logs LogGroup for the shared handler -Lambda (created by the `BlocksStack`/`BlocksBackend` with retention from the -stack-wide `defaults.logRetention`). The Logger construct **reconfigures that -group's retention** rather than creating its own: - -- **Retention:** Resolved as `options.retention ?? scope.defaults.logRetention` - and applied to the shared handler log group via the L1 escape hatch - (`CfnLogGroup.retentionInDays`). An explicit per-Logger `retention` wins over - the stack-wide default; both target the one group. -- **When `retention` is omitted:** The group keeps the stack-wide - `defaults.logRetention` already applied by the BlocksStack/BlocksBackend. -- **No second LogGroup:** Logger deliberately does not create a - `/aws/lambda/${handler.functionName}` group of its own — that would collide - with the framework-owned group on the log-group name. -- **Log level env var:** Sets `LOG_LEVEL` on the shared Lambda handler when - `options.level` is configured. Multiple Logger BBs can coexist with different - levels via constructor options. +**Logging is always on and Logger owns no deploy-time infrastructure.** Every +compute captures stdout to a single CloudWatch Logs LogGroup for its handler, +created by the compute itself. There is no "enable logging" seam — logs always +exist for every compute. + +Because logging is unconditional, the CDK `Logger` is a **no-op placeholder**: +its constructor just lets `new Logger(scope, id)` resolve in a CDK app. Any +number of Loggers can coexist freely (they own nothing to collide over). The +two things a Logger *used* to carry now live elsewhere: + +- **Retention** is a **compute-level setting**, not a Logger option. A compute's + handler log group takes its retention from that compute's `logRetention` prop + (e.g. `LambdaCompute`'s `logRetention`), falling back to the stack-wide + `defaults.logRetention`. Set retention where the compute is constructed, not + on a Logger. (The `retention` option was removed from `LoggingOptions`.) +- **Log level** is purely per-instance *runtime* behavior. A `Logger`'s `level` + is applied by the logger instance itself; a logger without an explicit `level` + defaults to `'info'` (see *Log Level Resolution* below). Blocks does **not** + stamp an app-wide default — there is no `defaults.logLevel`, no `LOG_LEVEL` + env var, and the CDK layer provisions nothing for level. This is why multiple + Loggers with different levels coexist without fighting over shared config. ## Serialization Format @@ -75,15 +79,15 @@ All logging methods are **synchronous**. This is an intentional deviation from t Priority order (highest wins): 1. Constructor `options.level` -2. Global env var: `LOG_LEVEL` -3. Default: `'info'` +2. Default: `'info'` ## Mock Implementation The mock entry point (`index.mock.ts`) re-exports the AWS runtime (`index.aws.ts`) directly. Both environments use the same code: write structured JSON to `process.stdout` / `process.stderr`. There is no mock-specific behavior because the logging mechanism (stdout/stderr → CloudWatch) is provided by the Lambda runtime, not by the BB. - No files created in `.bb-data/` — logs are ephemeral. -- `retention` option is accepted but ignored (no local CloudWatch equivalent). +- Retention is a cloud-only concept (no local CloudWatch equivalent); it is a + compute-level setting and has no effect locally. - Log level filtering works identically to production. ### Mock vs AWS Behavior Differences diff --git a/packages/bb-logger/README.md b/packages/bb-logger/README.md index 2b85e76bf..03d25d877 100644 --- a/packages/bb-logger/README.md +++ b/packages/bb-logger/README.md @@ -41,7 +41,8 @@ new Logger(scope: ScopeParent, id: string, options?: LoggingOptions) **Options:** - `level` — Minimum log level (`'debug' | 'info' | 'warn' | 'error'`). Default: `'info'`. - `defaultContext` — Fields included in every log entry. -- `retention` — CloudWatch Logs retention (days). Creates a LogGroup when set. + +Log retention is not a Logger option — it is a compute-level setting (see [Retention](#retention-production) below). ### Methods @@ -88,12 +89,11 @@ dbLog.warn('Slow query', { table: 'users', durationMs: 500 }); ## Log Level Precedence -1. Constructor `level` option (highest priority) -2. `LOG_LEVEL` environment variable -3. Default: `'info'` +1. Constructor `level` option +2. Default: `'info'` -This allows ops teams to change log levels without code changes via the -`LOG_LEVEL` env var (set automatically by the CDK construct). +Set the level per `Logger` via the `level` option. There is no `LOG_LEVEL` env +var — log level is a runtime construction-time choice. ## Error Object Handling @@ -118,22 +118,26 @@ The logger handles edge cases gracefully: ## Retention (Production) -The shared handler log group already carries the stack-wide default retention -(`defaults.logRetention` — one week in sandbox, one year in production). Set -`retention` only to override it for this handler: +**Logging is always on, and retention is a compute-level setting — not a Logger +option.** Every compute captures its handler's stdout to its own CloudWatch log +group, which carries the stack-wide default retention (`defaults.logRetention` — +one week in sandbox, one year in production). + +To change retention, set `logRetention` on the stack-wide `defaults` (it applies +to every compute's handler log group): ```typescript -const log = new Logger(scope, 'app', { - level: 'warn', - retention: 30, // 30 days — overrides the stack-wide default -}); -``` +import { BlocksPresets } from '@aws-blocks/core/cdk'; +import { RetentionDays } from 'aws-cdk-lib/aws-logs'; -Without `retention`, the stack-wide `defaults.logRetention` applies. The Logger -reconfigures the single, framework-owned handler log group — it does not create -a second `/aws/lambda/` group. +// In your aws-blocks backend, override the preset's logRetention: +defaults: { ...BlocksPresets.production, logRetention: RetentionDays.ONE_MONTH }; +``` -Valid retention values: 1, 3, 5, 7, 14, 30, 60, 90, 120, 150, 180, 365, 400, 545, 731, 1096, 1827, 2192, 2557, 2922, 3288, 3653 days. +A `Logger` no longer reconfigures retention — the compute owns the single, +framework-owned handler log group. Per-compute retention (a `logRetention` prop +on the compute) arrives with the public compute-configuration surface; until +then, `defaults.logRetention` is the retention knob. ## Local Development @@ -141,8 +145,8 @@ In local dev (`npm run dev`), the Logger BB: - Writes structured JSON to stdout/stderr (same as production) - Does NOT persist logs to disk - Does NOT create any files in `.bb-data/` -- `retention` option is ignored locally -- `LOG_LEVEL` env var works the same way +- Retention has no local effect (it is a cloud-only, compute-level setting) +- Log level comes from the `Logger`'s `level` option (default `'info'`) ## Errors diff --git a/packages/bb-logger/src/index.aws.ts b/packages/bb-logger/src/index.aws.ts index 10fd9cf66..30f4c764e 100644 --- a/packages/bb-logger/src/index.aws.ts +++ b/packages/bb-logger/src/index.aws.ts @@ -10,7 +10,7 @@ import { BB_NAME, BB_VERSION } from './version.js'; // ── Public types ──────────────────────────────────────────────────────────── export { LoggingErrors } from './errors.js'; -export type { LogLevel, LoggingOptions, LogEntry, ChildLogger, RetentionDays } from './types.js'; +export type { LogLevel, LoggingOptions, LogEntry, ChildLogger } from './types.js'; // ── Logger (AWS runtime) ────────────────────────────────────────────────────────── @@ -33,7 +33,8 @@ export type { LogLevel, LoggingOptions, LogEntry, ChildLogger, RetentionDays } f * * **Scaling:** No throughput limits from the BB itself. CloudWatch Logs * ingestion scales with Lambda concurrency. Cost is per GB ingested + - * per GB stored. Use log level filtering and `retention` to control costs. + * per GB stored. Use log level filtering to control costs; retention is a + * compute-level setting (`logRetention`), not a Logger option. * * **⚠️ G4 Exception:** All logging methods (`debug`, `info`, `warn`, `error`) * are **synchronous**, not async. Logging writes to stdout/stderr which Lambda @@ -48,9 +49,7 @@ export class Logger extends Scope implements ChildLogger { constructor(scope: ScopeParent, id: string, options?: LoggingOptions) { super(id, { parent: scope, bbName: BB_NAME, bbVersion: BB_VERSION }); this.loggerName = id; - this.level = options?.level - ?? (process.env.LOG_LEVEL as LogLevel | undefined) - ?? 'info'; + this.level = options?.level ?? 'info'; this.defaultContext = options?.defaultContext ?? {}; const logGroupName = `/aws/lambda/${process.env.AWS_LAMBDA_FUNCTION_NAME ?? this.fullId}`; registerSdkIdentifiers(this.fullId, { logGroupName }); diff --git a/packages/bb-logger/src/index.cdk.test.ts b/packages/bb-logger/src/index.cdk.test.ts index 939cfecda..35355ad07 100644 --- a/packages/bb-logger/src/index.cdk.test.ts +++ b/packages/bb-logger/src/index.cdk.test.ts @@ -4,101 +4,78 @@ /** * CDK-side tests for Logger. * - * Logger no longer creates its own `/aws/lambda/` LogGroup (which would - * collide with the framework-owned handler log group). Instead it reconfigures - * retention on the single shared group — but ONLY when an explicit - * `options.retention` is given, so a bare Logger can't clobber a retention set - * by another Logger or the stack default. + * Logger owns **no** deploy-time infrastructure. Logging is always on — every + * compute captures stdout to its own log group, retention is a compute-level + * setting (`logRetention` → `defaults.logRetention`), and the log level is + * per-instance runtime behavior (a `Logger`'s `level`, defaulting to `'info'`). + * A `Logger`'s `level` / `defaultContext` are per-instance *runtime* behavior, + * not deploy config. So + * the CDK construct is a no-op placeholder that only lets `new Logger(scope, id)` + * resolve in a CDK app, and any number of Loggers coexist freely. These tests + * assert exactly that: construction succeeds, provisions nothing, and does not + * touch the compute. */ -import { test, describe } from 'node:test'; +import assert from 'node:assert'; +import { describe, test } from 'node:test'; +import { type BlocksDefaults, BlocksPresets, Scope } from '@aws-blocks/core/cdk'; import * as cdk from 'aws-cdk-lib'; +import { Template } from 'aws-cdk-lib/assertions'; import type { Construct } from 'constructs'; -import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; -import { Scope, DEFAULT_NODE_RUNTIME, BlocksPresets, type BlocksDefaults } from '@aws-blocks/core/cdk'; import { Logger } from './index.cdk.js'; +/** A spy compute that fails the test if the Logger ever pokes it. The new model + * forbids the Logger from touching the compute — logging is always on. */ +class SpyCompute { + touched = false; + enableTracing(): void { + this.touched = true; + } +} + +// Minimal owner. A Logger resolves `id`/`defaults` off the ambient stack; the +// spy compute is here only to prove the Logger never calls into it. class StubBlocksStack extends cdk.Stack { - public readonly handler: cdk.aws_lambda.Function; - public readonly handlerLogGroup: cdk.aws_logs.ILogGroup; public readonly id: string; public readonly defaults: BlocksDefaults; + public readonly _defaultCompute = new SpyCompute(); constructor(scope: Construct, id: string, defaults: BlocksDefaults) { super(scope, id); this.id = id; this.defaults = defaults; (globalThis as any).CURRENT_BLOCKS_STACK = this; - // The framework-owned handler log group carries defaults.logRetention, - // exactly as setupBlocksInfra creates it. - this.handlerLogGroup = new cdk.aws_logs.LogGroup(this, 'HandlerLogGroup', { - retention: defaults.logRetention, - removalPolicy: cdk.RemovalPolicy.DESTROY, - }); - this.handler = new cdk.aws_lambda.Function(this, 'StubHandler', { - runtime: DEFAULT_NODE_RUNTIME, - handler: 'index.handler', - code: cdk.aws_lambda.Code.fromInline('exports.handler = async () => {};'), - logGroup: this.handlerLogGroup, - }); } } -function setup(defaults: BlocksDefaults = BlocksPresets.production): { stack: StubBlocksStack; parent: Scope } { +function setup(defaults: BlocksDefaults = BlocksPresets.production): { + stack: StubBlocksStack; + parent: Scope; + compute: SpyCompute; +} { const app = new cdk.App(); const stack = new StubBlocksStack(app, 'LoggerStack', defaults); const parent = new Scope('app'); - return { stack, parent }; + return { stack, parent, compute: stack._defaultCompute }; } -describe('Logger CDK retention', () => { - test('does not create a second (colliding) log group', () => { +describe('Logger CDK (no-op placeholder)', () => { + test('constructs without provisioning any infrastructure', () => { const { stack, parent } = setup(); new Logger(parent, 'log', { level: 'info' }); - const template = Template.fromStack(stack); - // Only the framework-owned handler log group exists. - template.resourceCountIs('AWS::Logs::LogGroup', 1); - }); - - test('a bare Logger leaves the stack-wide default retention untouched (no clobber)', () => { - const { stack, parent } = setup(BlocksPresets.production); - new Logger(parent, 'log'); - const template = Template.fromStack(stack); - // Retention is whatever setupBlocksInfra set (production → 365); Logger - // must not rewrite it. - template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 365 }); + // Logger owns nothing — no log group, no anything. + Template.fromStack(stack).resourceCountIs('AWS::Logs::LogGroup', 0); }); - test('an explicit per-Logger retention overrides the shared group retention', () => { - const { stack, parent } = setup(BlocksPresets.production); - new Logger(parent, 'log', { retention: 30 }); - const template = Template.fromStack(stack); - template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); - // Still one group — the override mutates the shared group, not a new one. - template.resourceCountIs('AWS::Logs::LogGroup', 1); + test('never pokes the compute (logging is always on, not enabled by a Logger)', () => { + const { parent, compute } = setup(); + new Logger(parent, 'log', { level: 'debug' }); + assert.strictEqual(compute.touched, false, 'Logger must not call into the compute'); }); - test('the last explicit retention wins; a later bare Logger does not reset it', () => { - const { stack, parent } = setup(BlocksPresets.production); - new Logger(parent, 'explicit', { retention: 14 }); - new Logger(parent, 'bare'); // must NOT clobber the 14 above - const template = Template.fromStack(stack); - template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 14 }); - }); - - test('two Loggers with conflicting explicit retention: last wins, with a synth warning', () => { - const { stack, parent } = setup(BlocksPresets.production); - new Logger(parent, 'first', { retention: 14 }); - new Logger(parent, 'second', { retention: 30 }); - const template = Template.fromStack(stack); - // Last explicit value wins on the shared group. - template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); - // …but the clobber is surfaced as a synth warning, not silent. - Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('retention')); - }); - - test('two Loggers with the SAME explicit retention: no conflict warning', () => { - const { stack, parent } = setup(BlocksPresets.production); - new Logger(parent, 'first', { retention: 30 }); - new Logger(parent, 'second', { retention: 30 }); - Annotations.fromStack(stack).hasNoWarning('*', Match.stringLikeRegexp('retention-conflict|overriding')); + test('multiple Loggers coexist freely', () => { + const { stack, parent } = setup(); + new Logger(parent, 'first', { level: 'info' }); + new Logger(parent, 'second', { level: 'warn' }); + // Still no infrastructure, regardless of how many Loggers exist. + Template.fromStack(stack).resourceCountIs('AWS::Logs::LogGroup', 0); }); }); diff --git a/packages/bb-logger/src/index.cdk.ts b/packages/bb-logger/src/index.cdk.ts index 4358eba46..60e6761e1 100644 --- a/packages/bb-logger/src/index.cdk.ts +++ b/packages/bb-logger/src/index.cdk.ts @@ -1,80 +1,29 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import { Annotations } from 'aws-cdk-lib'; -import type { CfnLogGroup } from 'aws-cdk-lib/aws-logs'; -import { Scope, registerConfig } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; +import { Scope } from '@aws-blocks/core/cdk'; import type { LoggingOptions } from './types.js'; -/** - * Marks the explicit retention a `Logger` last wrote to the shared handler log - * group, so a second `Logger` setting a *different* explicit value can warn - * about the silent last-wins (both target the one group). - */ -const EXPLICIT_RETENTION = Symbol.for('BLOCKS_LOGGER_EXPLICIT_RETENTION'); - // Re-export public types and errors (no runtime dependencies) export { LoggingErrors } from './errors.js'; -export type { LogLevel, LoggingOptions, LogEntry, ChildLogger, RetentionDays } from './types.js'; +export type { ChildLogger, LogEntry, LoggingOptions, LogLevel } from './types.js'; /** - * CDK construct for Logger. Sets the retention on the shared handler Lambda's - * CloudWatch log group and the `LOG_LEVEL` environment variable when configured. + * CDK construct for Logger. * - * The framework owns a single log group for the shared handler (created by the - * BlocksStack/BlocksBackend, already carrying `defaults.logRetention`). Logger - * reconfigures **that** group's retention rather than creating a second - * `/aws/lambda/` group, which would collide on the log-group name. - * - * Logger writes the group's retention **only when `options.retention` is - * explicitly set** — an explicit per-Logger value wins over the stack-wide - * default. A bare `new Logger(scope, id)` leaves the group's retention - * untouched: because every Logger targets the same shared group, writing the - * default back would let the last-constructed Logger silently clobber a - * `retention` an earlier Logger set (order-dependent). If two Loggers set - * *different* explicit `retention` values, the last one still wins, but a synth - * warning is emitted so the ambiguity isn't silent. + * Logger owns **no** deploy-time infrastructure. Logging is always on — every + * compute captures stdout to its own log group, and **retention is a + * compute-level setting** (`compute` `logRetention`, falling back to + * `defaults.logRetention`). The log **level** is purely **runtime** behavior: a + * `Logger`'s `level` / `defaultContext` are applied by the logger instance + * itself (per-instance), and a logger without an explicit `level` defaults to + * `'info'` — no deploy-time default or env var is involved. + * So the CDK construct is a no-op placeholder that just lets + * `new Logger(scope, id)` resolve in a CDK app; multiple Loggers coexist freely. */ export class Logger extends Scope { - constructor(scope: ScopeParent, id: string, options?: LoggingOptions) { + constructor(scope: ScopeParent, id: string, _options?: LoggingOptions) { super(id, { parent: scope }); - - // Set global LOG_LEVEL config when level is configured - if (options?.level) { - registerConfig(this, 'LOG_LEVEL', options.level); - } - - // Override retention on the shared handler log group ONLY when this - // Logger explicitly asks for one. Applied via the L1 escape hatch because - // a per-block option must reconfigure the framework-owned group, not spawn - // a competing one. - if (options?.retention) { - const cfnLogGroup = this.handlerLogGroup.node.defaultChild as CfnLogGroup | undefined; - if (!cfnLogGroup) { - // The shared group is always a concrete LogGroup today; guard so an - // imported ILogGroup can't silently drop the requested retention. - throw new Error( - 'Logger: cannot apply `retention` — the shared handler log group is not a concrete ' + - 'LogGroup (it may have been imported). Set retention via the stack-wide ' + - '`defaults.logRetention` instead.', - ); - } - // All Loggers reconfigure the one shared handler group, so the last - // explicit `retention` wins. Warn at synth if a different Logger already - // pinned a conflicting value — silent last-wins is otherwise invisible. - const prior = (cfnLogGroup as unknown as Record)[EXPLICIT_RETENTION]; - if (prior !== undefined && prior !== options.retention) { - Annotations.of(this).addWarningV2( - '@aws-blocks/bb-logger:retention-conflict', - `Logger "${id}" sets handler log retention to ${options.retention} day(s), overriding an ` + - `earlier Logger's explicit ${prior} day(s) — all Loggers share the one handler log group, ` + - 'so the last-constructed value wins. Set a single explicit `retention` (or rely on the ' + - 'stack-wide `defaults.logRetention`) to avoid the ambiguity.', - ); - } - cfnLogGroup.retentionInDays = options.retention; - (cfnLogGroup as unknown as Record)[EXPLICIT_RETENTION] = options.retention; - } } } diff --git a/packages/bb-logger/src/index.test.ts b/packages/bb-logger/src/index.test.ts index 054c94f3d..401369360 100644 --- a/packages/bb-logger/src/index.test.ts +++ b/packages/bb-logger/src/index.test.ts @@ -35,13 +35,11 @@ beforeEach(() => { stderrLines.push(String(chunk)); return true; }) as any; - delete process.env.LOG_LEVEL; }); afterEach(() => { process.stdout.write = origStdoutWrite; process.stderr.write = origStderrWrite; - delete process.env.LOG_LEVEL; }); function getStdoutEntry(index = 0): LogEntry { @@ -160,40 +158,6 @@ describe('level filtering', () => { }); }); -// ── LOG_LEVEL Environment Variable ────────────────────────────────────────── - -describe('LOG_LEVEL env var', () => { - test('reads LOG_LEVEL from environment', () => { - process.env.LOG_LEVEL = 'warn'; - const log = new Logger(fakeScope, 'app'); - log.info('suppressed'); - log.warn('emitted'); - assert.strictEqual(stdoutLines.length, 1); - assert.strictEqual(getStdoutEntry().level, 'warn'); - }); - - test('constructor option overrides env var', () => { - process.env.LOG_LEVEL = 'error'; - const log = new Logger(fakeScope, 'app', { level: 'debug' }); - log.debug('emitted'); - assert.strictEqual(stdoutLines.length, 1); - }); - - test('invalid env var falls through to default', () => { - process.env.LOG_LEVEL = 'invalid'; - const log = new Logger(fakeScope, 'app'); - // 'invalid' won't match any LEVEL_PRIORITY key, so shouldLog returns false for most - // Actually the level is set to 'invalid' which has undefined priority - // This means shouldLog will return NaN >= NaN which is false - // Effectively suppresses all output — acceptable edge case behavior - log.info('test'); - // Since 'invalid' is not in LEVEL_PRIORITY, info priority (1) >= undefined — which is false - assert.strictEqual(stdoutLines.length, 0); - }); - - -}); - // ── Context ───────────────────────────────────────────────────────────────── describe('context', () => { diff --git a/packages/bb-logger/src/types.ts b/packages/bb-logger/src/types.ts index c791a614c..86cfbbea4 100644 --- a/packages/bb-logger/src/types.ts +++ b/packages/bb-logger/src/types.ts @@ -9,11 +9,6 @@ /** Log severity levels, ordered from most verbose to least. */ export type LogLevel = 'debug' | 'info' | 'warn' | 'error'; -/** CloudWatch Logs retention periods (in days). Matches the AWS API. */ -export type RetentionDays = - | 1 | 3 | 5 | 7 | 14 | 30 | 60 | 90 | 120 | 150 | 180 - | 365 | 400 | 545 | 731 | 1096 | 1827 | 2192 | 2557 | 2922 | 3288 | 3653; - /** Configuration for the Logger building block. */ export interface LoggingOptions { /** Minimum log level. Messages below this are silently dropped. Default: 'info'. */ @@ -27,14 +22,6 @@ export interface LoggingOptions { * the entry's real level/message/etc. */ defaultContext?: Record; - /** - * CloudWatch Logs retention period for the shared handler log group. When - * set, it overrides the stack-wide `defaults.logRetention` on the single, - * framework-owned handler log group (the Logger does not create its own - * group). When omitted, the stack-wide default retention applies. Ignored in - * local dev. - */ - retention?: RetentionDays; } /** A structured log entry as emitted to stdout/stderr. */ diff --git a/packages/bb-tracer/DESIGN.md b/packages/bb-tracer/DESIGN.md index f0c6e9b5e..3844cfb85 100644 --- a/packages/bb-tracer/DESIGN.md +++ b/packages/bb-tracer/DESIGN.md @@ -35,13 +35,23 @@ Beyond `addAnnotation` and `addMetadata`, `Segment` exposes two additional metho ## Infrastructure (CDK) -Tracer is a **composite Building Block** — it creates no new AWS resources. It configures tracing on the parent scope's Lambda function: - -- **Tracing mode:** Sets `TracingConfig.Mode = 'Active'` on the Lambda `CfnFunction` (L1 construct). -- **IAM permissions:** Adds `xray:PutTraceSegments` and `xray:PutTelemetryRecords` on resource `'*'` to the Lambda execution role. +Tracer is a **composite Building Block** — it creates no new AWS resources. +Tracing is **presence-gated**: constructing a Tracer records intent by calling +`registerTracer(this)` (core's tracer registry), and at finalize the framework +enables tracing on **every** compute in the stack (`finalizeTracing()` → +`compute.enableTracing()` for each). A Tracer never pokes an individual compute +directly, so any number of Tracers coexist (the registry just records a +boolean), and one Tracer anywhere in the app turns on tracing fleet-wide. This +is deliberate: X-Ray provisions real, costed infrastructure, so it stays off +until the app opts in by creating a Tracer. + +For a Lambda compute, `enableTracing()` turns on: + +- **Tracing mode:** `TracingConfig.Mode = 'Active'` on the Lambda `CfnFunction` (L1 construct). +- **IAM permissions:** `xray:PutTraceSegments` and `xray:PutTelemetryRecords` on resource `'*'`, added to the shared execution role. - **No sampling rules:** X-Ray sampling rules are not managed by this BB. The default sampling rule (1 req/sec + 5% of additional requests) applies unless configured externally. -When `enabled: false` is passed, no CDK mutations occur — the Lambda runs without active tracing. +When `enabled: false` is passed, `registerTracer` is not called — that Tracer records no intent. (If another Tracer in the app is enabled, tracing is still turned on fleet-wide.) ## Mock Implementation diff --git a/packages/bb-tracer/README.md b/packages/bb-tracer/README.md index f7d9e8f5a..bd74c5f32 100644 --- a/packages/bb-tracer/README.md +++ b/packages/bb-tracer/README.md @@ -2,6 +2,11 @@ Distributed tracing backed by AWS X-Ray. +> **Fleet-wide + cost:** tracing is presence-gated — constructing **any** `Tracer` +> in the app enables X-Ray Active mode on **every** compute (X-Ray is billed per +> trace recorded/retrieved). `enabled: false` on a `Tracer` only removes *that* +> Tracer's opt-in; if any other `Tracer` exists, X-Ray still turns on fleet-wide. + **When to use:** You need to trace request flow across services, debug latency issues, or visualize service dependencies. Good for identifying bottlenecks, understanding call chains, and correlating failures across Building Blocks. **When NOT to use:** If you need structured log output, use `Logging`. If you need numeric measurements over time, use `Metrics`. diff --git a/packages/bb-tracer/src/index.cdk.ts b/packages/bb-tracer/src/index.cdk.ts index 72627f1ca..23ef196fc 100644 --- a/packages/bb-tracer/src/index.cdk.ts +++ b/packages/bb-tracer/src/index.cdk.ts @@ -1,26 +1,31 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import type { CfnFunction } from 'aws-cdk-lib/aws-lambda'; -import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; -import { Scope } from '@aws-blocks/core/cdk'; import type { ScopeParent } from '@aws-blocks/core'; -import type { TracerOptions, Segment, AnnotationValue } from './types.js'; +import { registerTracer, Scope } from '@aws-blocks/core/cdk'; +import type { TracerOptions } from './types.js'; -export type { TracerOptions, Segment, AnnotationValue } from './types.js'; +export type { AnnotationValue, Segment, TracerOptions } from './types.js'; +/** + * CDK construct for Tracer. + * + * Tracing is **presence-gated and fleet-wide**: constructing a `Tracer` records + * that the app wants tracing, and at synth the framework enables X-Ray on every + * compute in the stack. `enabled: false` only opts *this* Tracer + * out of that vote — it is **not** a global off switch: if any other `Tracer` + * exists, X-Ray still turns on for every compute. To keep X-Ray off, construct + * no `Tracer` at all. (X-Ray is a costed service, so this is deliberately opt-in.) + */ export class Tracer extends Scope { constructor(scope: ScopeParent, id: string, options?: TracerOptions) { super(id, { parent: scope }); if (options?.enabled !== false) { - const cfnFunction = this.handler.node.defaultChild as CfnFunction; - cfnFunction.tracingConfig = { mode: 'Active' }; - - this.handler.addToRolePolicy(new PolicyStatement({ - actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'], - resources: ['*'], - })); + // Tracing is presence-gated: creating a Tracer signals that the app + // wants tracing. At finalize, every compute in the stack is enabled. + // The Tracer never pokes an individual compute directly. + registerTracer(this); } } } diff --git a/packages/bb-tracer/src/types.ts b/packages/bb-tracer/src/types.ts index ea864970c..713f8af7c 100644 --- a/packages/bb-tracer/src/types.ts +++ b/packages/bb-tracer/src/types.ts @@ -58,8 +58,13 @@ export interface Segment { export interface TracerOptions { /** * Enable or disable tracing. Default: `true`. - * When `false`, all operations are silent no-ops but `startSegment` + * When `false`, all runtime operations are silent no-ops but `startSegment` * still executes the wrapped function normally. + * + * **At deploy time this is not a global off switch.** Tracing is presence-gated + * and fleet-wide: `enabled: false` only means *this* `Tracer` doesn't opt in. + * If any other `Tracer` exists in the app, X-Ray is still enabled on every + * compute. To keep X-Ray off, don't construct a `Tracer` at all. */ enabled?: boolean; diff --git a/packages/blocks/API.md b/packages/blocks/API.md index a6ff0d936..1011904e5 100644 --- a/packages/blocks/API.md +++ b/packages/blocks/API.md @@ -119,7 +119,6 @@ import { LifecycleAdmin } from '@aws-blocks/bb-auth-cognito'; import { LifecycleRule } from '@aws-blocks/bb-file-bucket'; import { LogEntry } from '@aws-blocks/bb-logger'; import { Logger } from '@aws-blocks/bb-logger'; -import { LoggerBBRef } from '@aws-blocks/bb-dashboard'; import { LoggingErrors } from '@aws-blocks/bb-logger'; import { LoggingOptions } from '@aws-blocks/bb-logger'; import { LogLevel } from '@aws-blocks/bb-logger'; @@ -133,6 +132,7 @@ import { MetricsBBRef } from '@aws-blocks/bb-dashboard'; import { MetricsEmitter } from '@aws-blocks/bb-metrics'; import { MetricsErrors } from '@aws-blocks/bb-metrics'; import { MetricsOptions } from '@aws-blocks/bb-metrics'; +import { MetricsSource } from '@aws-blocks/bb-dashboard'; import { MetricUnit } from '@aws-blocks/bb-metrics'; import { MFAPreference } from '@aws-blocks/bb-auth-cognito'; import { ModelConfig } from '@aws-blocks/bb-agent'; @@ -147,7 +147,6 @@ import { RealtimeSubscription } from '@aws-blocks/bb-realtime'; import { RelayOrigin } from '@aws-blocks/bb-auth-oidc'; import { relayOrigin } from '@aws-blocks/bb-auth-oidc'; import { ResetPasswordResult } from '@aws-blocks/bb-auth-cognito'; -import { RetentionDays } from '@aws-blocks/bb-logger'; import { RetrieveOptions } from '@aws-blocks/bb-knowledge-base'; import { RetrieveResult } from '@aws-blocks/bb-knowledge-base'; import { Segment } from '@aws-blocks/bb-tracer'; @@ -175,7 +174,6 @@ import { ToolFactory } from '@aws-blocks/bb-agent'; import { ToolHandlerArgs } from '@aws-blocks/bb-agent'; import { ToolsConfig } from '@aws-blocks/bb-agent'; import { Tracer } from '@aws-blocks/bb-tracer'; -import { TracerBBRef } from '@aws-blocks/bb-dashboard'; import { TracerOptions } from '@aws-blocks/bb-tracer'; import { Transaction } from '@aws-blocks/bb-data'; import { TransactionOptions } from '@aws-blocks/bb-distributed-data'; @@ -502,8 +500,6 @@ export { LogEntry } export { Logger } -export { LoggerBBRef } - export { LoggingErrors } export { LoggingOptions } @@ -530,6 +526,8 @@ export { MetricsErrors } export { MetricsOptions } +export { MetricsSource } + export { MetricUnit } export { MFAPreference } @@ -558,8 +556,6 @@ export { relayOrigin } export { ResetPasswordResult } -export { RetentionDays } - export { RetrieveOptions } export { RetrieveResult } @@ -614,8 +610,6 @@ export { ToolsConfig } export { Tracer } -export { TracerBBRef } - export { TracerOptions } export { Transaction } diff --git a/packages/blocks/src/index.cdk.ts b/packages/blocks/src/index.cdk.ts index e9857ce92..563a58853 100644 --- a/packages/blocks/src/index.cdk.ts +++ b/packages/blocks/src/index.cdk.ts @@ -7,15 +7,15 @@ // factory-injecting wrappers of the same name. export * from '@aws-blocks/core/cdk'; -import type { Construct } from 'constructs'; +import { LambdaCompute } from '@aws-blocks/bb-lambda-compute'; import { - BlocksStack as CoreBlocksStack, - BlocksBackend as CoreBlocksBackend, - type BlocksStackProps, type BlocksBackendProps, + type BlocksStackProps, + BlocksBackend as CoreBlocksBackend, + BlocksStack as CoreBlocksStack, } from '@aws-blocks/core/cdk'; import type { Compute, DefaultComputeFactory } from '@aws-blocks/core/cdk/internal'; -import { LambdaCompute } from '@aws-blocks/bb-lambda-compute'; +import type { Construct } from 'constructs'; // The umbrella is the one package that depends on both core and a concrete // compute, so it supplies the default-compute factory here — a plain import, @@ -54,62 +54,142 @@ export const BlocksBackend = { }; export type BlocksBackend = CoreBlocksBackend; -// Override core's untyped getSdkIdentifiers with typed overloads -export { getSdkIdentifiers } from './sdk-identifiers.js'; - +export type { AuthAction, AuthField, AuthState, AuthUser, BlocksAuth } from '@aws-blocks/auth-common'; +export type { + AgentConfig, + AgentResult, + AgentStreamChunk, + ModelConfig, + StreamOptions, + TokenUsage, + ToolCallRecord, + ToolDefinition, +} from '@aws-blocks/bb-agent'; +export { Agent, AgentErrors, BedrockModels, OllamaModels } from '@aws-blocks/bb-agent'; +export type { AppSettingOptions } from '@aws-blocks/bb-app-setting'; +export { AppSetting, AppSettingErrors } from '@aws-blocks/bb-app-setting'; +export type { + AsyncJobContext, + AsyncJobOptions, + AsyncJobState, + AsyncJobStatus, + AsyncJobTransition, + BatchSubmitResult, + SubmitOptions, + WaitUntilCompleteOptions, +} from '@aws-blocks/bb-async-job'; +export { AsyncJob, AsyncJobErrors } from '@aws-blocks/bb-async-job'; // Building Blocks (CDK versions) -export { AuthBasic, AuthBasicErrors, type AuthBasicUser, type AuthBasicOptions, type PasswordPolicy } from '@aws-blocks/bb-auth-basic'; -export { AuthCognito, AuthCognitoErrors } from '@aws-blocks/bb-auth-cognito'; +export { + AuthBasic, + AuthBasicErrors, + type AuthBasicOptions, + type AuthBasicUser, + type PasswordPolicy, +} from '@aws-blocks/bb-auth-basic'; export type { AuthCognitoOptions, AuthFlowType, + CodeDeliveryDetails, CognitoUser, + ConfirmSignInOptions, + DeviceRecord, + ExternalUserPoolRef, + MFAPreference, + ResetPasswordResult, + SignInNextStep, SignInOptions, SignInResult, - SignInNextStep, - ConfirmSignInOptions, SignUpOptions, SignUpResult, - ResetPasswordResult, - CodeDeliveryDetails, UpdateAttributeOutcome, - MFAPreference, - DeviceRecord, UserAttribute, - ExternalUserPoolRef, } from '@aws-blocks/bb-auth-cognito'; -export { AuthOIDC, AuthOIDCErrors, google, github, customOidc, customOauth2, stubIdp, cognitoFederated, relayOrigin } from '@aws-blocks/bb-auth-oidc'; -export type { AuthOIDCErrorName, OIDCUser, MappedClaims, RelayOrigin } from '@aws-blocks/bb-auth-oidc'; -export type { BlocksAuth, AuthUser, AuthState, AuthAction, AuthField } from '@aws-blocks/auth-common'; -export { KVStore, KVStoreErrors } from '@aws-blocks/bb-kv-store'; -export type { ConditionalWriteOptions, ConditionalDeleteOptions, PutOptions as KVPutOptions, KVStoreOptions, ExternalTableRef } from '@aws-blocks/bb-kv-store'; -export { DistributedTable, DistributedTableErrors } from '@aws-blocks/bb-distributed-table'; -export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions as DTPutOptions, DeleteOptions as DTDeleteOptions, QueryOptions as DTQueryOptions, ScanOptions as DTScanOptions } from '@aws-blocks/bb-distributed-table'; -export { Realtime } from '@aws-blocks/bb-realtime'; -export { Database, DatabaseErrors, fromExisting } from '@aws-blocks/bb-data'; -export { sql } from '@aws-blocks/bb-data'; +export { AuthCognito, AuthCognitoErrors } from '@aws-blocks/bb-auth-cognito'; +export type { AuthOIDCErrorName, MappedClaims, OIDCUser, RelayOrigin } from '@aws-blocks/bb-auth-oidc'; +export { + AuthOIDC, + AuthOIDCErrors, + cognitoFederated, + customOauth2, + customOidc, + github, + google, + relayOrigin, + stubIdp, +} from '@aws-blocks/bb-auth-oidc'; +export type { CronJobEvent, CronJobOptions } from '@aws-blocks/bb-cron-job'; +export { CronJob, CronJobErrors } from '@aws-blocks/bb-cron-job'; +export type { + DashboardOptions, + MetricConfig, + MetricsBBRef, + MetricsSource, +} from '@aws-blocks/bb-dashboard'; +export { Dashboard, DashboardErrors } from '@aws-blocks/bb-dashboard'; export type { DatabaseOptions, ExternalDatabaseRef, SqlQuery, Transaction } from '@aws-blocks/bb-data'; -export { DistributedDatabase, DistributedDatabaseErrors } from '@aws-blocks/bb-distributed-data'; +export { Database, DatabaseErrors, fromExisting, sql } from '@aws-blocks/bb-data'; export type { DistributedDatabaseOptions, TransactionOptions } from '@aws-blocks/bb-distributed-data'; -export { AsyncJob, AsyncJobErrors } from '@aws-blocks/bb-async-job'; -export type { AsyncJobOptions, AsyncJobContext, SubmitOptions, BatchSubmitResult, AsyncJobState, AsyncJobStatus, AsyncJobTransition, WaitUntilCompleteOptions } from '@aws-blocks/bb-async-job'; -export { Agent, AgentErrors, BedrockModels, OllamaModels } from '@aws-blocks/bb-agent'; -export type { AgentConfig, AgentResult, AgentStreamChunk, ToolDefinition, ToolCallRecord, ModelConfig, StreamOptions, TokenUsage } from '@aws-blocks/bb-agent'; -export { CronJob, CronJobErrors } from '@aws-blocks/bb-cron-job'; -export type { CronJobOptions, CronJobEvent } from '@aws-blocks/bb-cron-job'; +export { DistributedDatabase, DistributedDatabaseErrors } from '@aws-blocks/bb-distributed-data'; +export type { + DeleteOptions as DTDeleteOptions, + DistributedTableOptions, + PutOptions as DTPutOptions, + QueryOptions as DTQueryOptions, + ReadValidationMode, + ScanOptions as DTScanOptions, + TableKey, + TableKeyConfig, +} from '@aws-blocks/bb-distributed-table'; +export { DistributedTable, DistributedTableErrors } from '@aws-blocks/bb-distributed-table'; +export type { EmailMessage, EmailOptions, SendBatchResult, SendResult } from '@aws-blocks/bb-email-client'; +export { EmailClient, EmailErrors } from '@aws-blocks/bb-email-client'; +export type { + CorsRule, + ExternalBucketRef as FBExternalBucketRef, + FileBucketOptions, + FileContent, + FileInfo, + GetUrlOptions, + LifecycleRule, + PutOptions as FBPutOptions, + PutUrlOptions, + ScanOptions as FBScanOptions, +} from '@aws-blocks/bb-file-bucket'; export { FileBucket, FileBucketErrors } from '@aws-blocks/bb-file-bucket'; -export type { FileBucketOptions, PutOptions as FBPutOptions, GetUrlOptions, PutUrlOptions, ScanOptions as FBScanOptions, FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef as FBExternalBucketRef } from '@aws-blocks/bb-file-bucket'; -export { AppSetting, AppSettingErrors } from '@aws-blocks/bb-app-setting'; -export type { AppSettingOptions } from '@aws-blocks/bb-app-setting'; +export type { + ChunkingConfig, + ChunkingStrategy, + KnowledgeBaseOptions, + MetadataFilter, + RetrieveOptions, + RetrieveResult, + SourceConfig, + WaitUntilSyncedOptions, +} from '@aws-blocks/bb-knowledge-base'; export { KnowledgeBase, KnowledgeBaseErrors } from '@aws-blocks/bb-knowledge-base'; -export type { KnowledgeBaseOptions, RetrieveOptions, RetrieveResult, MetadataFilter, SourceConfig, ChunkingConfig, ChunkingStrategy, WaitUntilSyncedOptions } from '@aws-blocks/bb-knowledge-base'; -export { Tracer } from '@aws-blocks/bb-tracer'; -export type { TracerOptions, Segment, AnnotationValue } from '@aws-blocks/bb-tracer'; +export type { + ConditionalDeleteOptions, + ConditionalWriteOptions, + ExternalTableRef, + KVStoreOptions, + PutOptions as KVPutOptions, +} from '@aws-blocks/bb-kv-store'; +export { KVStore, KVStoreErrors } from '@aws-blocks/bb-kv-store'; +export type { ChildLogger, LogEntry, LoggingOptions, LogLevel } from '@aws-blocks/bb-logger'; export { Logger, LoggingErrors } from '@aws-blocks/bb-logger'; -export type { LogLevel, LoggingOptions, LogEntry, ChildLogger, RetentionDays } from '@aws-blocks/bb-logger'; -export { EmailClient, EmailErrors } from '@aws-blocks/bb-email-client'; -export type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from '@aws-blocks/bb-email-client'; +export type { + EmitOptions, + ExternalMetricsRef, + MetricDatum, + MetricResolution, + MetricsEmitter, + MetricsOptions, + MetricUnit, +} from '@aws-blocks/bb-metrics'; export { Metrics, MetricsErrors } from '@aws-blocks/bb-metrics'; -export type { MetricsOptions, EmitOptions, MetricDatum, MetricUnit, MetricResolution, ExternalMetricsRef, MetricsEmitter } from '@aws-blocks/bb-metrics'; -export { Dashboard, DashboardErrors } from '@aws-blocks/bb-dashboard'; -export type { DashboardOptions, MetricConfig, MetricsBBRef, LoggerBBRef, TracerBBRef } from '@aws-blocks/bb-dashboard'; +export { Realtime } from '@aws-blocks/bb-realtime'; +export type { AnnotationValue, Segment, TracerOptions } from '@aws-blocks/bb-tracer'; +export { Tracer } from '@aws-blocks/bb-tracer'; +// Override core's untyped getSdkIdentifiers with typed overloads +export { getSdkIdentifiers } from './sdk-identifiers.js'; diff --git a/packages/blocks/src/index.ts b/packages/blocks/src/index.ts index 06a6d1374..259554e7f 100644 --- a/packages/blocks/src/index.ts +++ b/packages/blocks/src/index.ts @@ -21,6 +21,80 @@ export { getSdkIdentifiers } from './sdk-identifiers.js'; // 4. node -e "console.log(require.resolve('@aws-blocks//package.json').replace('/package.json',''))" (last resort) // +/** + * **Shared auth interfaces and UI components for all Blocks auth Building Blocks.** + * + * Provides the `BlocksAuth` interface (implemented by every auth BB), auth state + * types, and framework-agnostic UI components (`Authenticator`, `AuthenticatedContent`, + * `onAuthChange`). Import these when writing provider-agnostic auth code. + * + * Package: `@aws-blocks/auth-common` + * Full docs: `README.md` in the package directory above. + */ +export type { AuthAction, AuthActionInput, AuthField, AuthState, AuthUser, BlocksAuth } from '@aws-blocks/auth-common'; +export type { + AgentConfig, + AgentResult, + AgentStreamChunk, + AgentTool, + DefaultToolContext, + ModelConfig, + StreamOptions, + TokenUsage, + ToolCallRecord, + ToolDefinition, + ToolFactory, + ToolHandlerArgs, + ToolsConfig, +} from '@aws-blocks/bb-agent'; +/** + * **AI agent with streaming, tool calling, and conversation persistence.** + * + * Use for building conversational AI experiences: chatbots, copilots, data + * extraction, or any LLM-powered feature. Supports Bedrock, OpenAI-compatible, + * and CannedProvider (local dev). Tools defined with Zod schemas. Conversation + * history persisted to DynamoDB. Streaming via AsyncJob + Realtime. + * Set `inferenceOnly: true` for simple prompt→response without persistence. + * + * Package: `@aws-blocks/bb-agent` + * Full docs: `README.md` in the package directory above. + */ +export { Agent, AgentErrors, BedrockModels, OllamaModels } from '@aws-blocks/bb-agent'; +export type { AppSettingOptions } from '@aws-blocks/bb-app-setting'; +/** + * **Single application configuration value backed by SSM Parameter Store.** + * + * Use for feature flags, API URLs, thresholds, or structured config objects. + * Set `secret: true` for sensitive values (API keys, tokens) — stored as SSM + * SecureString encrypted with the `aws/ssm` KMS key. Each instance maps to + * exactly one SSM parameter. Supports schema validation for typed objects. + * + * Package: `@aws-blocks/bb-app-setting` + * Full docs: `README.md` in the package directory above. + */ +export { AppSetting, AppSettingErrors } from '@aws-blocks/bb-app-setting'; +export type { + AsyncJobContext, + AsyncJobOptions, + AsyncJobState, + AsyncJobStatus, + AsyncJobTransition, + BatchSubmitResult, + SubmitOptions, + WaitUntilCompleteOptions, +} from '@aws-blocks/bb-async-job'; +/** + * **Background job processing backed by SQS and Lambda.** + * + * Use for fire-and-forget async work: sending emails, processing uploads, + * generating reports, or any task that shouldn't block an API response. + * Supports single and batch submission (up to 10), optional delay, schema + * validation, and automatic retries with dead-letter queue. + * + * Package: `@aws-blocks/bb-async-job` + * Full docs: `README.md` in the package directory above. + */ +export { AsyncJob, AsyncJobErrors } from '@aws-blocks/bb-async-job'; /** * **Username/password authentication with JWT sessions.** * @@ -33,8 +107,45 @@ export { getSdkIdentifiers } from './sdk-identifiers.js'; * * @see {@link BlocksAuth} for the provider-agnostic auth interface all auth BBs implement. */ -export { AuthBasic, AuthBasicErrors, type AuthBasicUser, type AuthBasicOptions, type PasswordPolicy } from '@aws-blocks/bb-auth-basic'; - +export { + AuthBasic, + AuthBasicErrors, + type AuthBasicOptions, + type AuthBasicUser, + type PasswordPolicy, +} from '@aws-blocks/bb-auth-basic'; +export type { + AdminAction, + AdminActionGate, + AdminCreateInit, + AdminDisabled, + AdminGetterOf, + AdminGrants, + AdminOptions, + AdminSurface, + AdminUser, + AdminUserFilter, + AuthCognitoOptions, + AuthFlowType, + CodeDeliveryDetails, + CodeDeliveryFn, + CognitoUser, + ConfirmSignInOptions, + DeviceRecord, + ExternalUserPoolRef, + GroupAdmin, + LifecycleAdmin, + MFAPreference, + ResetPasswordResult, + SetPasswordOptions, + SignInNextStep, + SignInOptions, + SignInResult, + SignUpOptions, + SignUpResult, + UpdateAttributeOutcome, + UserAttribute, +} from '@aws-blocks/bb-auth-cognito'; /** * **Cognito authentication — username/password + MFA + groups.** * @@ -52,38 +163,11 @@ export { AuthBasic, AuthBasicErrors, type AuthBasicUser, type AuthBasicOptions, */ export { AuthCognito, AuthCognitoErrors } from '@aws-blocks/bb-auth-cognito'; export type { - AuthCognitoOptions, - AuthFlowType, - CognitoUser, - SignInOptions, - SignInResult, - SignInNextStep, - ConfirmSignInOptions, - SignUpOptions, - SignUpResult, - ResetPasswordResult, - CodeDeliveryDetails, - UpdateAttributeOutcome, - MFAPreference, - DeviceRecord, - UserAttribute, - ExternalUserPoolRef, - CodeDeliveryFn, - AdminOptions, - AdminAction, - AdminUser, - AdminCreateInit, - AdminUserFilter, - SetPasswordOptions, - GroupAdmin, - LifecycleAdmin, - AdminSurface, - AdminGetterOf, - AdminDisabled, - AdminGrants, - AdminActionGate, -} from '@aws-blocks/bb-auth-cognito'; - + AuthOIDCErrorName, + MappedClaims, + OIDCUser, + RelayOrigin, +} from '@aws-blocks/bb-auth-oidc'; /** * **OIDC sign-in gate for Google, GitHub, Okta, Cognito User Pools, and any * OIDC-compliant IdP.** @@ -103,75 +187,47 @@ export type { export { AuthOIDC, AuthOIDCErrors, - google, - github, - customOidc, - customOauth2, - stubIdp, cognitoFederated, + customOauth2, + customOidc, + github, + google, relayOrigin, + stubIdp, } from '@aws-blocks/bb-auth-oidc'; -export type { - AuthOIDCErrorName, - OIDCUser, - MappedClaims, - RelayOrigin, -} from '@aws-blocks/bb-auth-oidc'; - -/** - * **Shared auth interfaces and UI components for all Blocks auth Building Blocks.** - * - * Provides the `BlocksAuth` interface (implemented by every auth BB), auth state - * types, and framework-agnostic UI components (`Authenticator`, `AuthenticatedContent`, - * `onAuthChange`). Import these when writing provider-agnostic auth code. - * - * Package: `@aws-blocks/auth-common` - * Full docs: `README.md` in the package directory above. - */ -export type { BlocksAuth, AuthUser, AuthState, AuthAction, AuthField, AuthActionInput } from '@aws-blocks/auth-common'; - +export type { CronJobEvent, CronJobOptions } from '@aws-blocks/bb-cron-job'; /** - * **Simple key-value storage backed by DynamoDB.** - * - * Use for fast single-key get/put/delete: user preferences, feature flags, - * session data, caches. Supports conditional writes and schema validation. - * If you need queries, indexes, or structured data, use `DistributedTable`. - * - * Package: `@aws-blocks/bb-kv-store` - * Full docs: `README.md` in the package directory above. - */ -export { KVStore, KVStoreErrors } from '@aws-blocks/bb-kv-store'; -export type { ConditionalWriteOptions, ConditionalDeleteOptions, PutOptions as KVPutOptions, KVStoreOptions, ExternalTableRef } from '@aws-blocks/bb-kv-store'; - -/** - * **Structured data storage with secondary indexes backed by DynamoDB.** + * **Scheduled task execution backed by EventBridge Scheduler and Lambda.** * - * Default choice for most application data. Use for entities with composite - * keys, range queries, secondary access patterns, and batch operations. - * Supports Zod/Valibot schemas for type-safe validation. Zero cost at rest, - * scales automatically. Use `KVStore` for simpler key-only access, or - * `Database` when you need SQL JOINs/transactions. + * Use for recurring jobs: cleanup, report generation, data syncs, cache + * warming, periodic health checks. Supports cron and rate expressions, + * IANA timezones, and typed static input. No runtime methods — the + * constructor defines the schedule and handler. * - * Package: `@aws-blocks/bb-distributed-table` + * Package: `@aws-blocks/bb-cron-job` * Full docs: `README.md` in the package directory above. */ -export { DistributedTable, DistributedTableErrors } from '@aws-blocks/bb-distributed-table'; -export type { DistributedTableOptions, ReadValidationMode, TableKeyConfig, TableKey, PutOptions as DTPutOptions, DeleteOptions as DTDeleteOptions, QueryOptions as DTQueryOptions, ScanOptions as DTScanOptions } from '@aws-blocks/bb-distributed-table'; - +export { CronJob, CronJobErrors } from '@aws-blocks/bb-cron-job'; +export type { + DashboardOptions, + MetricConfig, + MetricsBBRef, + MetricsSource, +} from '@aws-blocks/bb-dashboard'; /** - * **Real-time pub/sub messaging backed by AppSync Events.** + * **Auto-generated CloudWatch Dashboard for application observability.** * - * Use for pushing data to connected browser clients: chat, notifications, - * live dashboards, collaborative editing. Typed namespaces with schema - * validation on publish. Local dev uses WebSocket bridge; production uses - * AppSync Events API. + * Use when you want a single URL to view application health after deployment. + * The dashboard is organized by compute: each renders a health section plus + * logs (always captured) and traces (when the app contains a Tracer). Pass + * `metrics` for app-wide custom-metric widgets, and toggle sections with the + * `logs` / `traces` options. * - * Package: `@aws-blocks/bb-realtime` + * Package: `@aws-blocks/bb-dashboard` * Full docs: `README.md` in the package directory above. */ -export { Realtime } from '@aws-blocks/bb-realtime'; -export type { RealtimeChannel, RealtimeSubscription, SubscribeOptions, DisconnectReason } from '@aws-blocks/bb-realtime'; - +export { Dashboard, DashboardErrors } from '@aws-blocks/bb-dashboard'; +export type { DatabaseOptions, ExternalDatabaseRef, SqlQuery, Transaction } from '@aws-blocks/bb-data'; /** * **SQL database with Kysely query builder backed by Aurora Serverless v2.** * @@ -184,8 +240,7 @@ export type { RealtimeChannel, RealtimeSubscription, SubscribeOptions, Disconnec * Full docs: `README.md` in the package directory above. */ export { Database, DatabaseErrors, fromExisting, sql } from '@aws-blocks/bb-data'; -export type { DatabaseOptions, ExternalDatabaseRef, Transaction, SqlQuery } from '@aws-blocks/bb-data'; - +export type { DistributedDatabaseOptions, TransactionOptions } from '@aws-blocks/bb-distributed-data'; /** * **Serverless SQL database backed by Aurora DSQL.** * @@ -199,51 +254,30 @@ export type { DatabaseOptions, ExternalDatabaseRef, Transaction, SqlQuery } from * Full docs: `README.md` in the package directory above. */ export { DistributedDatabase, DistributedDatabaseErrors } from '@aws-blocks/bb-distributed-data'; -export type { DistributedDatabaseOptions, TransactionOptions } from '@aws-blocks/bb-distributed-data'; - -/** - * **Background job processing backed by SQS and Lambda.** - * - * Use for fire-and-forget async work: sending emails, processing uploads, - * generating reports, or any task that shouldn't block an API response. - * Supports single and batch submission (up to 10), optional delay, schema - * validation, and automatic retries with dead-letter queue. - * - * Package: `@aws-blocks/bb-async-job` - * Full docs: `README.md` in the package directory above. - */ -export { AsyncJob, AsyncJobErrors } from '@aws-blocks/bb-async-job'; -export type { AsyncJobOptions, AsyncJobContext, SubmitOptions, BatchSubmitResult, AsyncJobState, AsyncJobStatus, AsyncJobTransition, WaitUntilCompleteOptions } from '@aws-blocks/bb-async-job'; - -/** - * **AI agent with streaming, tool calling, and conversation persistence.** - * - * Use for building conversational AI experiences: chatbots, copilots, data - * extraction, or any LLM-powered feature. Supports Bedrock, OpenAI-compatible, - * and CannedProvider (local dev). Tools defined with Zod schemas. Conversation - * history persisted to DynamoDB. Streaming via AsyncJob + Realtime. - * Set `inferenceOnly: true` for simple prompt→response without persistence. - * - * Package: `@aws-blocks/bb-agent` - * Full docs: `README.md` in the package directory above. - */ -export { Agent, AgentErrors, BedrockModels, OllamaModels } from '@aws-blocks/bb-agent'; -export type { AgentConfig, AgentResult, AgentStreamChunk, ToolDefinition, AgentTool, ToolFactory, ToolsConfig, ToolHandlerArgs, DefaultToolContext, ToolCallRecord, ModelConfig, StreamOptions, TokenUsage } from '@aws-blocks/bb-agent'; - +export type { + DeleteOptions as DTDeleteOptions, + DistributedTableOptions, + PutOptions as DTPutOptions, + QueryOptions as DTQueryOptions, + ReadValidationMode, + ScanOptions as DTScanOptions, + TableKey, + TableKeyConfig, +} from '@aws-blocks/bb-distributed-table'; /** - * **Scheduled task execution backed by EventBridge Scheduler and Lambda.** + * **Structured data storage with secondary indexes backed by DynamoDB.** * - * Use for recurring jobs: cleanup, report generation, data syncs, cache - * warming, periodic health checks. Supports cron and rate expressions, - * IANA timezones, and typed static input. No runtime methods — the - * constructor defines the schedule and handler. + * Default choice for most application data. Use for entities with composite + * keys, range queries, secondary access patterns, and batch operations. + * Supports Zod/Valibot schemas for type-safe validation. Zero cost at rest, + * scales automatically. Use `KVStore` for simpler key-only access, or + * `Database` when you need SQL JOINs/transactions. * - * Package: `@aws-blocks/bb-cron-job` + * Package: `@aws-blocks/bb-distributed-table` * Full docs: `README.md` in the package directory above. */ -export { CronJob, CronJobErrors } from '@aws-blocks/bb-cron-job'; -export type { CronJobOptions, CronJobEvent } from '@aws-blocks/bb-cron-job'; - +export { DistributedTable, DistributedTableErrors } from '@aws-blocks/bb-distributed-table'; +export type { EmailMessage, EmailOptions, SendBatchResult, SendResult } from '@aws-blocks/bb-email-client'; /** * **Transactional email with AWS SES integration.** * @@ -255,8 +289,18 @@ export type { CronJobOptions, CronJobEvent } from '@aws-blocks/bb-cron-job'; * Full docs: `README.md` in the package directory above. */ export { EmailClient, EmailErrors } from '@aws-blocks/bb-email-client'; -export type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from '@aws-blocks/bb-email-client'; - +export type { + CorsRule, + ExternalBucketRef as FBExternalBucketRef, + FileBucketOptions, + FileContent, + FileInfo, + GetUrlOptions, + LifecycleRule, + PutOptions as FBPutOptions, + PutUrlOptions, + ScanOptions as FBScanOptions, +} from '@aws-blocks/bb-file-bucket'; /** * **File storage backed by Amazon S3.** * @@ -270,22 +314,16 @@ export type { EmailOptions, EmailMessage, SendResult, SendBatchResult } from '@a * Full docs: `README.md` in the package directory above. */ export { FileBucket, FileBucketErrors } from '@aws-blocks/bb-file-bucket'; -export type { FileBucketOptions, PutOptions as FBPutOptions, GetUrlOptions, PutUrlOptions, ScanOptions as FBScanOptions, FileContent, FileInfo, CorsRule, LifecycleRule, ExternalBucketRef as FBExternalBucketRef } from '@aws-blocks/bb-file-bucket'; - -/** - * **Single application configuration value backed by SSM Parameter Store.** - * - * Use for feature flags, API URLs, thresholds, or structured config objects. - * Set `secret: true` for sensitive values (API keys, tokens) — stored as SSM - * SecureString encrypted with the `aws/ssm` KMS key. Each instance maps to - * exactly one SSM parameter. Supports schema validation for typed objects. - * - * Package: `@aws-blocks/bb-app-setting` - * Full docs: `README.md` in the package directory above. - */ -export { AppSetting, AppSettingErrors } from '@aws-blocks/bb-app-setting'; -export type { AppSettingOptions } from '@aws-blocks/bb-app-setting'; - +export type { + ChunkingConfig, + ChunkingStrategy, + KnowledgeBaseOptions, + MetadataFilter, + RetrieveOptions, + RetrieveResult, + SourceConfig, + WaitUntilSyncedOptions, +} from '@aws-blocks/bb-knowledge-base'; /** * **Semantic document retrieval backed by Bedrock Knowledge Bases.** * @@ -299,22 +337,49 @@ export type { AppSettingOptions } from '@aws-blocks/bb-app-setting'; * Full docs: `README.md` in the package directory above. */ export { KnowledgeBase, KnowledgeBaseErrors } from '@aws-blocks/bb-knowledge-base'; -export type { KnowledgeBaseOptions, RetrieveOptions, RetrieveResult, MetadataFilter, SourceConfig, ChunkingConfig, ChunkingStrategy, WaitUntilSyncedOptions } from '@aws-blocks/bb-knowledge-base'; - +export type { + ConditionalDeleteOptions, + ConditionalWriteOptions, + ExternalTableRef, + KVStoreOptions, + PutOptions as KVPutOptions, +} from '@aws-blocks/bb-kv-store'; /** - * **Distributed tracing backed by AWS X-Ray.** + * **Simple key-value storage backed by DynamoDB.** * - * Use when you need to trace request flow across services, debug latency - * issues, or visualize service dependencies. Wrap discrete units of work - * (DB calls, HTTP requests, business logic) with `startSegment`. Use - * annotations for searchable values and metadata for debugging data. + * Use for fast single-key get/put/delete: user preferences, feature flags, + * session data, caches. Supports conditional writes and schema validation. + * If you need queries, indexes, or structured data, use `DistributedTable`. * - * Package: `@aws-blocks/bb-tracer` + * Package: `@aws-blocks/bb-kv-store` * Full docs: `README.md` in the package directory above. */ -export { Tracer } from '@aws-blocks/bb-tracer'; -export type { TracerOptions, Segment, AnnotationValue } from '@aws-blocks/bb-tracer'; - +export { KVStore, KVStoreErrors } from '@aws-blocks/bb-kv-store'; +export type { ChildLogger, LogEntry, LoggingOptions, LogLevel } from '@aws-blocks/bb-logger'; +/** + * **Structured logging with consistent JSON format, log levels, and contextual metadata.** + * + * Use when you need structured, queryable application logs with consistent + * format across your backend. Good for request logging, audit trails, + * debugging context, and operational visibility. All methods are synchronous + * (no await needed). Supports child loggers for request-scoped context. + * + * For numeric measurements over time, use `Metrics`. For distributed + * request tracing, use `Tracing`. + * + * Package: `@aws-blocks/bb-logger` + * Full docs: `README.md` in the package directory above. + */ +export { Logger, LoggingErrors } from '@aws-blocks/bb-logger'; +export type { + EmitOptions, + ExternalMetricsRef, + MetricDatum, + MetricResolution, + MetricsEmitter, + MetricsOptions, + MetricUnit, +} from '@aws-blocks/bb-metrics'; /** * **Custom application metrics backed by Amazon CloudWatch (via EMF).** * @@ -328,36 +393,34 @@ export type { TracerOptions, Segment, AnnotationValue } from '@aws-blocks/bb-tra * Full docs: `README.md` in the package directory above. */ export { Metrics, MetricsErrors } from '@aws-blocks/bb-metrics'; -export type { MetricsOptions, EmitOptions, MetricDatum, MetricUnit, MetricResolution, ExternalMetricsRef, MetricsEmitter } from '@aws-blocks/bb-metrics'; - +export type { + DisconnectReason, + RealtimeChannel, + RealtimeSubscription, + SubscribeOptions, +} from '@aws-blocks/bb-realtime'; /** - * **Structured logging with consistent JSON format, log levels, and contextual metadata.** - * - * Use when you need structured, queryable application logs with consistent - * format across your backend. Good for request logging, audit trails, - * debugging context, and operational visibility. All methods are synchronous - * (no await needed). Supports child loggers for request-scoped context. + * **Real-time pub/sub messaging backed by AppSync Events.** * - * For numeric measurements over time, use `Metrics`. For distributed - * request tracing, use `Tracing`. + * Use for pushing data to connected browser clients: chat, notifications, + * live dashboards, collaborative editing. Typed namespaces with schema + * validation on publish. Local dev uses WebSocket bridge; production uses + * AppSync Events API. * - * Package: `@aws-blocks/bb-logger` + * Package: `@aws-blocks/bb-realtime` * Full docs: `README.md` in the package directory above. */ -export { Logger, LoggingErrors } from '@aws-blocks/bb-logger'; -export type { LogLevel, LoggingOptions, LogEntry, ChildLogger, RetentionDays } from '@aws-blocks/bb-logger'; - +export { Realtime } from '@aws-blocks/bb-realtime'; +export type { AnnotationValue, Segment, TracerOptions } from '@aws-blocks/bb-tracer'; /** - * **Auto-generated CloudWatch Dashboard for application observability.** + * **Distributed tracing backed by AWS X-Ray.** * - * Use when you want a single URL to view application health after deployment. - * Creates pre-configured widgets for Lambda health, custom metrics, log - * queries, and X-Ray traces without manually creating CloudWatch dashboards. - * Pass real observability BB instances (Logger, Metrics, Tracer) for - * automatic type-safe integration. + * Use when you need to trace request flow across services, debug latency + * issues, or visualize service dependencies. Wrap discrete units of work + * (DB calls, HTTP requests, business logic) with `startSegment`. Use + * annotations for searchable values and metadata for debugging data. * - * Package: `@aws-blocks/bb-dashboard` + * Package: `@aws-blocks/bb-tracer` * Full docs: `README.md` in the package directory above. */ -export { Dashboard, DashboardErrors } from '@aws-blocks/bb-dashboard'; -export type { DashboardOptions, MetricConfig, MetricsBBRef, LoggerBBRef, TracerBBRef } from '@aws-blocks/bb-dashboard'; +export { Tracer } from '@aws-blocks/bb-tracer'; diff --git a/packages/core/src/cdk/blocks-backend.test.ts b/packages/core/src/cdk/blocks-backend.test.ts index 24beab4b2..59199172e 100644 --- a/packages/core/src/cdk/blocks-backend.test.ts +++ b/packages/core/src/cdk/blocks-backend.test.ts @@ -8,6 +8,7 @@ import { fileURLToPath } from 'node:url'; import * as cdk from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import { PolicyStatement } from 'aws-cdk-lib/aws-iam'; import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import type { Construct } from 'constructs'; @@ -57,6 +58,17 @@ class StubLambdaCompute extends Compute { setEnv(key: string, value: string): void { this.fn.addEnvironment(key, value); } + + protected applyTracing(): void {} + protected healthWidgets(_region: string): IWidget[][] { + return []; + } + protected loggingWidgets(_region: string): IWidget[][] { + return []; + } + protected tracingWidgets(_region: string): IWidget[][] { + return []; + } } const stubComputeFactory: DefaultComputeFactory = (root) => new StubLambdaCompute(root as never, 'DefaultCompute'); @@ -77,7 +89,12 @@ const importMetaHandlerPath = join(__dirname, '__fixtures__', 'import-meta-handl // Wraps BlocksBackend.create, injecting the stub default-compute factory the way // @aws-blocks/blocks injects LambdaCompute — so tests don't repeat it 15 times. const makeBackend = (scope: Construct, id: string, backendCDKPath: string) => - BlocksBackend.create(scope, id, { backendHandlerPath: handlerPath, backendCDKPath, defaults: BlocksPresets.production, defaultComputeFactory: stubComputeFactory }); + BlocksBackend.create(scope, id, { + backendHandlerPath: handlerPath, + backendCDKPath, + defaults: BlocksPresets.production, + defaultComputeFactory: stubComputeFactory, + }); describe('ESM cache-busting (multi-stage)', () => { test('BlocksBackend.create() with same backendCDKPath but different IDs produces constructs in each', async () => { @@ -230,25 +247,25 @@ describe('shared execution role', () => { }); describe('CJS bundle: import.meta.url in the handler is shimmed (no Lambda-load crash)', () => { - test('a handler that uses import.meta.url bundles successfully instead of throwing at load', async () => { - // The handler is bundled to CJS, where `import.meta` is empty. Left unshimmed, - // `fileURLToPath(import.meta.url)` compiles to `fileURLToPath(undefined)` and - // throws at Lambda load (esbuild only warns, so the broken bundle would deploy). - // blocksNodejsBundling shims import.meta.* to CommonJS equivalents, so bundling - // (which runs synchronously during construction) succeeds. The runtime behaviour - // of the emitted shim is verified directly in bundling.test.ts. - const app = new cdk.App(); - const stack = new cdk.Stack(app, 'ImportMetaStack'); - - await assert.doesNotReject(() => - BlocksBackend.create(stack, 'blocks', { - backendHandlerPath: importMetaHandlerPath, - backendCDKPath: sideEffectBackendPath, - defaults: BlocksPresets.production, - defaultComputeFactory: stubComputeFactory, - }), - ); - }); + test('a handler that uses import.meta.url bundles successfully instead of throwing at load', async () => { + // The handler is bundled to CJS, where `import.meta` is empty. Left unshimmed, + // `fileURLToPath(import.meta.url)` compiles to `fileURLToPath(undefined)` and + // throws at Lambda load (esbuild only warns, so the broken bundle would deploy). + // blocksNodejsBundling shims import.meta.* to CommonJS equivalents, so bundling + // (which runs synchronously during construction) succeeds. The runtime behaviour + // of the emitted shim is verified directly in bundling.test.ts. + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'ImportMetaStack'); + + await assert.doesNotReject(() => + BlocksBackend.create(stack, 'blocks', { + backendHandlerPath: importMetaHandlerPath, + backendCDKPath: sideEffectBackendPath, + defaults: BlocksPresets.production, + defaultComputeFactory: stubComputeFactory, + }), + ); + }); }); describe('factory function support', () => { @@ -348,44 +365,44 @@ describe('fullId is token-free (construct IDs / env-var keys)', () => { }); describe('infrastructure defaults (backend-anchored)', () => { - test('each backend exposes its own defaults', async () => { - const app = new cdk.App(); - const stack = new cdk.Stack(app, 'TwoBackendsStack'); - - const a = await BlocksBackend.create(stack, 'A', { - backendHandlerPath: handlerPath, - backendCDKPath: sideEffectBackendPath, - defaults: BlocksPresets.production, - defaultComputeFactory: stubComputeFactory, - }); - const b = await BlocksBackend.create(stack, 'B', { - backendHandlerPath: handlerPath, - backendCDKPath: sideEffectBackendPath, - defaults: BlocksPresets.sandbox, - defaultComputeFactory: stubComputeFactory, - }); - - // Two backends in one stack must NOT clobber each other — defaults are - // anchored on the backend, not the shared stack. - assert.strictEqual(a.defaults, BlocksPresets.production); - assert.strictEqual(b.defaults, BlocksPresets.sandbox); - }); - - test('a nested block resolves its owning backend defaults via the tree-walk', async () => { - const app = new cdk.App(); - const stack = new cdk.Stack(app, 'ResolveDefaultsStack'); - - const backend = await BlocksBackend.create(stack, 'Blocks', { - backendHandlerPath: handlerPath, - backendCDKPath: sideEffectBackendPath, - defaults: BlocksPresets.sandbox, - defaultComputeFactory: stubComputeFactory, - }); - - // A Scope under the backend resolves scope.defaults by walking up to it. - const outer = new Scope('outer'); - const inner = new Scope('inner', { parent: outer }); - assert.strictEqual(inner.defaults, backend.defaults); - assert.strictEqual(inner.defaults, BlocksPresets.sandbox); - }); + test('each backend exposes its own defaults', async () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'TwoBackendsStack'); + + const a = await BlocksBackend.create(stack, 'A', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + defaults: BlocksPresets.production, + defaultComputeFactory: stubComputeFactory, + }); + const b = await BlocksBackend.create(stack, 'B', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + defaults: BlocksPresets.sandbox, + defaultComputeFactory: stubComputeFactory, + }); + + // Two backends in one stack must NOT clobber each other — defaults are + // anchored on the backend, not the shared stack. + assert.strictEqual(a.defaults, BlocksPresets.production); + assert.strictEqual(b.defaults, BlocksPresets.sandbox); + }); + + test('a nested block resolves its owning backend defaults via the tree-walk', async () => { + const app = new cdk.App(); + const stack = new cdk.Stack(app, 'ResolveDefaultsStack'); + + const backend = await BlocksBackend.create(stack, 'Blocks', { + backendHandlerPath: handlerPath, + backendCDKPath: sideEffectBackendPath, + defaults: BlocksPresets.sandbox, + defaultComputeFactory: stubComputeFactory, + }); + + // A Scope under the backend resolves scope.defaults by walking up to it. + const outer = new Scope('outer'); + const inner = new Scope('inner', { parent: outer }); + assert.strictEqual(inner.defaults, backend.defaults); + assert.strictEqual(inner.defaults, BlocksPresets.sandbox); + }); }); diff --git a/packages/core/src/cdk/blocks-backend.ts b/packages/core/src/cdk/blocks-backend.ts index 79b418837..68c4fa235 100644 --- a/packages/core/src/cdk/blocks-backend.ts +++ b/packages/core/src/cdk/blocks-backend.ts @@ -1,19 +1,21 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { pathToFileURL } from 'node:url'; import * as cdk from 'aws-cdk-lib'; import type * as apigateway from 'aws-cdk-lib/aws-apigateway'; import * as iam from 'aws-cdk-lib/aws-iam'; import { CfnGroup } from 'aws-cdk-lib/aws-resourcegroups'; import { Construct } from 'constructs'; -import { pathToFileURL } from 'node:url'; -import { addBlocksStackMetadata } from './stack-metadata.js'; -import { finalizeConfigRegistry, registerConfig } from './config-registry.js'; -import type { BlocksDefaults } from './blocks-defaults.js'; import { registerBuiltinRoutes } from '../builtin-routes.js'; +import type { BlocksDefaults } from './blocks-defaults.js'; import type { Compute } from './compute/compute.js'; import { getComputes } from './compute/compute-registry.js'; import type { DefaultComputeFactory, LambdaShapedCompute } from './compute/default-compute-factory.js'; +import { finalizeConfigRegistry, registerConfig } from './config-registry.js'; +import { finalizeDashboards } from './dashboard-registry.js'; +import { finalizeTracing } from './tracer-registry.js'; +import { addBlocksStackMetadata } from './stack-metadata.js'; /** * Validate that the Node.js process was started with `--conditions=cdk`. @@ -24,23 +26,23 @@ import type { DefaultComputeFactory, LambdaShapedCompute } from './compute/defau * no real infrastructure (no tables, no IAM, no Lambda configs). */ export function assertCdkConditionActive(): void { - const nodeOptions = process.env.NODE_OPTIONS ?? ''; - const execArgv = process.execArgv ?? []; + const nodeOptions = process.env.NODE_OPTIONS ?? ''; + const execArgv = process.execArgv ?? []; - const hasCdkCondition = - execArgv.some(arg => arg === '--conditions=cdk') || - execArgv.some((arg, i) => (arg === '--conditions' || arg === '-C') && execArgv[i + 1] === 'cdk') || - nodeOptions.includes('--conditions=cdk') || - /(?:--conditions|-C)\s+cdk/.test(nodeOptions); + const hasCdkCondition = + execArgv.some((arg) => arg === '--conditions=cdk') || + execArgv.some((arg, i) => (arg === '--conditions' || arg === '-C') && execArgv[i + 1] === 'cdk') || + nodeOptions.includes('--conditions=cdk') || + /(?:--conditions|-C)\s+cdk/.test(nodeOptions); - if (!hasCdkCondition) { - throw new Error( - 'Missing --conditions=cdk: Building Blocks will silently load mock implementations instead of CDK constructs.\n\n' + - 'Fix: Set NODE_OPTIONS="--conditions=cdk" before running CDK synth:\n' + - ' NODE_OPTIONS="--conditions=cdk" npx cdk synth\n\n' + - 'Or use the Blocks CLI commands (npm run deploy / npm run sandbox) which set this automatically.', - ); - } + if (!hasCdkCondition) { + throw new Error( + 'Missing --conditions=cdk: Building Blocks will silently load mock implementations instead of CDK constructs.\n\n' + + 'Fix: Set NODE_OPTIONS="--conditions=cdk" before running CDK synth:\n' + + ' NODE_OPTIONS="--conditions=cdk" npx cdk synth\n\n' + + 'Or use the Blocks CLI commands (npm run deploy / npm run sandbox) which set this automatically.', + ); + } } /** @@ -51,16 +53,16 @@ export function assertCdkConditionActive(): void { export const SHARED_HANDLER_TIMEOUT_SECONDS = 60 * 15; export interface BlocksBackendProps { - backendHandlerPath: string; - backendCDKPath: string; - /** - * Stack-wide infrastructure defaults applied to every Building Block (removal - * policy, deletion protection, …). See {@link BlocksDefaults}. Start from - * `BlocksPresets.sandbox` or `BlocksPresets.production` and override - * individual fields as needed. A per-block option always wins over the - * corresponding stack default. - */ - defaults: BlocksDefaults; + backendHandlerPath: string; + backendCDKPath: string; + /** + * Stack-wide infrastructure defaults applied to every Building Block (removal + * policy, deletion protection, …). See {@link BlocksDefaults}. Start from + * `BlocksPresets.sandbox` or `BlocksPresets.production` and override + * individual fields as needed. A per-block option always wins over the + * corresponding stack default. + */ + defaults: BlocksDefaults; } /** @@ -71,8 +73,8 @@ export interface BlocksBackendProps { * @internal */ export interface CoreBlocksBackendProps extends BlocksBackendProps { - /** Builds the backend's default compute. Injected by `@aws-blocks/blocks`. */ - defaultComputeFactory: DefaultComputeFactory; + /** Builds the backend's default compute. Injected by `@aws-blocks/blocks`. */ + defaultComputeFactory: DefaultComputeFactory; } /** @@ -81,94 +83,100 @@ export interface CoreBlocksBackendProps extends BlocksBackendProps { * routes. */ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id?: string) { - // Fail fast with an actionable message at the create() call site if `defaults` - // is missing (e.g. a plain-JS caller, `as any`, or a dynamically-built props - // object) — otherwise the first Building Block to read `scope.defaults` throws - // a cryptic `Cannot read properties of undefined (reading 'removalPolicy')`. - if (!props.defaults) { - throw new Error( - 'BlocksStack/BlocksBackend requires a `defaults` field. Pass a posture from ' + - '`@aws-blocks/core/cdk` — typically `defaults: sandboxMode ? BlocksPresets.sandbox : BlocksPresets.production`.', - ); - } + // Fail fast with an actionable message at the create() call site if `defaults` + // is missing (e.g. a plain-JS caller, `as any`, or a dynamically-built props + // object) — otherwise the first Building Block to read `scope.defaults` throws + // a cryptic `Cannot read properties of undefined (reading 'removalPolicy')`. + if (!props.defaults) { + throw new Error( + 'BlocksStack/BlocksBackend requires a `defaults` field. Pass a posture from ' + + '`@aws-blocks/core/cdk` — typically `defaults: sandboxMode ? BlocksPresets.sandbox : BlocksPresets.production`.', + ); + } - // ── Shared execution role ─────────────────────────────────────────────── - // A single IAM role that every Building Block grants to. Provisioned here so - // it exists before the backend module is imported (Building Blocks reach it - // via `scope.executionRole`). Block grants sit on the role's default (inline) - // policy. AWSLambdaBasicExecutionRole is attached so compute functions retain - // CloudWatch Logs permissions. - // - // INVARIANT: this must be a mutable, framework-owned `iam.Role` — never an - // imported role (`Role.fromRoleArn`/`fromRoleName`), which is immutable by - // default. On an immutable role, every Building Block's `grant*()` / - // `addToPrincipalPolicy()` silently becomes a no-op (returns false, no error), - // so permissions would quietly vanish. If a bring-your-own-role option is ever - // added, it must resolve to a mutable role (`{ mutable: true }`). - const executionRole = new iam.Role(scope, 'BlocksRole', { - // CompositePrincipal (rather than a bare ServicePrincipal) so a Building Block - // whose compute runs AS this shared role can add its own trust principal here - // (e.g. the Agent BB adds bedrock-agentcore in its CDK construct) — core stays - // agnostic and only Lambda is trusted by default. - assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')), - managedPolicies: [ - iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole'), - ], - }); + // ── Shared execution role ─────────────────────────────────────────────── + // A single IAM role that every Building Block grants to. Provisioned here so + // it exists before the backend module is imported (Building Blocks reach it + // via `scope.executionRole`). Block grants sit on the role's default (inline) + // policy. AWSLambdaBasicExecutionRole is attached so compute functions retain + // CloudWatch Logs permissions. + // + // INVARIANT: this must be a mutable, framework-owned `iam.Role` — never an + // imported role (`Role.fromRoleArn`/`fromRoleName`), which is immutable by + // default. On an immutable role, every Building Block's `grant*()` / + // `addToPrincipalPolicy()` silently becomes a no-op (returns false, no error), + // so permissions would quietly vanish. If a bring-your-own-role option is ever + // added, it must resolve to a mutable role (`{ mutable: true }`). + const executionRole = new iam.Role(scope, 'BlocksRole', { + // CompositePrincipal (rather than a bare ServicePrincipal) so a Building Block + // whose compute runs AS this shared role can add its own trust principal here + // (e.g. the Agent BB adds bedrock-agentcore in its CDK construct) — core stays + // agnostic and only Lambda is trusted by default. + assumedBy: new iam.CompositePrincipal(new iam.ServicePrincipal('lambda.amazonaws.com')), + managedPolicies: [iam.ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaBasicExecutionRole')], + }); - // ── Resource Groups ─────────────────────────────────────────────────── - let rootStack = cdk.Stack.of(scope); - while (rootStack.nestedStackParent) rootStack = rootStack.nestedStackParent; - const groupPrefix = (id && id !== rootStack.stackName) ? `${rootStack.stackName}-${id}` : rootStack.stackName; + // ── Resource Groups ─────────────────────────────────────────────────── + let rootStack = cdk.Stack.of(scope); + while (rootStack.nestedStackParent) rootStack = rootStack.nestedStackParent; + const groupPrefix = id && id !== rootStack.stackName ? `${rootStack.stackName}-${id}` : rootStack.stackName; - new CfnGroup(scope, 'StackResources', { - name: `${groupPrefix}-resources`, - resourceQuery: { - type: 'CLOUDFORMATION_STACK_1_0', - query: { - resourceTypeFilters: [ - 'AWS::CloudWatch::Dashboard', - 'AWS::Cognito::UserPool', - 'AWS::DynamoDB::Table', - 'AWS::Logs::LogGroup', - 'AWS::RDS::DBCluster', - 'AWS::RDS::DBInstance', - 'AWS::S3::Bucket', - 'AWS::SQS::Queue', - ], - stackIdentifier: cdk.Stack.of(scope).stackId, - }, - }, - }); + new CfnGroup(scope, 'StackResources', { + name: `${groupPrefix}-resources`, + resourceQuery: { + type: 'CLOUDFORMATION_STACK_1_0', + query: { + resourceTypeFilters: [ + 'AWS::CloudWatch::Dashboard', + 'AWS::Cognito::UserPool', + 'AWS::DynamoDB::Table', + 'AWS::Logs::LogGroup', + 'AWS::RDS::DBCluster', + 'AWS::RDS::DBInstance', + 'AWS::S3::Bucket', + 'AWS::SQS::Queue', + ], + stackIdentifier: cdk.Stack.of(scope).stackId, + }, + }, + }); - new CfnGroup(scope, 'StackSettings', { - name: `${groupPrefix}-settings`, - resourceQuery: { - type: 'TAG_FILTERS_1_0', - query: { - resourceTypeFilters: ['AWS::SSM::Parameter'], - tagFilters: [{ key: 'aws-blocks-stack', values: [rootStack.stackName] }], - }, - }, - }); + new CfnGroup(scope, 'StackSettings', { + name: `${groupPrefix}-settings`, + resourceQuery: { + type: 'TAG_FILTERS_1_0', + query: { + resourceTypeFilters: ['AWS::SSM::Parameter'], + tagFilters: [{ key: 'aws-blocks-stack', values: [rootStack.stackName] }], + }, + }, + }); - // ── Console redirect routes ─────────────────────────────────────────── - const region = cdk.Fn.ref('AWS::Region'); - const resourcesUrl = cdk.Fn.join('', [ - 'https://', region, '.console.aws.amazon.com/resource-groups/group/', - `${groupPrefix}-resources`, '?region=', region, - ]); - const settingsUrl = cdk.Fn.join('', [ - 'https://', region, '.console.aws.amazon.com/resource-groups/group/', - `${groupPrefix}-settings`, '?region=', region, - ]); + // ── Console redirect routes ─────────────────────────────────────────── + const region = cdk.Fn.ref('AWS::Region'); + const resourcesUrl = cdk.Fn.join('', [ + 'https://', + region, + '.console.aws.amazon.com/resource-groups/group/', + `${groupPrefix}-resources`, + '?region=', + region, + ]); + const settingsUrl = cdk.Fn.join('', [ + 'https://', + region, + '.console.aws.amazon.com/resource-groups/group/', + `${groupPrefix}-settings`, + '?region=', + region, + ]); - registerConfig(scope, 'BB_RESOURCES_GROUP_URL', resourcesUrl); - registerConfig(scope, 'BB_SETTINGS_GROUP_URL', settingsUrl); + registerConfig(scope, 'BB_RESOURCES_GROUP_URL', resourcesUrl); + registerConfig(scope, 'BB_SETTINGS_GROUP_URL', settingsUrl); - registerBuiltinRoutes(); + registerBuiltinRoutes(); - return { executionRole }; + return { executionRole }; } /** @@ -189,122 +197,137 @@ export function setupBlocksInfra(scope: Construct, props: BlocksBackendProps, id * ``` */ export class BlocksBackend extends Construct { - public readonly backendHandlerPath: string; - /** - * Path to the app's backend module (`props.backendCDKPath`). Exposed so Building Blocks that - * co-bundle the backend at synth (e.g. the Agent BB's AgentCore Runtime) can discover it via - * `globalThis.CURRENT_BLOCKS_STACK.backendModulePath`. - */ - public readonly backendModulePath: string; - /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ - public readonly executionRole: iam.IRole; - /** Infrastructure defaults for Building Blocks created under this backend. */ - public readonly defaults: BlocksDefaults; - /** The default compute (owns the Lambda function + API Gateway); set in `create()`. @internal */ - _defaultCompute?: Compute; + public readonly backendHandlerPath: string; + /** + * Path to the app's backend module (`props.backendCDKPath`). Exposed so Building Blocks that + * co-bundle the backend at synth (e.g. the Agent BB's AgentCore Runtime) can discover it via + * `globalThis.CURRENT_BLOCKS_STACK.backendModulePath`. + */ + public readonly backendModulePath: string; + /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ + public readonly executionRole: iam.IRole; + /** Infrastructure defaults for Building Blocks created under this backend. */ + public readonly defaults: BlocksDefaults; + /** The default compute (owns the Lambda function + API Gateway); set in `create()`. @internal */ + _defaultCompute?: Compute; + + /** The default compute's Lambda function. To be removed once consumers move to the multi-compute model. */ + get handler(): cdk.aws_lambda_nodejs.NodejsFunction { + return this.requireDefaultCompute().fn; + } + /** The default compute's API Gateway REST API. To be removed once consumers move to the multi-compute model. */ + get gateway(): apigateway.RestApi { + return this.requireDefaultCompute().apiGateway; + } + /** The default compute's RPC endpoint URL. To be removed once consumers move to the multi-compute model. */ + get apiUrl(): string { + return this.requireDefaultCompute().apiUrl; + } + /** The default compute's handler CloudWatch log group. Its retention comes from + * the compute's `logRetention` (falling back to `defaults.logRetention`); the + * `bb-logger` CDK construct is a no-op and no longer touches it. */ + get handlerLogGroup(): cdk.aws_logs.ILogGroup { + return this.requireDefaultCompute().logGroup; + } + + private requireDefaultCompute(): LambdaShapedCompute { + if (!this._defaultCompute) { + throw new Error( + 'Blocks backend not fully initialized — access .handler/.gateway/.apiUrl after BlocksBackend.create() resolves.', + ); + } + return this._defaultCompute as LambdaShapedCompute; + } - /** The default compute's Lambda function. To be removed once consumers move to the multi-compute model. */ - get handler(): cdk.aws_lambda_nodejs.NodejsFunction { - return this.requireDefaultCompute().fn; - } - /** The default compute's API Gateway REST API. To be removed once consumers move to the multi-compute model. */ - get gateway(): apigateway.RestApi { - return this.requireDefaultCompute().apiGateway; - } - /** The default compute's RPC endpoint URL. To be removed once consumers move to the multi-compute model. */ - get apiUrl(): string { - return this.requireDefaultCompute().apiUrl; - } - /** The default compute's handler CloudWatch log group. `bb-logger` reconfigures its retention. */ - get handlerLogGroup(): cdk.aws_logs.ILogGroup { - return this.requireDefaultCompute().logGroup; - } + /** + * The fullId used by child Scopes to compute their env var names, + * construct IDs, and physical resource names (e.g., DynamoDB table names). + * + * Includes the CDK stack name to ensure physical resources are unique + * per deployment. This matches what the runtime sees via BLOCKS_STACK_NAME. + * + * IMPORTANT: this value MUST be token-free. Child Scopes embed `fullId` in + * CDK construct IDs (e.g. `${fullId}DsqlMigrationFn`), and CDK forbids + * unresolved tokens in construct IDs ("ID components may not include + * unresolved tokens"). It is also used to build env-var keys that must match + * byte-for-byte between synth time and runtime. + * + * A nested stack (e.g. Amplify Gen2 `backend.createStack('blocks')`) has a + * tokenized `stackName` that only resolves at deploy time. We therefore walk + * up to the top-level stack, whose name is concrete at synth time and still + * unique per deployment. The `Token.isUnresolved` guard is a defensive + * fallback to the (token-free) construct id should no resolvable name exist. + */ + get fullId(): string { + let stack = cdk.Stack.of(this); + while (stack.nestedStackParent) { + stack = stack.nestedStackParent; + } + const stackName = stack.stackName; + if (cdk.Token.isUnresolved(stackName)) { + return this.node.id; + } + return `${stackName}-${this.node.id}`; + } - private requireDefaultCompute(): LambdaShapedCompute { - if (!this._defaultCompute) { - throw new Error('Blocks backend not fully initialized — access .handler/.gateway/.apiUrl after BlocksBackend.create() resolves.'); - } - return this._defaultCompute as LambdaShapedCompute; - } + private constructor(scope: Construct, id: string, props: BlocksBackendProps) { + super(scope, id); - /** - * The fullId used by child Scopes to compute their env var names, - * construct IDs, and physical resource names (e.g., DynamoDB table names). - * - * Includes the CDK stack name to ensure physical resources are unique - * per deployment. This matches what the runtime sees via BLOCKS_STACK_NAME. - * - * IMPORTANT: this value MUST be token-free. Child Scopes embed `fullId` in - * CDK construct IDs (e.g. `${fullId}DsqlMigrationFn`), and CDK forbids - * unresolved tokens in construct IDs ("ID components may not include - * unresolved tokens"). It is also used to build env-var keys that must match - * byte-for-byte between synth time and runtime. - * - * A nested stack (e.g. Amplify Gen2 `backend.createStack('blocks')`) has a - * tokenized `stackName` that only resolves at deploy time. We therefore walk - * up to the top-level stack, whose name is concrete at synth time and still - * unique per deployment. The `Token.isUnresolved` guard is a defensive - * fallback to the (token-free) construct id should no resolvable name exist. - */ - get fullId(): string { - let stack = cdk.Stack.of(this); - while (stack.nestedStackParent) { - stack = stack.nestedStackParent; - } - const stackName = stack.stackName; - if (cdk.Token.isUnresolved(stackName)) { - return this.node.id; - } - return `${stackName}-${this.node.id}`; - } + this.backendHandlerPath = props.backendHandlerPath; + this.backendModulePath = props.backendCDKPath; - private constructor(scope: Construct, id: string, props: BlocksBackendProps) { - super(scope, id); + // Expose self to Building Blocks at CDK time + (globalThis as any).CURRENT_BLOCKS_STACK = this; - this.backendHandlerPath = props.backendHandlerPath; - this.backendModulePath = props.backendCDKPath; + // Store defaults on the backend (not the stack) so several BlocksBackends + // in one stack each keep their own posture; Building Blocks resolve them by + // walking up to their owning backend (see Scope.defaults). + this.defaults = props.defaults; - // Expose self to Building Blocks at CDK time - (globalThis as any).CURRENT_BLOCKS_STACK = this; + const infra = setupBlocksInfra(this, props, id); + this.executionRole = infra.executionRole; + // The default compute (and thus handler/gateway) is created in create(), + // after construction — it derives BLOCKS_STACK_NAME from this.fullId. + } - // Store defaults on the backend (not the stack) so several BlocksBackends - // in one stack each keep their own posture; Building Blocks resolve them by - // walking up to their owning backend (see Scope.defaults). - this.defaults = props.defaults; + static async create(scope: Construct, id: string, props: CoreBlocksBackendProps) { + assertCdkConditionActive(); + const backend = new BlocksBackend(scope, id, props); + // Create the default compute before importing the backend: it OWNS the + // Lambda function + API Gateway (which back .handler/.gateway/.apiUrl), and + // a block reading `this.compute` in its constructor (during that import) + // must resolve to it. The factory is supplied by the umbrella + // @aws-blocks/blocks (which injects LambdaCompute) via props, so core never + // imports the concrete compute class. + backend._defaultCompute = props.defaultComputeFactory(backend); + // file:// URL (not a raw path) so the cache-busting query works on Windows, + // where an absolute path like `D:\...` is rejected as URL scheme `d:`. + const backendUrl = pathToFileURL(props.backendCDKPath); + backendUrl.searchParams.set('stack', id); + const mod = await import(backendUrl.href); + if (typeof mod.default === 'function') { + try { + await mod.default(backend); + } catch (error) { + throw new Error( + `Error executing default export function for backend "${id}": ${error instanceof Error ? error.message : error}`, + { cause: error }, + ); + } + } + addBlocksStackMetadata(cdk.Stack.of(backend)); - const infra = setupBlocksInfra(this, props, id); - this.executionRole = infra.executionRole; - // The default compute (and thus handler/gateway) is created in create(), - // after construction — it derives BLOCKS_STACK_NAME from this.fullId. - } + // Finalize BB config → S3 (after all BBs have registered their config) + finalizeConfigRegistry(backend, backend.executionRole, getComputes(backend)); - static async create(scope: Construct, id: string, props: CoreBlocksBackendProps) { - assertCdkConditionActive(); - const backend = new BlocksBackend(scope, id, props); - // Create the default compute before importing the backend: it OWNS the - // Lambda function + API Gateway (which back .handler/.gateway/.apiUrl), and - // a block reading `this.compute` in its constructor (during that import) - // must resolve to it. The factory is supplied by the umbrella - // @aws-blocks/blocks (which injects LambdaCompute) via props, so core never - // imports the concrete compute class. - backend._defaultCompute = props.defaultComputeFactory(backend); - // file:// URL (not a raw path) so the cache-busting query works on Windows, - // where an absolute path like `D:\...` is rejected as URL scheme `d:`. - const backendUrl = pathToFileURL(props.backendCDKPath); - backendUrl.searchParams.set('stack', id); - const mod = await import(backendUrl.href); - if (typeof mod.default === 'function') { - try { - await mod.default(backend); - } catch (error) { - throw new Error(`Error executing default export function for backend "${id}": ${error instanceof Error ? error.message : error}`, { cause: error }); - } - } - addBlocksStackMetadata(cdk.Stack.of(backend)); + // Tracing is presence-gated: if the app contains a Tracer, enable X-Ray on + // every compute. Runs before the dashboard finalize. + finalizeTracing(backend, backend.executionRole); - // Finalize BB config → S3 (after all BBs have registered their config) - finalizeConfigRegistry(backend, backend.executionRole, getComputes(backend)); + // Build any deferred Dashboards now that every compute's observability + // state is settled — so the dashboard is order-independent. + finalizeDashboards(backend); - return backend; - } + return backend; + } } diff --git a/packages/core/src/cdk/blocks-stack.test.ts b/packages/core/src/cdk/blocks-stack.test.ts index a11c8a53e..c89ed787d 100644 --- a/packages/core/src/cdk/blocks-stack.test.ts +++ b/packages/core/src/cdk/blocks-stack.test.ts @@ -7,6 +7,7 @@ import { before, describe, test } from 'node:test'; import { fileURLToPath } from 'node:url'; import * as cdk from 'aws-cdk-lib'; import * as apigateway from 'aws-cdk-lib/aws-apigateway'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import * as lambda from 'aws-cdk-lib/aws-lambda-nodejs'; import type { Construct } from 'constructs'; import type { ScopeParent } from '../common/index.js'; @@ -15,7 +16,7 @@ import { BlocksBackend } from './blocks-backend.js'; import { Compute } from './compute/compute.js'; import { getComputes } from './compute/compute-registry.js'; import type { DefaultComputeFactory } from './compute/default-compute-factory.js'; -import { BlocksStack, BlocksPresets, Scope } from './index.js'; +import { BlocksPresets, BlocksStack, Scope } from './index.js'; // A real app gets its default compute from @aws-blocks/bb-lambda-compute (via // @aws-blocks/blocks), which core's own tests can't depend on. Use an @@ -55,6 +56,17 @@ class StubLambdaCompute extends Compute { setEnv(key: string, value: string): void { this.fn.addEnvironment(key, value); } + + protected applyTracing(): void {} + protected healthWidgets(_region: string): IWidget[][] { + return []; + } + protected loggingWidgets(_region: string): IWidget[][] { + return []; + } + protected tracingWidgets(_region: string): IWidget[][] { + return []; + } } const stubComputeFactory: DefaultComputeFactory = (root) => new StubLambdaCompute(root as never, 'DefaultCompute'); @@ -72,9 +84,19 @@ const factoryBackendPath = join(__dirname, '__fixtures__', 'factory-backend.js') // Wrap create(), injecting the stub default-compute factory the way // @aws-blocks/blocks injects LambdaCompute — so tests don't repeat it. const makeStack = (scope: Construct, id: string, backendCDKPath: string) => - BlocksStack.create(scope, id, { backendHandlerPath: handlerPath, backendCDKPath, defaults: BlocksPresets.production, defaultComputeFactory: stubComputeFactory }); + BlocksStack.create(scope, id, { + backendHandlerPath: handlerPath, + backendCDKPath, + defaults: BlocksPresets.production, + defaultComputeFactory: stubComputeFactory, + }); const makeBackend = (scope: Construct, id: string, backendCDKPath: string) => - BlocksBackend.create(scope, id, { backendHandlerPath: handlerPath, backendCDKPath, defaults: BlocksPresets.production, defaultComputeFactory: stubComputeFactory }); + BlocksBackend.create(scope, id, { + backendHandlerPath: handlerPath, + backendCDKPath, + defaults: BlocksPresets.production, + defaultComputeFactory: stubComputeFactory, + }); describe('ESM cache-busting (multi-stage)', () => { test('BlocksStack.create() with same backendCDKPath but different IDs produces constructs in each', async () => { @@ -203,16 +225,13 @@ describe('assertCdkConditionActive', () => { try { const app = new cdk.App(); - await assert.rejects( - makeStack(app, 'MissingConditionStack', sideEffectBackendPath), - (err: Error) => { - assert.ok( - err.message.includes('Missing --conditions=cdk'), - `Expected condition error, got: ${err.message}`, - ); - return true; - }, - ); + await assert.rejects(makeStack(app, 'MissingConditionStack', sideEffectBackendPath), (err: Error) => { + assert.ok( + err.message.includes('Missing --conditions=cdk'), + `Expected condition error, got: ${err.message}`, + ); + return true; + }); } finally { process.env.NODE_OPTIONS = origNodeOptions; process.execArgv = origExecArgv; diff --git a/packages/core/src/cdk/compute/compute.ts b/packages/core/src/cdk/compute/compute.ts index fe719db2b..2c51a497c 100644 --- a/packages/core/src/cdk/compute/compute.ts +++ b/packages/core/src/cdk/compute/compute.ts @@ -1,6 +1,7 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import type { ScopeOptions } from '../../common/index.js'; import { Scope } from '../index.js'; import { registerCompute } from './compute-registry.js'; @@ -16,11 +17,17 @@ import { registerCompute } from './compute-registry.js'; * compute in an app runs the same backend and agrees on the resource-name * namespace. * + * **Observability is compute-owned at deploy time.** Logging is always on (a + * compute owns its log group and captures stdout; retention is a compute-level + * setting), so there is no "enable logging" — logs always exist. Tracing, by + * contrast, provisions real infrastructure (X-Ray) and has cost, so it is + * enabled explicitly via {@link enableTracing} — which the framework calls on + * every compute when the app contains a `Tracer` (presence-gated). + * * The abstract base lives in core (a framework primitive); concrete computes * live in their own packages (e.g. `LambdaCompute` in `@aws-blocks/bb-lambda-compute`). * - * @internal Not exported from the package's public entry points. Customers - * cannot instantiate a compute until the customer-facing surface exists. + * @internal Not exported from the package's public entry points. */ export abstract class Compute extends Scope { /** @@ -30,9 +37,23 @@ export abstract class Compute extends Scope { */ readonly namespaces: string[] = []; + /** + * Whether tracing has been enabled on this compute — flipped by + * {@link enableTracing}. Private so it can't be set independently of the + * infra; read internally by {@link dashboardSection} to decide whether to + * render the traces section. + */ + private tracerEnabled = false; + + /** Read-only view of {@link tracerEnabled} for subclasses (e.g. to guard + * their `tracingWidgets` builder). Subclasses can read but not set it. */ + protected get isTracerEnabled(): boolean { + return this.tracerEnabled; + } + constructor(id: string, options?: ScopeOptions) { super(id, options); - // Self-register on the owning stack so finalize steps (config, routing, + // Self-register on the owning stack so finalize steps (tracing, routing, // dashboards) can enumerate every compute without a separate discovery // pass. Scoped per stack, so a multi-stack synth keeps lists isolated. registerCompute(this); @@ -44,4 +65,85 @@ export abstract class Compute extends Scope { * directly so config targets the right compute. */ abstract setEnv(key: string, value: string): void; + + /** + * Enable distributed tracing on this compute: mark it traced (so the Dashboard + * renders its traces section) and turn on the compute's active tracing via + * {@link applyTracing}. Idempotent — the framework calls this on **every** + * compute when the app contains a `Tracer` (tracing is presence-gated, not + * per-compute), so calling it more than once is a no-op. + */ + enableTracing(): void { + if (this.tracerEnabled) return; + this.tracerEnabled = true; + this.applyTracing(); + } + + /** + * Turn on this compute's active tracing (e.g. X-Ray) and grant its role the + * permission to publish trace segments. Called by {@link enableTracing}; + * `protected` so tracing can't be turned on without marking the compute + * traced. + */ + protected abstract applyTracing(): void; + + /** + * Build this compute's CloudWatch Dashboard section: health widgets and log + * widgets **always** (logs are always captured for a compute), plus trace + * widgets **only when** tracing is enabled on this compute (via + * {@link enableTracing}). + * + * This is the single public entry the Dashboard Building Block uses; the + * per-kind builders below are `protected`. Whether the logs / traces sections + * are actually shown is a display choice the Dashboard makes on top (its + * `logs` / `traces` options) — this returns what the compute *has*. + * + * @param region - AWS region the widgets query metrics in. + */ + dashboardSection(region: string): ComputeDashboardSection { + return { + // The scope id (e.g. 'DefaultCompute', 'api') — short and readable for a + // section header, and distinct per compute within a stack. + label: this.id, + health: this.healthWidgets(region), + logging: this.loggingWidgets(region), + tracing: this.tracerEnabled ? this.tracingWidgets(region) : undefined, + }; + } + + /** + * Build this compute's health widget rows. Implemented by a concrete compute; + * obtained only via {@link dashboardSection}. + */ + protected abstract healthWidgets(region: string): IWidget[][]; + + /** + * Build this compute's **log** widget rows for its own log group. Logs always + * exist, so this is always available; the Dashboard's `logs` option decides + * whether to render it. + */ + protected abstract loggingWidgets(region: string): IWidget[][]; + + /** + * Build this compute's **trace** widget rows. Gated behind + * {@link dashboardSection} so it is only used when tracing is enabled. + */ + protected abstract tracingWidgets(region: string): IWidget[][]; +} + +/** + * A compute's self-reported CloudWatch Dashboard section. `health` and `logging` + * are always present; `tracing` is populated only when tracing is enabled on the + * compute. (The Dashboard may still hide `logging` / `tracing` via its display + * options.) + */ +export interface ComputeDashboardSection { + /** Display label used as the compute's group header. */ + label: string; + /** Health widget rows — always present. */ + health: IWidget[][]; + /** Log widget rows — always present (logs are always captured). */ + logging?: IWidget[][]; + /** Trace widget rows — present only when tracing is enabled. */ + tracing?: IWidget[][]; } diff --git a/packages/core/src/cdk/config-registry.test.ts b/packages/core/src/cdk/config-registry.test.ts index 2b4a96db8..d62fb5b99 100644 --- a/packages/core/src/cdk/config-registry.test.ts +++ b/packages/core/src/cdk/config-registry.test.ts @@ -13,6 +13,7 @@ import assert from 'node:assert'; import { afterEach, test } from 'node:test'; import * as cdk from 'aws-cdk-lib'; import { Match, Template } from 'aws-cdk-lib/assertions'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; import { Construct } from 'constructs'; import { Compute } from './compute/compute.js'; import { finalizeConfigRegistry, getConfigLocation, registerConfig } from './config-registry.js'; @@ -42,6 +43,19 @@ class TestCompute extends Compute { setEnv(key: string, value: string): void { this.fn.addEnvironment(key, value); } + + // Observability hooks are irrelevant to config-registry tests — stub them so + // this test double satisfies Compute's abstract contract. + protected applyTracing(): void {} + protected healthWidgets(): IWidget[][] { + return []; + } + protected loggingWidgets(): IWidget[][] { + return []; + } + protected tracingWidgets(): IWidget[][] { + return []; + } } function stackWithCompute(id: string): { diff --git a/packages/core/src/cdk/dashboard-registry.ts b/packages/core/src/cdk/dashboard-registry.ts new file mode 100644 index 000000000..8c2a979b4 --- /dev/null +++ b/packages/core/src/cdk/dashboard-registry.ts @@ -0,0 +1,68 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as cdk from 'aws-cdk-lib'; +import type { Construct } from 'constructs'; + +const REGISTRY_KEY = Symbol.for('BLOCKS_DASHBOARD_REGISTRY'); + +/** A Dashboard's deferred widget-body build, run once after the app is constructed. */ +type DashboardFinalizer = () => void; + +/** + * Get or create the deferred-dashboard list for a given stack. Stored on the + * stack object (keyed by a Symbol), so each stack in a multi-stack synth gets + * its own — mirrors the config + compute registries. + */ +function getRegistry(stack: cdk.Stack): DashboardFinalizer[] { + let list = (stack as unknown as Record)[REGISTRY_KEY]; + if (!list) { + list = []; + (stack as unknown as Record)[REGISTRY_KEY] = list; + } + return list; +} + +/** + * Register a Dashboard's deferred body-build, to run after every Building Block + * in the app has been constructed (the end of `BlocksStack`/`BlocksBackend` + * `create()`, once the backend module has fully imported). + * + * The Dashboard builds its widget body here rather than in its constructor + * because the body depends on which computes are traced, and a `Tracer` may be + * constructed *after* the Dashboard. Deferring makes the Dashboard observe the + * complete app, so it never depends on construction order. (The Dashboard's + * CloudWatch resource itself is created eagerly in the constructor, so its URL / + * redirect route never dangle — only the body is deferred.) + * + * This is intentionally scoped to the Dashboard (the only deferred-build case + * today) rather than a generic finalizer mechanism; generalize it only if a + * second use case appears. + * + * @param scope - Any construct in the stack (used to locate the stack). + * @param finalize - The deferred body-build; run once (in registration order) + * by {@link finalizeDashboards}. + */ +export function registerDashboardFinalizer(scope: Construct, finalize: DashboardFinalizer): void { + getRegistry(cdk.Stack.of(scope)).push(finalize); +} + +/** + * Run — and clear — every registered Dashboard finalizer on `scope`'s stack, in + * registration order. Called once from `create()` after the backend module has + * imported. Clearing the list makes a repeated call a no-op, so a dashboard's + * body is never built twice. + * + * A Dashboard constructed outside `create()` (e.g. directly in a unit test) must + * call this explicitly before synth — the same way `config-registry.test.ts` + * drives `finalizeConfigRegistry`. (A Dashboard's CloudWatch resource is created + * eagerly in its constructor, so even if its finalizer never runs its URL / + * redirect never dangle — only the widget body is missing.) + * + * @param scope - Any construct in the stack (used to locate the stack). + */ +export function finalizeDashboards(scope: Construct): void { + const list = getRegistry(cdk.Stack.of(scope)); + // Drain the list so a repeated call can't rebuild an already-built dashboard. + const pending = list.splice(0, list.length); + for (const finalize of pending) finalize(); +} diff --git a/packages/core/src/cdk/index.ts b/packages/core/src/cdk/index.ts index 822984363..25b2545ee 100644 --- a/packages/core/src/cdk/index.ts +++ b/packages/core/src/cdk/index.ts @@ -1,44 +1,48 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 -import * as cdk from 'aws-cdk-lib'; -import { Construct } from 'constructs'; import { pathToFileURL } from 'node:url'; import { __PIPELINE_STAGE_SCOPE__ } from '@aws-blocks/pipeline'; +import * as cdk from 'aws-cdk-lib'; +import { Construct } from 'constructs'; import { - type BlocksStackProps, - type BlocksStack as BaseBlocksStack, - type ScopeParent, - type ScopeOptions, - computeScopeFullId, + type BlocksStack as BaseBlocksStack, + type BlocksStackProps, + computeScopeFullId, + type ScopeOptions, + type ScopeParent, } from '../common/index.js'; -import { setupBlocksInfra, BlocksBackend, assertCdkConditionActive } from './blocks-backend.js'; -import { addBlocksStackMetadata } from './stack-metadata.js'; -import { finalizeConfigRegistry } from './config-registry.js'; +import { assertCdkConditionActive, BlocksBackend, setupBlocksInfra } from './blocks-backend.js'; import { type BlocksDefaults, BlocksPresets } from './blocks-defaults.js'; import type { Compute } from './compute/compute.js'; import { getComputes } from './compute/compute-registry.js'; import type { DefaultComputeFactory, LambdaShapedCompute } from './compute/default-compute-factory.js'; +import { finalizeConfigRegistry } from './config-registry.js'; +import { finalizeDashboards } from './dashboard-registry.js'; +import { finalizeTracing } from './tracer-registry.js'; +import { addBlocksStackMetadata } from './stack-metadata.js'; +export { ApiError, DEFAULT_API_ERROR_NAME, hasAuthError, isBlocksError } from '../errors.js'; +export type { ScopeOptions } from '../index.js'; +export { ensureApiGatewayAccount } from './apigateway-account.js'; export { BlocksBackend, type BlocksBackendProps, type CoreBlocksBackendProps, SHARED_HANDLER_TIMEOUT_SECONDS, } from './blocks-backend.js'; -export { DEFAULT_NODE_RUNTIME } from './node-version.js'; -export { blocksNodejsBundling } from './bundling.js'; -export { SandboxDisableDeletionProtection } from './mixins.js'; -export { registerConfig, finalizeConfigRegistry, getConfigLocation } from './config-registry.js'; -export { ensureApiGatewayAccount } from './apigateway-account.js'; export { - type BlocksDefaults, - type BlocksThrottling, - BlocksPresets, + type BlocksDefaults, + BlocksPresets, + type BlocksThrottling, } from './blocks-defaults.js'; +export { blocksNodejsBundling } from './bundling.js'; +export { finalizeConfigRegistry, getConfigLocation, registerConfig } from './config-registry.js'; +export { finalizeDashboards, registerDashboardFinalizer } from './dashboard-registry.js'; +export { finalizeTracing, registerTracer } from './tracer-registry.js'; +export { SandboxDisableDeletionProtection } from './mixins.js'; +export { DEFAULT_NODE_RUNTIME } from './node-version.js'; export { synthGuard } from './synth-guard.js'; -export type { ScopeOptions } from '../index.js'; -export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '../errors.js'; /** * Core's `create()` props: the public {@link BlocksStackProps} plus the required @@ -52,288 +56,317 @@ export { ApiError, isBlocksError, hasAuthError, DEFAULT_API_ERROR_NAME } from '. * @internal */ export interface CoreBlocksStackProps extends BlocksStackProps { - /** Builds the stack's default compute. Injected by `@aws-blocks/blocks`. */ - defaultComputeFactory: DefaultComputeFactory; + /** Builds the stack's default compute. Injected by `@aws-blocks/blocks`. */ + defaultComputeFactory: DefaultComputeFactory; } export class BlocksStack extends cdk.Stack implements BaseBlocksStack { - public readonly id: string; - public readonly backendHandlerPath: string; - /** - * Path to the app's backend module (`props.backendCDKPath`). Exposed so Building Blocks that - * co-bundle the backend at synth (e.g. the Agent BB's AgentCore Runtime) can discover it via - * `globalThis.CURRENT_BLOCKS_STACK.backendModulePath`. - */ - public readonly backendModulePath: string; - /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ - public readonly executionRole: cdk.aws_iam.IRole; - /** Infrastructure defaults for Building Blocks created under this stack. */ - public readonly defaults: BlocksDefaults; - /** The default compute (owns the Lambda function + API Gateway); set in `create()`. @internal */ - _defaultCompute?: Compute; - - /** The default compute's Lambda function. To be removed once consumers move to the multi-compute model. */ - get handler(): cdk.aws_lambda_nodejs.NodejsFunction { - return this.requireDefaultCompute().fn; - } - /** The default compute's API Gateway REST API. To be removed once consumers move to the multi-compute model. */ - get gateway(): cdk.aws_apigateway.RestApi { - return this.requireDefaultCompute().apiGateway; - } - /** The default compute's RPC endpoint URL. To be removed once consumers move to the multi-compute model. */ - get apiUrl(): string { - return this.requireDefaultCompute().apiUrl; - } - /** The default compute's handler CloudWatch log group. `bb-logger` reconfigures its retention. */ - get handlerLogGroup(): cdk.aws_logs.ILogGroup { - return this.requireDefaultCompute().logGroup; - } - - private requireDefaultCompute(): LambdaShapedCompute { - if (!this._defaultCompute) { - throw new Error('Blocks stack not fully initialized — access .handler/.gateway/.apiUrl after BlocksStack.create() resolves.'); - } - return this._defaultCompute as LambdaShapedCompute; - } - - private constructor(scope: Construct, id: string, props: BlocksStackProps) { - super(scope, id, props); - this.id = id; - this.backendHandlerPath = props.backendHandlerPath; - this.defaults = props.defaults; - this.backendModulePath = props.backendCDKPath; - - // Set globalThis so Building Blocks attach directly to this stack - (globalThis as any).CURRENT_BLOCKS_STACK = this; - - const infra = setupBlocksInfra(this, props, id); - this.executionRole = infra.executionRole; - } - - static async create(scope: Construct, id: string, props: CoreBlocksStackProps) { - assertCdkConditionActive(); - - // Detect ambient pipeline stage scope set by Pipeline appFile imports - const pipelineScope = (globalThis as any)[__PIPELINE_STAGE_SCOPE__]; - const actualScope = pipelineScope || scope; - - const stack = new BlocksStack(actualScope, id, props); - // Create the default compute before importing the backend: it OWNS the - // Lambda function + API Gateway (which back .handler/.gateway/.apiUrl), and - // a block reading `this.compute` in its constructor (during that import) - // must resolve to it. The factory is supplied by the umbrella - // @aws-blocks/blocks (which injects LambdaCompute) via props, so core never - // imports the concrete compute class. - stack._defaultCompute = props.defaultComputeFactory(stack); - // file:// URL (not a raw path) so the cache-busting query works on Windows, - // where an absolute path like `D:\...` is rejected as URL scheme `d:`. - const backendUrl = pathToFileURL(props.backendCDKPath); - backendUrl.searchParams.set('stack', id); - const mod = await import(backendUrl.href); - if (typeof mod.default === 'function') { - try { - await mod.default(stack); - } catch (error) { - throw new Error(`Error executing default export function for stack "${id}": ${error instanceof Error ? error.message : error}`, { cause: error }); - } - } - // Finalize BB config → S3 (after all BBs have registered their config) - finalizeConfigRegistry(stack, stack.executionRole, getComputes(stack)); - - new cdk.CfnOutput(stack, 'ApiUrl', { value: stack.apiUrl }); - - addBlocksStackMetadata(stack); - - return stack; - } + public readonly id: string; + public readonly backendHandlerPath: string; + /** + * Path to the app's backend module (`props.backendCDKPath`). Exposed so Building Blocks that + * co-bundle the backend at synth (e.g. the Agent BB's AgentCore Runtime) can discover it via + * `globalThis.CURRENT_BLOCKS_STACK.backendModulePath`. + */ + public readonly backendModulePath: string; + /** Shared IAM role assumed by all Blocks compute. Building Blocks grant to this role. */ + public readonly executionRole: cdk.aws_iam.IRole; + /** Infrastructure defaults for Building Blocks created under this stack. */ + public readonly defaults: BlocksDefaults; + /** The default compute (owns the Lambda function + API Gateway); set in `create()`. @internal */ + _defaultCompute?: Compute; + + /** The default compute's Lambda function. To be removed once consumers move to the multi-compute model. */ + get handler(): cdk.aws_lambda_nodejs.NodejsFunction { + return this.requireDefaultCompute().fn; + } + /** The default compute's API Gateway REST API. To be removed once consumers move to the multi-compute model. */ + get gateway(): cdk.aws_apigateway.RestApi { + return this.requireDefaultCompute().apiGateway; + } + /** The default compute's RPC endpoint URL. To be removed once consumers move to the multi-compute model. */ + get apiUrl(): string { + return this.requireDefaultCompute().apiUrl; + } + /** The default compute's handler CloudWatch log group. Its retention comes from + * the compute's `logRetention` (falling back to `defaults.logRetention`); the + * `bb-logger` CDK construct is a no-op and no longer touches it. */ + get handlerLogGroup(): cdk.aws_logs.ILogGroup { + return this.requireDefaultCompute().logGroup; + } + + private requireDefaultCompute(): LambdaShapedCompute { + if (!this._defaultCompute) { + throw new Error( + 'Blocks stack not fully initialized — access .handler/.gateway/.apiUrl after BlocksStack.create() resolves.', + ); + } + return this._defaultCompute as LambdaShapedCompute; + } + + private constructor(scope: Construct, id: string, props: BlocksStackProps) { + super(scope, id, props); + this.id = id; + this.backendHandlerPath = props.backendHandlerPath; + this.defaults = props.defaults; + this.backendModulePath = props.backendCDKPath; + + // Set globalThis so Building Blocks attach directly to this stack + (globalThis as any).CURRENT_BLOCKS_STACK = this; + + const infra = setupBlocksInfra(this, props, id); + this.executionRole = infra.executionRole; + } + + static async create(scope: Construct, id: string, props: CoreBlocksStackProps) { + assertCdkConditionActive(); + + // Detect ambient pipeline stage scope set by Pipeline appFile imports + const pipelineScope = (globalThis as any)[__PIPELINE_STAGE_SCOPE__]; + const actualScope = pipelineScope || scope; + + const stack = new BlocksStack(actualScope, id, props); + // Create the default compute before importing the backend: it OWNS the + // Lambda function + API Gateway (which back .handler/.gateway/.apiUrl), and + // a block reading `this.compute` in its constructor (during that import) + // must resolve to it. The factory is supplied by the umbrella + // @aws-blocks/blocks (which injects LambdaCompute) via props, so core never + // imports the concrete compute class. + stack._defaultCompute = props.defaultComputeFactory(stack); + // file:// URL (not a raw path) so the cache-busting query works on Windows, + // where an absolute path like `D:\...` is rejected as URL scheme `d:`. + const backendUrl = pathToFileURL(props.backendCDKPath); + backendUrl.searchParams.set('stack', id); + const mod = await import(backendUrl.href); + if (typeof mod.default === 'function') { + try { + await mod.default(stack); + } catch (error) { + throw new Error( + `Error executing default export function for stack "${id}": ${error instanceof Error ? error.message : error}`, + { cause: error }, + ); + } + } + // Finalize BB config → S3 (after all BBs have registered their config) + finalizeConfigRegistry(stack, stack.executionRole, getComputes(stack)); + + // Tracing is presence-gated: if the app contains a Tracer, enable X-Ray on + // every compute. Runs before the dashboard finalize so tracingEnabled is + // set when the dashboard reads it. + finalizeTracing(stack, stack.executionRole); + + // Build any deferred Dashboards now that every compute's observability + // state is settled — so the dashboard is order-independent. + finalizeDashboards(stack); + + new cdk.CfnOutput(stack, 'ApiUrl', { value: stack.apiUrl }); + + addBlocksStackMetadata(stack); + + return stack; + } } export class Scope extends Construct { - public readonly id: string; - public readonly parent: ScopeParent; - - readonly bbName?: string; - readonly bbVersion?: string; - - /** - * The owning stack/backend (the root of the Blocks construct tree), resolved - * once at construction: the nearest BlocksStack/BlocksBackend up the construct - * tree, or the ambient `globalThis.CURRENT_BLOCKS_STACK` fallback. All - * root-derived accessors below read from this instead of each repeating the - * tree walk. - */ - private readonly root: BlocksStack | BlocksBackend; - - /** - * Compute assigned at this node. Applies to this block and is inherited by - * descendants (a nearer assignment wins). Covers both a handler assigned to a - * specific compute and a scope-level default for its subtree. Internal until - * the customer-facing surface exists. - * @internal - */ - _compute?: Compute; - - constructor(id: string, options?: ScopeOptions) { - const parent = options?.parent || (globalThis as any).CURRENT_BLOCKS_STACK; - super(parent, id); - this.id = id; - this.parent = parent; - this.root = this.resolveRoot(); - } - - /** - * Walk up the construct tree to the nearest owning BlocksStack/BlocksBackend; - * fall back to the ambient `globalThis.CURRENT_BLOCKS_STACK`. Called once from - * the constructor; the result is cached in {@link root}. - */ - private resolveRoot(): BlocksStack | BlocksBackend { - let current: Construct = this; - while (current.node.scope) { - current = current.node.scope as Construct; - if (current instanceof BlocksStack || current instanceof BlocksBackend) { - return current; - } - } - // Fallback to the ambient stack. In production this is always a real - // BlocksStack/BlocksBackend; the cast also admits the test doubles that set - // globalThis.CURRENT_BLOCKS_STACK to a stub exposing the same surface. - return (globalThis as any).CURRENT_BLOCKS_STACK as BlocksStack | BlocksBackend; - } - - get handler() { - return this.root.handler; - } - - /** - * The shared IAM role assumed by all Blocks compute. Building Blocks grant - * their permissions to this role; CDK's `grant*()` / `addToPrincipalPolicy()` - * route those grants to the role's default (inline) policy. - */ - get executionRole(): cdk.aws_iam.IRole { - return this.root.executionRole; - } - - /** - * The compute this block runs on: the nearest `_compute` assigned on this - * block or an ancestor scope, else the owning stack/backend's default compute. - * - * For any app that doesn't assign a compute, this always resolves to the - * default — so reads are a no-op refactor. `_compute` is internal - * (test/framework) until the customer-facing surface exists; there is no - * public option to set it yet. - */ - get compute(): Compute { - for (let current: ScopeParent | undefined = this; current; current = (current as Scope).parent) { - const assigned = (current as Scope)._compute; - if (assigned) return assigned; - } - const defaultCompute = this.root._defaultCompute; - if (!defaultCompute) { - throw new Error('Default compute not initialized — BlocksStack/BlocksBackend.create() must run before resolving `compute`.'); - } - return defaultCompute; - } - - /** - * The stack's default compute, **ignoring** any per-scope `_compute` - * assignment (unlike {@link compute}, which resolves the nearest assigned - * one). Use when a resource is a stack-level singleton that must bind to one - * deterministic compute regardless of the block's resolved compute — e.g. - * Realtime's shared WebSocket route integration, where one WebSocket API - * integrates to a single target and connection bookkeeping is compute-agnostic. - * - * @internal Not a customer surface; for framework/BB singleton infra only. - */ - get defaultCompute(): Compute { - const defaultCompute = this.root._defaultCompute; - if (!defaultCompute) { - throw new Error('Default compute not initialized — BlocksStack/BlocksBackend.create() must run before resolving `defaultCompute`.'); - } - return defaultCompute; - } - - /** - * The backend entry file the owning BlocksStack/BlocksBackend runs — the - * single handler entry shared across the whole app. - */ - get backendHandlerPath(): string { - return this.root.backendHandlerPath; - } - - /** - * The owning stack/backend's token-free root identity. This is the value the - * runtime receives as `BLOCKS_STACK_NAME` and rebuilds `fullId` from, so - * physical resource names (DynamoDB tables, env-var keys, IAM ARNs) derived - * from `fullId` match byte-for-byte between synth and runtime — otherwise the - * runtime looks up names that were never created. `BlocksBackend` exposes this - * as `fullId` ({@link BlocksBackend.fullId}); `BlocksStack` as `id`. - */ - get backendStackName(): string { - const name = this.root instanceof BlocksBackend ? this.root.fullId : this.root.id; - if (!name) { - throw new Error('Owning Blocks stack/backend has no id to derive BLOCKS_STACK_NAME'); - } - return name; - } - - /** - * The shared handler Lambda's CloudWatch log group (the default compute's). - * Resolves the same way as {@link handler} — via the owning - * BlocksStack/BlocksBackend. `bb-logger` uses this to reconfigure retention on - * the single, framework-owned group rather than creating a second one that - * would collide on the log-group name. - */ - get handlerLogGroup(): cdk.aws_logs.ILogGroup { - return this.root.handlerLogGroup; - } - - get fullId(): string { - return computeScopeFullId(this); - } - - /** - * The stack-wide infrastructure {@link BlocksDefaults} registered by - * `BlocksStack.create` / `BlocksBackend.create`. Read these in a Building - * Block's CDK constructor to resolve a durability value, letting a per-block - * option override: - * - * ```ts - * const removalPolicy = options?.removalPolicy ?? this.defaults.removalPolicy; - * ``` - */ - get defaults(): BlocksDefaults { - // Resolve the same way as handler/executionRole: walk up to the owning - // BlocksStack/BlocksBackend and read its defaults, so several backends in - // one stack each keep their own posture. Falls back to the ambient stack, - // then to the production preset when none was registered. - let current: Construct = this; - while (current.node.scope) { - current = current.node.scope as Construct; - if (current instanceof BlocksStack || current instanceof BlocksBackend) { - return current.defaults; - } - } - const ambient = ((globalThis as any).CURRENT_BLOCKS_STACK as { defaults?: BlocksDefaults } | undefined)?.defaults; - if (ambient) return ambient; - // No owning BlocksStack/BlocksBackend in the tree and none ambient — this is - // usually a deliberate test stub, but could be a real misconfiguration (a - // block built outside any Blocks backend). Fall back to the safe production - // posture, and log so it's debuggable if it fires unexpectedly. - console.warn( - `[Blocks] Scope "${this.id}" resolved infrastructure defaults with no owning ` + - 'BlocksStack/BlocksBackend in scope; falling back to BlocksPresets.production.', - ); - return BlocksPresets.production; - } - - protected buildUserAgentChain(): [string, string][] { - return []; - } - - // Plugin registration — no-ops in CDK context (plugins are only used at dev/build time) - registerClientMiddleware(_packageSpecifier: string): void {} - registerDevAttachment(_packageSpecifier: string): void {} - registerLambdaEventHandler(_eventSource: string, _identifier: string, _handler: (record: any) => Promise): void {} - get clientMiddleware(): readonly string[] { return []; } - get devAttachments(): readonly string[] { return []; } + public readonly id: string; + public readonly parent: ScopeParent; + + readonly bbName?: string; + readonly bbVersion?: string; + + /** + * The owning stack/backend (the root of the Blocks construct tree), resolved + * once at construction: the nearest BlocksStack/BlocksBackend up the construct + * tree, or the ambient `globalThis.CURRENT_BLOCKS_STACK` fallback. All + * root-derived accessors below read from this instead of each repeating the + * tree walk. + */ + private readonly root: BlocksStack | BlocksBackend; + + /** + * Compute assigned at this node. Applies to this block and is inherited by + * descendants (a nearer assignment wins). Covers both a handler assigned to a + * specific compute and a scope-level default for its subtree. Internal until + * the customer-facing surface exists. + * @internal + */ + _compute?: Compute; + + constructor(id: string, options?: ScopeOptions) { + const parent = options?.parent || (globalThis as any).CURRENT_BLOCKS_STACK; + super(parent, id); + this.id = id; + this.parent = parent; + this.root = this.resolveRoot(); + } + + /** + * Walk up the construct tree to the nearest owning BlocksStack/BlocksBackend; + * fall back to the ambient `globalThis.CURRENT_BLOCKS_STACK`. Called once from + * the constructor; the result is cached in {@link root}. + */ + private resolveRoot(): BlocksStack | BlocksBackend { + let current: Construct = this; + while (current.node.scope) { + current = current.node.scope as Construct; + if (current instanceof BlocksStack || current instanceof BlocksBackend) { + return current; + } + } + // Fallback to the ambient stack. In production this is always a real + // BlocksStack/BlocksBackend; the cast also admits the test doubles that set + // globalThis.CURRENT_BLOCKS_STACK to a stub exposing the same surface. + return (globalThis as any).CURRENT_BLOCKS_STACK as BlocksStack | BlocksBackend; + } + + get handler() { + return this.root.handler; + } + + /** + * The shared IAM role assumed by all Blocks compute. Building Blocks grant + * their permissions to this role; CDK's `grant*()` / `addToPrincipalPolicy()` + * route those grants to the role's default (inline) policy. + */ + get executionRole(): cdk.aws_iam.IRole { + return this.root.executionRole; + } + + /** + * The compute this block runs on: the nearest `_compute` assigned on this + * block or an ancestor scope, else the owning stack/backend's default compute. + * + * For any app that doesn't assign a compute, this always resolves to the + * default — so reads are a no-op refactor. `_compute` is internal + * (test/framework) until the customer-facing surface exists; there is no + * public option to set it yet. + */ + get compute(): Compute { + for (let current: ScopeParent | undefined = this; current; current = (current as Scope).parent) { + const assigned = (current as Scope)._compute; + if (assigned) return assigned; + } + const defaultCompute = this.root._defaultCompute; + if (!defaultCompute) { + throw new Error( + 'Default compute not initialized — BlocksStack/BlocksBackend.create() must run before resolving `compute`.', + ); + } + return defaultCompute; + } + + /** + * The stack's default compute, **ignoring** any per-scope `_compute` + * assignment (unlike {@link compute}, which resolves the nearest assigned + * one). Use when a resource is a stack-level singleton that must bind to one + * deterministic compute regardless of the block's resolved compute — e.g. + * Realtime's shared WebSocket route integration, where one WebSocket API + * integrates to a single target and connection bookkeeping is compute-agnostic. + * + * @internal Not a customer surface; for framework/BB singleton infra only. + */ + get defaultCompute(): Compute { + const defaultCompute = this.root._defaultCompute; + if (!defaultCompute) { + throw new Error( + 'Default compute not initialized — BlocksStack/BlocksBackend.create() must run before resolving `defaultCompute`.', + ); + } + return defaultCompute; + } + + /** + * The backend entry file the owning BlocksStack/BlocksBackend runs — the + * single handler entry shared across the whole app. + */ + get backendHandlerPath(): string { + return this.root.backendHandlerPath; + } + + /** + * The owning stack/backend's token-free root identity. This is the value the + * runtime receives as `BLOCKS_STACK_NAME` and rebuilds `fullId` from, so + * physical resource names (DynamoDB tables, env-var keys, IAM ARNs) derived + * from `fullId` match byte-for-byte between synth and runtime — otherwise the + * runtime looks up names that were never created. `BlocksBackend` exposes this + * as `fullId` ({@link BlocksBackend.fullId}); `BlocksStack` as `id`. + */ + get backendStackName(): string { + const name = this.root instanceof BlocksBackend ? this.root.fullId : this.root.id; + if (!name) { + throw new Error('Owning Blocks stack/backend has no id to derive BLOCKS_STACK_NAME'); + } + return name; + } + + /** + * The shared handler Lambda's CloudWatch log group (the default compute's). + * Resolves the same way as {@link handler} — via the owning + * BlocksStack/BlocksBackend. Its retention comes from the compute's + * `logRetention` (falling back to `defaults.logRetention`); the `bb-logger` + * CDK construct is a no-op and no longer reconfigures it. + */ + get handlerLogGroup(): cdk.aws_logs.ILogGroup { + return this.root.handlerLogGroup; + } + + get fullId(): string { + return computeScopeFullId(this); + } + + /** + * The stack-wide infrastructure {@link BlocksDefaults} registered by + * `BlocksStack.create` / `BlocksBackend.create`. Read these in a Building + * Block's CDK constructor to resolve a durability value, letting a per-block + * option override: + * + * ```ts + * const removalPolicy = options?.removalPolicy ?? this.defaults.removalPolicy; + * ``` + */ + get defaults(): BlocksDefaults { + // Resolve the same way as handler/executionRole: walk up to the owning + // BlocksStack/BlocksBackend and read its defaults, so several backends in + // one stack each keep their own posture. Falls back to the ambient stack, + // then to the production preset when none was registered. + let current: Construct = this; + while (current.node.scope) { + current = current.node.scope as Construct; + if (current instanceof BlocksStack || current instanceof BlocksBackend) { + return current.defaults; + } + } + const ambient = ((globalThis as any).CURRENT_BLOCKS_STACK as { defaults?: BlocksDefaults } | undefined) + ?.defaults; + if (ambient) return ambient; + // No owning BlocksStack/BlocksBackend in the tree and none ambient — this is + // usually a deliberate test stub, but could be a real misconfiguration (a + // block built outside any Blocks backend). Fall back to the safe production + // posture, and log so it's debuggable if it fires unexpectedly. + console.warn( + `[Blocks] Scope "${this.id}" resolved infrastructure defaults with no owning ` + + 'BlocksStack/BlocksBackend in scope; falling back to BlocksPresets.production.', + ); + return BlocksPresets.production; + } + + protected buildUserAgentChain(): [string, string][] { + return []; + } + + // Plugin registration — no-ops in CDK context (plugins are only used at dev/build time) + registerClientMiddleware(_packageSpecifier: string): void {} + registerDevAttachment(_packageSpecifier: string): void {} + registerLambdaEventHandler( + _eventSource: string, + _identifier: string, + _handler: (record: any) => Promise, + ): void {} + get clientMiddleware(): readonly string[] { + return []; + } + get devAttachments(): readonly string[] { + return []; + } } diff --git a/packages/core/src/cdk/internal.ts b/packages/core/src/cdk/internal.ts index 5e554cc46..bd87085ad 100644 --- a/packages/core/src/cdk/internal.ts +++ b/packages/core/src/cdk/internal.ts @@ -23,8 +23,12 @@ * @internal */ -export { Compute } from './compute/compute.js'; -export type { DefaultComputeFactory } from './compute/default-compute-factory.js'; // Reserved `/aws-blocks` path segment, needed by concrete computes (e.g. // LambdaCompute in @aws-blocks/bb-lambda-compute) to build their API route tree. export { BLOCKS_NAMESPACE } from '../constants.js'; +export type { ComputeDashboardSection } from './compute/compute.js'; +export { Compute } from './compute/compute.js'; +// Enumerate the computes registered on a stack — the Dashboard BB's default +// compute selection resolves through this at finalize. +export { getComputes } from './compute/compute-registry.js'; +export type { DefaultComputeFactory } from './compute/default-compute-factory.js'; diff --git a/packages/core/src/cdk/tracer-registry.ts b/packages/core/src/cdk/tracer-registry.ts new file mode 100644 index 000000000..ae40a180b --- /dev/null +++ b/packages/core/src/cdk/tracer-registry.ts @@ -0,0 +1,54 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 +import * as cdk from 'aws-cdk-lib'; +import { type IRole, PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import type { Construct } from 'constructs'; +import { getComputes } from './compute/compute-registry.js'; + +const REGISTRY_KEY = Symbol.for('BLOCKS_TRACER_PRESENCE'); + +/** + * Mark that the app contains a `Tracer`. Tracing is **presence-gated**: a Tracer + * anywhere in the app means every compute should be traced (X-Ray provisions + * real, costed infra, so it's off unless the app opts in by creating a Tracer). + * Multiple Tracers are fine — this just records the boolean. Stored per stack + * (keyed by a Symbol), like the config/compute registries. + * + * @param scope - Any construct in the stack (used to locate the stack). + */ +export function registerTracer(scope: Construct): void { + (cdk.Stack.of(scope) as unknown as Record)[REGISTRY_KEY] = true; +} + +function hasTracer(stack: cdk.Stack): boolean { + return (stack as unknown as Record)[REGISTRY_KEY] === true; +} + +/** + * If the app contains a `Tracer`, enable tracing on **every** compute in the + * stack and grant X-Ray publish **once** on the shared execution role. Runs at + * the end of `create()` (after the backend module has imported, so all computes + * are registered). `Compute.enableTracing()` is idempotent, so this is safe + * regardless of how many Tracers exist. + * + * The IAM grant lives here — at the framework level, once on the shared role — + * rather than in each compute's `applyTracing()`: every compute assumes the same + * execution role, so a per-compute grant would add N identical statements. The + * compute only flips its own tracing mode (e.g. Lambda `TracingConfig: Active`); + * the permission to publish segments is a single stack-level concern. + * + * @param scope - Any construct in the stack (used to locate the stack + computes). + * @param executionRole - The shared execution role every compute assumes; granted + * X-Ray publish once when tracing is enabled. + */ +export function finalizeTracing(scope: Construct, executionRole: IRole): void { + if (!hasTracer(cdk.Stack.of(scope))) return; + for (const compute of getComputes(scope)) compute.enableTracing(); + // One grant on the shared role rather than one per traced compute. + executionRole.addToPrincipalPolicy( + new PolicyStatement({ + actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'], + resources: ['*'], + }), + ); +} diff --git a/packages/core/src/index.cdk.ts b/packages/core/src/index.cdk.ts index b8edcb44d..c342d60eb 100644 --- a/packages/core/src/index.cdk.ts +++ b/packages/core/src/index.cdk.ts @@ -31,8 +31,12 @@ export { DEFAULT_NODE_RUNTIME, ensureApiGatewayAccount, finalizeConfigRegistry, + finalizeDashboards, + finalizeTracing, getConfigLocation, registerConfig, + registerDashboardFinalizer, + registerTracer, SandboxDisableDeletionProtection, Scope, SHARED_HANDLER_TIMEOUT_SECONDS,