From 5847dbaae7948274cdb1aa1aba34f9b326b5fc47 Mon Sep 17 00:00:00 2001 From: Simone Zhang Date: Sun, 30 Aug 2026 17:51:41 +0200 Subject: [PATCH] feat(observability): compute-scoped observability and a per-compute Dashboard MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Move the observability surface onto the compute. Compute gains enableLogging(retention?) / enableTracing() — public entry points that flip private loggerEnabled/tracerEnabled flags and drive the protected provisionLogGroup/applyTracing infra hooks — plus dashboardSection(region), the single public builder returning { label, health, logging?, tracing? } gated on those flags. Logger and Tracer now delegate to their resolved compute instead of touching the Lambda directly. Omitting the Logger's retention keeps today's behavior: logging is marked enabled (the section renders) but no LogGroup is provisioned — Lambda's auto-created group applies (logs never expire). Dashboard is organized by compute: it takes computes?: Compute | Compute[] (defaulting to the app's default compute) and renders one group per compute, with logs/traces appearing automatically for computes that have a Logger/Tracer attached. The logger/tracer options are removed (BREAKING). Metrics stay app-scoped and move to metrics?: MetricsSource | MetricsSource[], pairing each Metrics BB with its own metricConfigs so widget names stay namespace-correct. LambdaCompute: buildLambdaWidgets renamed to buildHealthWidgets; widget builders are protected and guard-throw when the corresponding observability BB is not attached. --- .changeset/compute-observability-methods.md | 59 +++ package-lock.json | 70 +++ packages/bb-async-job/src/index.cdk.test.ts | 53 ++- packages/bb-cron-job/src/index.cdk.test.ts | 33 +- packages/bb-dashboard/API.md | 13 +- packages/bb-dashboard/DESIGN.md | 183 ++++++-- packages/bb-dashboard/README.md | 89 ++-- packages/bb-dashboard/package.json | 3 +- packages/bb-dashboard/src/index.aws.ts | 1 + packages/bb-dashboard/src/index.browser.ts | 1 + packages/bb-dashboard/src/index.cdk.test.ts | 127 +++++ packages/bb-dashboard/src/index.cdk.ts | 50 +- packages/bb-dashboard/src/index.mock.ts | 1 + packages/bb-dashboard/src/index.test.ts | 374 ++++++--------- packages/bb-dashboard/src/types.ts | 130 ++++-- packages/bb-dashboard/src/widgets.ts | 316 +++---------- .../bb-lambda-compute/src/index.cdk.test.ts | 155 ++++++- packages/bb-lambda-compute/src/index.cdk.ts | 61 ++- packages/bb-lambda-compute/src/widgets.ts | 198 ++++++++ packages/bb-logger/DESIGN.md | 28 +- packages/bb-logger/src/index.cdk.test.ts | 101 ++-- packages/bb-logger/src/index.cdk.ts | 74 +-- packages/bb-tracer/DESIGN.md | 11 +- packages/bb-tracer/src/index.cdk.ts | 18 +- packages/blocks/API.md | 3 + packages/blocks/src/index.cdk.ts | 174 +++++-- packages/blocks/src/index.ts | 433 ++++++++++-------- packages/core/src/cdk/blocks-backend.test.ts | 138 +++--- packages/core/src/cdk/blocks-stack.test.ts | 46 +- packages/core/src/cdk/compute/compute.ts | 167 +++++++ packages/core/src/cdk/config-registry.test.ts | 15 + packages/core/src/cdk/internal.ts | 1 + 32 files changed, 2001 insertions(+), 1125 deletions(-) create mode 100644 .changeset/compute-observability-methods.md create mode 100644 packages/bb-dashboard/src/index.cdk.test.ts create mode 100644 packages/bb-lambda-compute/src/widgets.ts diff --git a/.changeset/compute-observability-methods.md b/.changeset/compute-observability-methods.md new file mode 100644 index 000000000..22eae31a6 --- /dev/null +++ b/.changeset/compute-observability-methods.md @@ -0,0 +1,59 @@ +--- +"@aws-blocks/core": patch +"@aws-blocks/bb-lambda-compute": patch +"@aws-blocks/bb-logger": patch +"@aws-blocks/bb-tracer": patch +"@aws-blocks/bb-dashboard": minor +"@aws-blocks/blocks": minor +--- + +feat: compute-scoped observability and a per-compute Dashboard + +The `Compute` abstraction gains observability so observability Building Blocks +target the resolved compute instead of poking a specific function: + +- `enableLogging(retentionDays?)` — marks the compute as having a Logger + attached (so the Dashboard shows its logs) and, when a `retentionDays` is + given, sets the retention on **this compute's own** single log group (created + with the stack-wide `defaults.logRetention`), so no second group is spawned. + The compute owns whether a group already exists plus the last-wins + + synth-conflict-warning policy, so several Loggers on one compute can't collide; +- `enableTracing()` — marks the compute as traced and turns on active X-Ray + tracing + the role's trace-publish permission; +- `dashboardSection(region)` — returns the compute's CloudWatch Dashboard section + (`{ label, health, logging?, tracing? }`); logs / traces are populated only + when a Logger / Tracer is attached, so a caller can't build an empty section. + +The compute owns its own state and infra: the `loggerEnabled` / `tracerEnabled` +flags are private, and the log-group-retention and X-Ray hooks are `protected`, +so a flag can't be set independently of the infra and retention always runs the +shared last-wins/conflict-warning policy. Logger just calls +`this.compute.enableLogging(options?.retention)` — its only observability seam — +and Tracer calls `this.compute.enableTracing()`. Because these target the +*resolved* compute, an observability block attached to a non-default compute +reconfigures that compute's group — not always the default one. + +**Breaking (Dashboard):** the Dashboard now renders logs / traces sections +automatically for whichever compute has a Logger / Tracer attached (the compute +self-reports this), rather than taking those Building Blocks as options. +Consequently the `logger` and `tracer` options are **removed** — attaching those +Building Blocks is the signal, so they no longer need to be passed to the +Dashboard. Metrics remain app-scoped and explicit, but are now passed as +**`MetricsSource`** objects (a Metrics BB paired with its own `metricConfigs`) — +a single source or an array, one app-wide section per namespace. The top-level +`metricConfigs` option is **removed**: metric names are namespace-specific, so +they live on their source. No compute selector is exposed — the dashboard covers +the app's single default compute (a `computes` option arrives with the +multi-compute customer surface). + +Migration: +- drop `logger` / `tracer` from `new Dashboard(...)` options; keep creating the + `Logger` / `Tracer` Building Blocks as before; +- move `metricConfigs` inside the metrics source: + `metrics: { metrics, metricConfigs: [...] }` (was `metrics, metricConfigs: [...]`). + +The `MetricsSource` type is now re-exported from `@aws-blocks/blocks` (and +`@aws-blocks/blocks/cdk`) so it can be imported to annotate variables, not just +passed inline. `LoggerBBRef` / `TracerBBRef` are marked `@deprecated` — the +Dashboard no longer consumes them (attaching a Logger / Tracer to a compute is +the signal); they remain exported for backward compatibility. diff --git a/package-lock.json b/package-lock.json index 1df2012e5..ceea2caf3 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" }, diff --git a/packages/bb-async-job/src/index.cdk.test.ts b/packages/bb-async-job/src/index.cdk.test.ts index 0c0286f84..eca2c67bf 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,18 @@ 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 applyLogRetention(): void {} + protected applyTracing(): void {} + protected healthWidgets(): IWidget[][] { + return []; + } + protected loggingWidgets(): IWidget[][] { + return []; + } + protected tracingWidgets(): IWidget[][] { + return []; + } } const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -261,7 +274,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 +288,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 +302,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 +316,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..f94fd7740 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,18 @@ 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 applyLogRetention(): void {} + protected applyTracing(): void {} + protected healthWidgets(): IWidget[][] { + return []; + } + protected loggingWidgets(): IWidget[][] { + return []; + } + protected tracingWidgets(): IWidget[][] { + return []; + } } const __dirname = dirname(fileURLToPath(import.meta.url)); @@ -126,9 +139,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..1412bb409 100644 --- a/packages/bb-dashboard/API.md +++ b/packages/bb-dashboard/API.md @@ -23,15 +23,12 @@ export const DashboardErrors: { export interface DashboardOptions { dashboardName?: string; defaultTimeRange?: string; - logger?: LoggerBBRef; - metricConfigs?: MetricConfig[]; - metrics?: MetricsBBRef; + metrics?: MetricsSource | MetricsSource[]; routePath?: string | false; title?: string; - tracer?: TracerBBRef; } -// @public +// @public @deprecated export interface LoggerBBRef { // (undocumented) readonly fullId: string; @@ -54,6 +51,12 @@ export interface MetricsBBRef { } // @public +export interface MetricsSource { + metricConfigs?: MetricConfig[]; + metrics: MetricsBBRef; +} + +// @public @deprecated export interface TracerBBRef { // (undocumented) readonly fullId: string; diff --git a/packages/bb-dashboard/DESIGN.md b/packages/bb-dashboard/DESIGN.md index 15fc9ef69..2098117c3 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,97 @@ 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** — For full visibility, attach a Logger and Tracer to the compute (their sections appear automatically) and pass Metrics source(s) to the dashboard; use `title` to distinguish dashboards across multi-stage deployments. - **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 internally; no compute selector is exposed yet. The +> dashboard is organized **by compute** — it renders the app's default compute +> (`this.compute`) as a group (health always; logs/traces only when a +> Logger/Tracer is attached to it) and app-wide metrics sections after it, one +> per `MetricsSource`. There is **no public `computes` option**: `Compute` isn't +> customer-instantiable yet (mirroring how Logger/Tracer expose no compute +> parameter), so exposing a compute selector would leak an internal type. A +> `computes` option arrives with the multi-compute customer surface (see D-DB-10). +> The removed `logger`/`tracer` options were a breaking change. + +### 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) + logs (only if a Logger targets this compute) + traces (only if a Tracer targets this compute) +## Compute — worker (Container) + health + 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? }`. The `loggerEnabled` / `tracerEnabled` +flags are **private** on `Compute` — flipped only by `enableLogging()` / +`enableTracing()` (which Logger/Tracer call), never settable from outside — and +the per-kind builders (`healthWidgets` / `loggingWidgets` / `tracingWidgets`) +are `protected`, so a caller cannot obtain log/trace widgets for a compute that +has no Logger/Tracer attached. + +**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. +- Presence (a Logger/Tracer *exists* for this compute) drives the section: `dashboardSection` includes `logging`/`tracing` only when the corresponding enable method was called. +- Encapsulation: sections can't be fabricated or bypassed — the flag and the infra move together through the enable methods (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; render the single default compute + +**Decision:** The dashboard exposes **no** `computes` option. It always renders `[this.compute]` — the app's default compute, the only one that exists today. The internal seam (`compute.dashboardSection(region)`) already supports multiple computes, but the public option is deferred. + +**Rationale:** +- `Compute` is `@internal` and not customer-instantiable, mirroring how Logger/Tracer expose no compute parameter. Exposing `computes?: Compute | Compute[]` would leak an internal type through a public API before customers can even construct a compute to pass. +- Nothing is lost today: there is exactly one compute, so `[this.compute]` is complete. +- When the multi-compute customer surface lands, the selector returns. The open question then is whether it should be an **explicit** `computes` list (caller names what appears; no construction-order dependency) or a **default-to-all** resolved at a finalize pass (like `finalizeConfigRegistry`, which enumerates `getComputes()` after the backend module fully imports). The explicit list is simpler and order-safe; default-to-all is the more "batteries-included" DX but must defer body assembly to finalize to avoid missing computes created after the dashboard. Decide with that surface, not ahead of it. + +### 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 +187,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:** +**When a Logger is attached to the compute:** 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 a Tracer is attached to the compute:** 8. **Traces** — X-Ray trace widget showing a list of recent traces ### Widget Layout @@ -113,13 +201,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 a Tracer is attached (X-Ray trace map) +Row N: [Recent Errors (24w, 6h)] ← only if a Logger is attached +Row N+1: [Log Volume (24w, 6h)] ← only if a Logger is attached +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, if only a Logger is attached (no metrics or tracing): ``` Row 0 (y=0): [Lambda Invocations (12w, 6h)] [Lambda Errors (12w, 6h)] @@ -213,50 +303,52 @@ Dashboard accepts observability BB instances as constructor parameters. This is ### 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. +Dashboard parameters use structural typing. Each `MetricsSource.metrics` accepts any object with a `namespace` property (the resolved CloudWatch namespace) and an optional `defaultDimensions` property. Logger/Tracer are not dashboard parameters — they attach to a compute (`enableLogging`/`enableTracing`), and the dashboard reads each compute's self-reported `dashboardSection`. This keeps the Dashboard BB decoupled from the observability BB classes. **Metrics namespace and dimensions resolution:** 1. `metrics.namespace` → used if metrics BB 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 -**Example (full BB composition):** +**Example (full observability):** ```typescript +new Logger(scope, 'logger'); // attaches to the compute → its logs section appears +new Tracer(scope, 'tracer'); // attaches to the compute → its traces section appears +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' }], + // computes omitted → the app's default compute + 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) │ │ │ -└──────────────┘ └──────────────┘ +┌──────────────┐ enableLogging() ┌──────────────┐ +│ Logger │ ────────────────► │ │ +└──────────────┘ │ Compute │ dashboardSection(region) +┌──────────────┐ enableTracing() │ (per unit) │ ──────────────────────────┐ +│ Tracer │ ────────────────► │ │ ▼ +└──────────────┘ └──────────────┘ ┌──────────────┐ + │ Dashboard │──► CloudWatch Dashboard (CDK) +┌──────────────┐ MetricsSource (namespace + configs) │ (CDK only) │──► CfnOutput (URL) +│ Metrics │ ─────────────────────────────────────────────────► │ │──► Optional API route +└──────────────┘ └──────────────┘ ``` -### What Dashboard Reads from Each BB +Logger/Tracer never talk to the Dashboard: they attach to their compute, and +the Dashboard asks each compute for its self-reported section. + +### 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** (per `computes` entry) | `dashboardSection(region)` → `{ label, health, logging?, tracing? }` | The compute's group: header, health widgets, plus logs/traces widgets when a Logger/Tracer is attached | +| **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 +390,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..3885a6fe8 100644 --- a/packages/bb-dashboard/README.md +++ b/packages/bb-dashboard/README.md @@ -22,43 +22,52 @@ npm install @aws-blocks/bb-dashboard ## Quick Start -### Minimal (Lambda Health Only) +### Minimal (default compute) ```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 section for the default compute. ``` ### With Observability BBs (Recommended) +The dashboard is organized **by compute** — it renders your app's compute as a +group. Logs and traces appear automatically when a `Logger` / `Tracer` is +attached — you do **not** pass those 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 Logger(scope, 'logs'); // → logs section +new Tracer(scope, 'tracing'); // → traces section 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' } }, - ], + // 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 when a `Logger` is attached to the compute (the compute self-reports via `loggerEnabled`); log group derived from that compute's own log group. +- **Traces** — shown when a `Tracer` is attached to the compute (`tracerEnabled`). +- **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). ## API Reference @@ -83,13 +92,16 @@ Creates a CloudWatch Dashboard with auto-generated widgets. ### `DashboardOptions` -#### Observability BB Composition +#### Observability composition + +Logs and traces are **not** options — they appear automatically when a `Logger` +/ `Tracer` is attached to the compute. You pass the app-scoped metrics here. +(There is no compute selector yet — the dashboard covers the app's default +compute; one arrives with the multi-compute customer surface.) | 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 +109,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 +140,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 a `Tracer` is attached to it | +| Recent Errors (Log Insights) | Log group | Per compute, when a `Logger` is attached to it | +| Log Volume | AWS/Logs | Per compute, when a `Logger` is attached to it | +| 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 @@ -161,8 +174,8 @@ const dashboard = new Dashboard(scope, 'dashboard', { ## 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: +When a `Logger` is attached to a compute, that compute's log section derives the +log group name from its Lambda function name using the standard pattern: ``` /aws/lambda/{functionName} @@ -217,10 +230,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..199599da6 100644 --- a/packages/bb-dashboard/src/index.aws.ts +++ b/packages/bb-dashboard/src/index.aws.ts @@ -18,6 +18,7 @@ export type { DashboardOptions, MetricConfig, MetricsBBRef, + MetricsSource, LoggerBBRef, TracerBBRef, } from './types.js'; diff --git a/packages/bb-dashboard/src/index.browser.ts b/packages/bb-dashboard/src/index.browser.ts index 127e385da..645e8b0eb 100644 --- a/packages/bb-dashboard/src/index.browser.ts +++ b/packages/bb-dashboard/src/index.browser.ts @@ -14,6 +14,7 @@ export type { DashboardOptions, MetricConfig, MetricsBBRef, + MetricsSource, LoggerBBRef, TracerBBRef, } from './types.js'; 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..f0a38a6dd --- /dev/null +++ b/packages/bb-dashboard/src/index.cdk.test.ts @@ -0,0 +1,127 @@ +// 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 a + * logs / traces section only when a Logger / Tracer is attached to that + * compute) 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 resolving `this.compute` and calling + * `compute.dashboardSection(region)`) end to end. + * + * A Logger / Tracer attaches to a compute purely by calling its public + * `enableLogging()` / `enableTracing()` seam (that is all the cdk Logger / + * Tracer constructs do to the compute). We drive that seam directly on the real + * cdk `LambdaCompute`: the bb-logger / bb-tracer packages export their 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 them here would resolve their local-mock variant and never touch + * the compute. Calling the seam directly is the faithful equivalent. + */ + +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 } 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 + traces sections when logging + tracing are enabled', async () => { + const stack = await makeStack('DashboardComputeFull'); + + // Attach a Logger + Tracer to the stack's default compute via the exact + // public seam their cdk constructs use. + const compute = stack._defaultCompute as LambdaCompute; + compute.enableLogging(); + 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 }); + + 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 (logging enabled)'); + assert.ok(body.includes('🔍 Traces'), 'body has the traces section (tracing enabled)'); + }); + + test('omits logs/traces sections when no Logger/Tracer is attached', async () => { + const stack = await makeStack('DashboardComputeBare'); + + new Dashboard(stack, 'dashboard', { routePath: false }); + + const body = dashboardBody(stack); + assert.ok(body.includes('🔧 DefaultCompute'), 'body still has the compute header (health always renders)'); + assert.ok(!body.includes('📋 Logs'), 'no logs section without a Logger'); + assert.ok(!body.includes('🔍 Traces'), 'no traces section without a Tracer'); + }); + + test('covers the app default compute (no compute selector is exposed yet)', async () => { + const stack = await makeStack('DashboardDefaultCompute'); + const compute = stack._defaultCompute as LambdaCompute; + compute.enableLogging(); + + // There's no `computes` option — the dashboard always renders the app's + // single default compute. A second compute isn't customer-reachable yet. + new Dashboard(stack, 'dashboard', { routePath: false }); + + 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 once logging is enabled'); + }); +}); diff --git a/packages/bb-dashboard/src/index.cdk.ts b/packages/bb-dashboard/src/index.cdk.ts index 5830ef193..461ae2ed8 100644 --- a/packages/bb-dashboard/src/index.cdk.ts +++ b/packages/bb-dashboard/src/index.cdk.ts @@ -1,21 +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, Scope } from '@aws-blocks/core/cdk'; 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, + LoggerBBRef, MetricConfig, MetricsBBRef, - LoggerBBRef, + MetricsSource, + ResolvedDashboardConfig, TracerBBRef, } from './types.js'; @@ -34,21 +35,26 @@ export type { * * @example * ```typescript - * // Minimal — Lambda health widgets only + * // Minimal — a health section for the app's compute. * const dashboard = new Dashboard(scope, 'dashboard'); * ``` * * @example * ```typescript - * // With observability BB composition + * // Logs/traces appear automatically when a Logger/Tracer is attached to the + * // compute — you don't pass them to the dashboard. Metrics are app-wide and + * // passed explicitly (one section per namespace), with their configs. + * new Logger(scope, 'logger'); // → logs section + * new Tracer(scope, 'tracer'); // → traces section + * const metrics = new Metrics(scope, 'metrics'); * const dashboard = new Dashboard(scope, 'dashboard', { - * logger, - * metrics, - * tracer, - * metricConfigs: [ - * { name: 'OrdersPlaced' }, - * { name: 'Latency', stat: 'p99', period: 300 }, - * ], + * metrics: { + * metrics, + * metricConfigs: [ + * { name: 'OrdersPlaced' }, + * { name: 'Latency', stat: 'p99', period: 300 }, + * ], + * }, * }); * ``` */ @@ -65,15 +71,19 @@ 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); + // The dashboard is organized by compute: it renders the app's compute + // (`this.compute`) as a group — health always, plus logs/traces only when + // a Logger/Tracer is attached to it (`dashboardSection` gates internally), + // so we can't render an empty section. Metrics are app-wide (rendered once + // per namespace). There is no customer-facing way to create additional + // computes yet, so the dashboard covers the single default compute; a + // `computes` selector arrives with the multi-compute customer surface. + const computeSections = [this.compute.dashboardSection(region)]; + const widgetRows = buildDashboardWidgets(computeSections, config, region); new CwDashboard(this, 'Resource', { dashboardName: config.dashboardName, diff --git a/packages/bb-dashboard/src/index.mock.ts b/packages/bb-dashboard/src/index.mock.ts index a5e3679d2..cee1f33fd 100644 --- a/packages/bb-dashboard/src/index.mock.ts +++ b/packages/bb-dashboard/src/index.mock.ts @@ -18,6 +18,7 @@ export type { DashboardOptions, MetricConfig, MetricsBBRef, + MetricsSource, LoggerBBRef, TracerBBRef, } from './types.js'; 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..525d72444 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 cloudwatch import + * below is `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, Logger, Tracer) satisfy these types via duck typing, while tests + * can pass minimal mock objects. */ // ── Observability BB structural interfaces ────────────────────────────────── @@ -31,6 +32,11 @@ export interface MetricsBBRef { * 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. + * + * @deprecated No longer consumed by {@link DashboardOptions}. The dashboard is + * organized by compute and renders a logs section automatically when a Logger + * is attached to a compute — you do not pass a Logger to the Dashboard. Kept + * only for backward compatibility; will be removed in a future major. */ export interface LoggerBBRef { readonly fullId: string; @@ -39,6 +45,11 @@ export interface LoggerBBRef { /** * Structural interface satisfied by `@aws-blocks/bb-tracer` instances. * Only requires `fullId` for identification. Presence implies tracing is active. + * + * @deprecated No longer consumed by {@link DashboardOptions}. The dashboard + * renders a traces section automatically when a Tracer is attached to a + * compute — you do not pass a Tracer to the Dashboard. Kept only for backward + * compatibility; will be removed in a future major. */ export interface TracerBBRef { readonly fullId: string; @@ -89,16 +100,52 @@ 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 renders your app's compute with a health section plus logs / + * traces sections automatically when a Logger / Tracer is attached to it (the + * compute self-reports this). You therefore do **not** pass Logger / Tracer + * instances here — attaching them is the signal. + * + * **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 { /** @@ -110,44 +157,25 @@ export interface DashboardOptions { // ── Observability BB composition ──────────────────────────────────────── /** - * Metrics Building Block instance (or any object with `namespace`). - * When provided, adds metric widgets using the BB's resolved CloudWatch namespace. - */ - metrics?: MetricsBBRef; - - /** - * Logger Building Block instance (or any object with `fullId`). - * When provided, adds log query widgets using the Lambda handler's log group. - */ - logger?: LoggerBBRef; - - /** - * Tracer Building Block instance (or any object with `fullId`). - * When provided, adds X-Ray trace widgets. - */ - tracer?: TracerBBRef; - - // ── Dashboard-specific config ────────────────────────────────────────── - - /** - * 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 and are rendered automatically for whichever computes have + * a Logger / Tracer attached — 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 +210,26 @@ 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 build time from whether each compute has a Logger / Tracer attached. */ 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..80a7cc3d5 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,54 @@ 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 a Tracer is attached to this compute. + 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 — present only when a Logger is attached to this compute. + 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. + for (const metrics of config.metrics) { + rows.push(sectionHeader(`## 📊 Metrics — ${metrics.namespace}`)); + rows.push(...buildMetricsWidgets(metrics.namespace, metrics.metricConfigs, region, metrics.defaultDimensions)); } return rows; @@ -371,46 +184,35 @@ 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. + * - **Log group**: derived from Lambda handler function name when Logger BB present * - **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/src/index.cdk.test.ts b/packages/bb-lambda-compute/src/index.cdk.test.ts index 14f42e0c4..4cde14025 100644 --- a/packages/bb-lambda-compute/src/index.cdk.test.ts +++ b/packages/bb-lambda-compute/src/index.cdk.test.ts @@ -17,7 +17,7 @@ import { fileURLToPath } from 'node:url'; import { type BlocksDefaults, BlocksPresets, Scope } from '@aws-blocks/core/cdk'; import { Compute } from '@aws-blocks/core/cdk/internal'; import * as cdk from 'aws-cdk-lib'; -import { Match, Template } from 'aws-cdk-lib/assertions'; +import { Annotations, Match, Template } from 'aws-cdk-lib/assertions'; import { Architecture } from 'aws-cdk-lib/aws-lambda'; import type { Construct } from 'constructs'; import { LambdaCompute } from './index.cdk.js'; @@ -251,13 +251,66 @@ describe('LambdaCompute handler log-group retention (defaults.logRetention)', () }); }); +// enableLogging(retention) reconfigures the compute's OWN single log group (the +// one the function writes to), enforcing last-wins + a synth warning on +// conflict. This is the seam bb-logger drives when a Logger passes an explicit +// retention; the compute owns whether a group already exists and the policy. +describe('LambdaCompute enableLogging(retention)', () => { + test('overrides the handler group retention without spawning a second group', () => { + const { stack, parent } = setup('LambdaComputeSetRetention', BlocksPresets.production); + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(30); + const template = Template.fromStack(stack); + template.hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); + // Still exactly one group — the override mutates the owned group. + template.resourceCountIs('AWS::Logs::LogGroup', 1); + }); + + test('last explicit retention wins across multiple calls', () => { + const { stack, parent } = setup('LambdaComputeRetentionLastWins', BlocksPresets.production); + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(14); + compute.enableLogging(30); + Template.fromStack(stack).hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 30 }); + }); + + test('conflicting retentions emit a synth warning (last wins, not silent)', () => { + const { stack, parent } = setup('LambdaComputeRetentionConflict', BlocksPresets.production); + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(14); + compute.enableLogging(30); + Annotations.fromStack(stack).hasWarning('*', Match.stringLikeRegexp('log retention set to 30')); + }); + + test('the same retention twice emits no conflict warning', () => { + const { stack, parent } = setup('LambdaComputeRetentionSame', BlocksPresets.production); + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(30); + compute.enableLogging(30); + Annotations.fromStack(stack).hasNoWarning('*', Match.stringLikeRegexp('log-retention-conflict|overriding')); + }); + + test('a bare enableLogging() leaves the stack-wide default retention untouched', () => { + const { stack, parent } = setup('LambdaComputeRetentionDefault', BlocksPresets.production); + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(); + // No explicit retention → the group keeps the production default (365). + Template.fromStack(stack).hasResourceProperties('AWS::Logs::LogGroup', { RetentionInDays: 365 }); + }); +}); + 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 +332,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 +396,97 @@ describe('LambdaCompute stage access logging (defaults.accessLogging)', () => { template.resourceCountIs('AWS::ApiGateway::Stage', 2); }); }); + +// Observability methods the Logger / Tracer / Dashboard blocks call on the +// resolved compute instead of poking a specific function. Under Option 1 +// retention is NOT set here: the compute owns one handler log group (created in +// its constructor with defaults.logRetention) and bb-logger reconfigures that +// group — so enableLogging() is presence-only. +describe('LambdaCompute observability', () => { + test('enableLogging() marks logging enabled so the Dashboard renders the logs section', () => { + const { parent } = setup('LambdaComputeLogEnabled'); + + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(); + + // Presence-based: no second LogGroup is provisioned (the constructor's + // handler group is the only one); the logs section simply renders. + assert.notStrictEqual(compute.dashboardSection('us-east-1').logging, undefined); + }); + + test('enableTracing turns on Active tracing and grants X-Ray publish on the shared role', () => { + 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' }, + }); + template.hasResourceProperties('AWS::IAM::Policy', { + PolicyDocument: { + Statement: Match.arrayWith([ + Match.objectLike({ + Action: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'], + Effect: 'Allow', + }), + ]), + }, + }); + }); + + 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 omits logs/traces until a Logger/Tracer is attached', () => { + const { parent } = setup('LambdaComputeGating'); + + const compute = new LambdaCompute(parent, 'extra'); + const section = compute.dashboardSection('us-east-1'); + assert.equal(section.logging, undefined, 'no logs section without a Logger'); + assert.equal(section.tracing, undefined, 'no traces section without a Tracer'); + }); + + test("dashboardSection.logging queries this compute's log group once a Logger is attached", () => { + const { parent } = setup('LambdaComputeLogWidgets'); + + const compute = new LambdaCompute(parent, 'extra'); + compute.enableLogging(); + 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..2dc65e78f 100644 --- a/packages/bb-lambda-compute/src/index.cdk.ts +++ b/packages/bb-lambda-compute/src/index.cdk.ts @@ -2,14 +2,23 @@ // 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 { PolicyStatement } from 'aws-cdk-lib/aws-iam'; +import type { CfnFunction } from 'aws-cdk-lib/aws-lambda'; 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 { type CfnLogGroup, LogGroup } from 'aws-cdk-lib/aws-logs'; import type { LambdaComputeProps } from './types.js'; +import { buildHealthWidgets, buildLoggingWidgets, buildTracingWidgets } from './widgets.js'; export type { LambdaComputeProps } from './types.js'; @@ -180,6 +189,52 @@ 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 applyLogRetention(retentionDays: number): void { + // `this.logGroup` is a concrete L2 LogGroup this compute created, so its + // defaultChild is always the CfnLogGroup — reconfigure retention on the + // one group the function already writes to rather than spawning a + // competing `/aws/lambda/` group. + (this.logGroup.node.defaultChild as CfnLogGroup).retentionInDays = retentionDays; + } + + protected applyTracing(): void { + (this.fn.node.defaultChild as CfnFunction).tracingConfig = { mode: 'Active' }; + this.executionRole.addToPrincipalPolicy( + new PolicyStatement({ + actions: ['xray:PutTraceSegments', 'xray:PutTelemetryRecords'], + resources: ['*'], + }), + ); + } + + protected healthWidgets(region: string): IWidget[][] { + return buildHealthWidgets(this.fn.functionName, region); + } + + protected loggingWidgets(region: string): IWidget[][] { + // Defense-in-depth: dashboardSection only calls this when logging is on, + // but guard anyway so the builder can never emit an empty/misleading + // log section for a compute with no Logger attached. + if (!this.isLoggerEnabled) { + throw new Error(`Compute "${this.id}": loggingWidgets requires a Logger — call enableLogging() first`); + } + // Query the compute's own log group (the one wired into the function), + // not the AWS default `/aws/lambda/` name — the function writes to + // `this.logGroup`, whose name CDK generates. + 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/widgets.ts b/packages/bb-lambda-compute/src/widgets.ts new file mode 100644 index 000000000..f8d803106 --- /dev/null +++ b/packages/bb-lambda-compute/src/widgets.ts @@ -0,0 +1,198 @@ +// Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. +// SPDX-License-Identifier: Apache-2.0 + +/** + * CloudWatch Dashboard health widgets for a Lambda-backed compute. + * + * A `LambdaCompute` self-reports these via `dashboardSection(region)` so the + * Dashboard Building Block can assemble a per-compute health section without + * knowing the compute is Lambda-shaped. + */ +import { Duration } from 'aws-cdk-lib'; +import { GraphWidget, LogQueryWidget, Metric, ConcreteWidget } from 'aws-cdk-lib/aws-cloudwatch'; +import type { IWidget } from 'aws-cdk-lib/aws-cloudwatch'; + +/** + * 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-logger/DESIGN.md b/packages/bb-logger/DESIGN.md index 2abedfd14..57df31d1d 100644 --- a/packages/bb-logger/DESIGN.md +++ b/packages/bb-logger/DESIGN.md @@ -8,20 +8,26 @@ 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. +Every compute owns a single CloudWatch Logs LogGroup for its handler (created by +the compute with retention from the stack-wide `defaults.logRetention`). Logger +owns **no** infrastructure — it targets the compute it resolves to and calls one +seam, `compute.enableLogging(options?.retention)`: + +- **Presence + retention (one call):** `enableLogging` always marks the compute + as having a Logger (so the per-compute Dashboard renders its logs section), and + when a `retention` is passed, the compute reconfigures **its own** log group + (via the L1 `CfnLogGroup.retentionInDays` escape hatch). The compute owns the + policy: because several Loggers can target one compute and all describe the + same group, the last explicit value wins and a synth warning is emitted on a + conflicting value. Targeting the resolved compute means a Logger attached to a + non-default compute reconfigures *that* compute's group, not always the + default one. - **When `retention` is omitted:** The group keeps the stack-wide - `defaults.logRetention` already applied by the BlocksStack/BlocksBackend. + `defaults.logRetention` the compute already applied — a bare Logger never + clobbers a retention another Logger set. - **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. + with the compute-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. diff --git a/packages/bb-logger/src/index.cdk.test.ts b/packages/bb-logger/src/index.cdk.test.ts index 939cfecda..68df6402c 100644 --- a/packages/bb-logger/src/index.cdk.test.ts +++ b/packages/bb-logger/src/index.cdk.test.ts @@ -4,101 +4,74 @@ /** * 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 infrastructure. It targets the compute it resolves to: it + * always calls `enableLogging()` (presence, so the per-compute Dashboard renders + * the logs section) and, only when an explicit `retention` is given, forwards it + * via `setLogRetention()`. The compute owns the actual log group and the + * last-wins + conflict-warning policy (covered in `@aws-blocks/bb-lambda-compute` + * tests), so here we assert only that Logger delegates correctly. */ -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 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'; +/** Records the observability-seam calls bb-logger makes on its resolved compute. */ +class SpyCompute { + /** One entry per `enableLogging` call — the retention arg it was passed. */ + enableLoggingArgs: Array = []; + enableLogging(retentionDays?: number): void { + this.enableLoggingArgs.push(retentionDays); + } +} + +// Minimal owner. Logger resolves `this.compute` to the root's `_defaultCompute` +// and reads `defaults`/`id` off the ambient stack; a spy compute is enough to +// observe the delegation without provisioning a real log group. 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): { 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 { parent, compute: stack._defaultCompute }; } -describe('Logger CDK retention', () => { - test('does not create a second (colliding) log group', () => { - const { stack, parent } = setup(); +describe('Logger CDK (delegates observability to the compute)', () => { + test('marks logging on the resolved compute with no retention when none is given', () => { + const { parent, compute } = 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); + assert.deepStrictEqual(compute.enableLoggingArgs, [undefined], 'enableLogging() called once, no retention'); }); - test('a bare Logger leaves the stack-wide default retention untouched (no clobber)', () => { - const { stack, parent } = setup(BlocksPresets.production); + test('a bare Logger enables logging with no retention (no clobber of the stack default)', () => { + const { parent, compute } = setup(); 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 }); + assert.deepStrictEqual(compute.enableLoggingArgs, [undefined]); }); - test('an explicit per-Logger retention overrides the shared group retention', () => { - const { stack, parent } = setup(BlocksPresets.production); + test('forwards an explicit retention to the compute', () => { + const { parent, compute } = setup(); 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('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 }); + assert.deepStrictEqual(compute.enableLoggingArgs, [30]); }); - test('two Loggers with conflicting explicit retention: last wins, with a synth warning', () => { - const { stack, parent } = setup(BlocksPresets.production); + test('two Loggers each forward their own value (compute enforces last-wins/conflict policy)', () => { + const { parent, compute } = setup(); 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')); + assert.deepStrictEqual(compute.enableLoggingArgs, [14, 30]); }); }); diff --git a/packages/bb-logger/src/index.cdk.ts b/packages/bb-logger/src/index.cdk.ts index 4358eba46..0779ea526 100644 --- a/packages/bb-logger/src/index.cdk.ts +++ b/packages/bb-logger/src/index.cdk.ts @@ -1,40 +1,27 @@ // 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 { registerConfig, 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, RetentionDays } 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. Sets the `LOG_LEVEL` environment variable when + * configured, and reconfigures the resolved compute's log group retention when + * an explicit `retention` is given. * - * 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 infrastructure. It targets the compute it resolves to and + * calls `enableLogging(options?.retention)` — the only observability seam it + * knows about. That marks the compute as having a Logger (so the per-compute + * Dashboard renders its logs section) and, when a `retention` is given, has the + * compute reconfigure its **own** single log group (created with the stack-wide + * `defaults.logRetention`) rather than spawning a second, competing group. The + * compute owns everything else — whether a group already exists, the last-wins + * behavior, and the synth conflict warning across multiple Loggers. */ export class Logger extends Scope { constructor(scope: ScopeParent, id: string, options?: LoggingOptions) { @@ -45,36 +32,9 @@ export class Logger extends Scope { 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; - } + // Signal to the resolved compute that a Logger is attached (so the + // Dashboard renders its logs section) and, when set, forward the desired + // retention. The compute owns whether/how to apply it. + this.compute.enableLogging(options?.retention); } } diff --git a/packages/bb-tracer/DESIGN.md b/packages/bb-tracer/DESIGN.md index f0c6e9b5e..481b45d35 100644 --- a/packages/bb-tracer/DESIGN.md +++ b/packages/bb-tracer/DESIGN.md @@ -35,13 +35,16 @@ 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: +Tracer is a **composite Building Block** — it creates no new AWS resources. It +**delegates to its resolved compute** by calling `this.compute.enableTracing()`, +so tracing targets whichever compute backs the block. For a Lambda compute that +turns on: -- **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. +- **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, `enableTracing()` is not called — no CDK mutations occur and the compute runs without active tracing. ## Mock Implementation diff --git a/packages/bb-tracer/src/index.cdk.ts b/packages/bb-tracer/src/index.cdk.ts index 72627f1ca..0049fe5ce 100644 --- a/packages/bb-tracer/src/index.cdk.ts +++ b/packages/bb-tracer/src/index.cdk.ts @@ -1,26 +1,20 @@ // 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 { 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'; 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: ['*'], - })); + // The compute owns marking itself traced + turning on active tracing; + // the Tracer only signals intent by calling enableTracing(). + this.compute.enableTracing(); } } } diff --git a/packages/blocks/API.md b/packages/blocks/API.md index a6ff0d936..c4947f042 100644 --- a/packages/blocks/API.md +++ b/packages/blocks/API.md @@ -133,6 +133,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'; @@ -530,6 +531,8 @@ export { MetricsErrors } export { MetricsOptions } +export { MetricsSource } + export { MetricUnit } export { MFAPreference } diff --git a/packages/blocks/src/index.cdk.ts b/packages/blocks/src/index.cdk.ts index e9857ce92..85d84c18c 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,144 @@ 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, + LoggerBBRef, + MetricConfig, + MetricsBBRef, + MetricsSource, + TracerBBRef, +} 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, RetentionDays } 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..e36a9484a 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,49 @@ 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'; - +export type { CronJobEvent, CronJobOptions } from '@aws-blocks/bb-cron-job'; /** - * **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'; - -/** - * **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, + LoggerBBRef, + MetricConfig, + MetricsBBRef, + MetricsSource, + TracerBBRef, +} 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. + * 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. * - * 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 +242,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 +256,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 +291,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 +316,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 +339,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, RetentionDays } 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 +395,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..0c4a3de1e 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,18 @@ class StubLambdaCompute extends Compute { setEnv(key: string, value: string): void { this.fn.addEnvironment(key, value); } + + protected applyLogRetention(_retentionDays: number): void {} + 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 +90,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 +248,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 +366,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-stack.test.ts b/packages/core/src/cdk/blocks-stack.test.ts index a11c8a53e..abc994141 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,18 @@ class StubLambdaCompute extends Compute { setEnv(key: string, value: string): void { this.fn.addEnvironment(key, value); } + + protected applyLogRetention(_retentionDays: number): void {} + 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 +85,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 +226,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..93d937caa 100644 --- a/packages/core/src/cdk/compute/compute.ts +++ b/packages/core/src/cdk/compute/compute.ts @@ -1,6 +1,8 @@ // Copyright Amazon.com, Inc. or its affiliates. All Rights Reserved. // SPDX-License-Identifier: Apache-2.0 +import { Annotations } from 'aws-cdk-lib'; +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'; @@ -30,6 +32,40 @@ export abstract class Compute extends Scope { */ readonly namespaces: string[] = []; + /** + * Whether a Logger targets this compute — flipped by {@link enableLogging}. + * Private so only the compute itself can set it (an observability BB signals + * intent by calling `enableLogging`, never by mutating this); read internally + * by {@link dashboardSection} to decide whether to render the logs section. + */ + private loggerEnabled = false; + + /** + * Whether a Tracer targets this compute — flipped by {@link enableTracing}. + * Private for the same reason as {@link loggerEnabled}. + */ + private tracerEnabled = false; + + /** + * The last explicit log retention (in days) written via {@link setLogRetention}. + * Tracked so a later, conflicting explicit value can warn about the silent + * last-wins (several Loggers can target one compute and all describe its one + * log group). Private — only {@link setLogRetention} sets it. + */ + private explicitLogRetentionDays?: number; + + /** Read-only view of {@link loggerEnabled} for subclasses (e.g. to guard their + * `loggingWidgets` builder). Subclasses can read but not set it. */ + protected get isLoggerEnabled(): boolean { + return this.loggerEnabled; + } + + /** 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, @@ -44,4 +80,135 @@ export abstract class Compute extends Scope { * directly so config targets the right compute. */ abstract setEnv(key: string, value: string): void; + + /** + * Attach a Logger to this compute: records that logs should be shown (so the + * Dashboard renders this compute's logs section) and, when an explicit + * `retentionDays` is given, sets the retention on the compute's single log + * group. Presence-based — the Logger Building Block calls this to signal + * intent, targeting the right compute without touching a specific function's + * log group. + * + * The Logger only knows about `enableLogging`; the compute owns the rest. A + * compute already owns one log group (created with the stack-wide + * `defaults.logRetention`), so no second group is spawned. The compute also + * owns the shared policy for `retentionDays`: because several Loggers can + * target one compute and all describe the same group, the **last** explicit + * value wins and a synth warning is emitted if a later call disagrees with an + * earlier one (so the clobber isn't silent). A bare `enableLogging()` (no + * `retentionDays`) leaves the group's retention untouched. + * + * @param retentionDays - Optional CloudWatch Logs retention, in days. When + * omitted, the group keeps the stack-wide `defaults.logRetention`. + */ + enableLogging(retentionDays?: number): void { + this.loggerEnabled = true; + if (retentionDays === undefined) return; + + if (this.explicitLogRetentionDays !== undefined && this.explicitLogRetentionDays !== retentionDays) { + Annotations.of(this).addWarningV2( + '@aws-blocks/core:log-retention-conflict', + `Compute "${this.id}": log retention set to ${retentionDays} day(s), overriding an earlier ` + + `explicit ${this.explicitLogRetentionDays} day(s) — all Loggers on a compute share its one ` + + 'log group, so the last value wins. Set a single explicit retention (or rely on the ' + + 'stack-wide `defaults.logRetention`) to avoid the ambiguity.', + ); + } + this.explicitLogRetentionDays = retentionDays; + this.applyLogRetention(retentionDays); + } + + /** + * Reconfigure this compute's log group retention to `retentionDays`. + * Implemented by the concrete compute (which owns the group); invoked only + * via {@link enableLogging} so the last-wins + conflict-warning policy always + * runs. `protected` so retention can't be changed without that policy. + */ + protected abstract applyLogRetention(retentionDays: number): void; + + /** + * Enable distributed tracing on this compute: record that traces should be + * shown (so the Dashboard renders this compute's traces section) and turn on + * the compute's active tracing via {@link applyTracing}. The Tracer Building + * Block calls this instead of poking a specific function so tracing targets + * the right compute. + */ + enableTracing(): void { + 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: its health widgets + * always, plus its log / trace widgets **only when** a Logger / Tracer is + * attached to this compute (via {@link enableLogging} / {@link enableTracing}). + * + * This is the single public entry the Dashboard Building Block uses; the + * per-kind builders below are `protected` so a caller cannot obtain log or + * trace widgets for a compute that has no logging / tracing enabled (which + * would render empty, misleading widgets). + * + * @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. (Not fullId, + // which is stack-prefixed and verbose.) + label: this.id, + health: this.healthWidgets(region), + logging: this.loggerEnabled ? this.loggingWidgets(region) : undefined, + tracing: this.tracerEnabled ? this.tracingWidgets(region) : undefined, + }; + } + + /** + * Build this compute's health widget rows (rows of `IWidget`). Implemented by + * a concrete compute; obtained only via {@link dashboardSection}. + * + * @param region - AWS region the widgets query metrics in. + */ + protected abstract healthWidgets(region: string): IWidget[][]; + + /** + * Build this compute's **log** widget rows (recent-errors query + log-volume + * graph) for its own log group. Gated behind {@link dashboardSection} so it + * is only used when a Logger is attached. + * + * @param region - AWS region the widgets query in. + */ + protected abstract loggingWidgets(region: string): IWidget[][]; + + /** + * Build this compute's **trace** widget rows (an X-Ray trace list filtered to + * this compute). Gated behind {@link dashboardSection} so it is only used + * when a Tracer is attached. + * + * @param region - AWS region the widget queries in. + */ + protected abstract tracingWidgets(region: string): IWidget[][]; +} + +/** + * A compute's self-reported CloudWatch Dashboard section. `health` is always + * present; `logging` / `tracing` are populated only when a Logger / Tracer is + * attached to the compute. + */ +export interface ComputeDashboardSection { + /** Display label used as the compute's group header. */ + label: string; + /** Health widget rows — always present. */ + health: IWidget[][]; + /** Log widget rows — present when a Logger is attached. */ + logging?: IWidget[][]; + /** Trace widget rows — present when a Tracer is attached. */ + tracing?: IWidget[][]; } diff --git a/packages/core/src/cdk/config-registry.test.ts b/packages/core/src/cdk/config-registry.test.ts index 2b4a96db8..9c2b1bacc 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,20 @@ 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 applyLogRetention(): void {} + 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/internal.ts b/packages/core/src/cdk/internal.ts index 5e554cc46..162395c92 100644 --- a/packages/core/src/cdk/internal.ts +++ b/packages/core/src/cdk/internal.ts @@ -24,6 +24,7 @@ */ export { Compute } from './compute/compute.js'; +export type { ComputeDashboardSection } 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.