---
name: finnair-node-lambda
description: Create Node.js Lambda functions with the Finnair `NodeLambda` construct from `@finnairoyj/cdk-constructs-lib/lambda`, or migrate existing CDK code that uses `NodejsFunction` (aws-cdk-lib/aws-lambda-nodejs) to it. Use when adding a new Node/TypeScript Lambda to a Finnair CDK project, when replacing `new NodejsFunction(...)` / hand-rolled log groups, roles or Datadog instrumentation, or when the user mentions NodeLambda, cdk-constructs Lambda or "migrate away from NodejsFunction".
---

# Finnair `NodeLambda` construct

`NodeLambda` wraps `NodejsFunction` and creates in one step:

- the function, named `<serviceName>-<functionName>-<environment>` (or exactly `functionName` with `forceFunctionName: true`)
- a dedicated CloudWatch `LogGroup` (`/aws/lambda/<serviceName>-<functionName>-<environment>`, or `/aws/lambda/<functionName>-logs` with `forceFunctionName`)
- a least-privilege execution role with `AWSLambdaBasicExecutionRole` and read access to the shared Secrets Manager secrets (`shared/*`) and their KMS key
- the env vars `ENVIRONMENT_NAME`, `ENV`, `DD_GIT_COMMIT_SHA` and `DD_VERSION` (both from `process.env.COMMIT_HASH_ID`)
- `Name` tags, cdk-nag suppressions and, optionally, Datadog instrumentation (`datadogEnabled: true`)

Full prop reference: https://cdk-constructs-lib-docs.common.tools.app.finnair.com/interfaces/cdk-modules_lambda.NodeLambdaProps.html

## Setup in the consuming project

1. Install the library with the package manager the project already uses. Never switch package managers, and never create a second lockfile.
   - To find the package manager, check the `packageManager` field in the root `package.json` first. If there isn't one, look at which lockfile exists:

     | Evidence                                         | Package manager | Install command                              |
     | ------------------------------------------------ | --------------- | -------------------------------------------- |
     | `packageManager: "pnpm@…"` or `pnpm-lock.yaml`   | pnpm            | `pnpm add @finnairoyj/cdk-constructs-lib`    |
     | `packageManager: "yarn@…"` or `yarn.lock`        | yarn            | `yarn add @finnairoyj/cdk-constructs-lib`    |
     | `packageManager: "npm@…"` or `package-lock.json` | npm             | `npm install @finnairoyj/cdk-constructs-lib` |

     If the evidence conflicts (e.g. several lockfiles), ask the user.

   - Add it to `dependencies`, not `devDependencies`, because it is used at synth time.
   - Follow the project's version style. If the other dependencies are pinned to exact versions, pin this one too (`pnpm add -E`, `yarn add -E`, `npm install -E`).
   - In a monorepo, add it to the workspace package that contains the CDK code (`pnpm add --filter <pkg> …`, `yarn workspace <pkg> add …`, `npm install -w <pkg> …`).
   - The library is published to Finnair Artifactory, so `@finnairoyj` must resolve to `https://finnair.jfrog.io/artifactory/api/npm/finnair-npm/`. npm and pnpm read this from `.npmrc`. Yarn 2+ reads it from `npmScopes` in `.yarnrc.yml`. If the install fails with a 404 or an auth error, tell the user to configure the registry access themselves. Don't write credentials or tokens.

2. Peer dependencies (`aws-cdk-lib`, `constructs`, `cdk-nag`). Read the required ranges from `peerDependencies` in `node_modules/@finnairoyj/cdk-constructs-lib/package.json`. Don't rely on versions written in docs, because they change between releases.
   - **Don't add a peer that isn't listed in the project's `package.json`.** The package manager is expected to install it from the library's `peerDependencies`, which keeps the version range in one place.
   - **Keep the peers that the project already lists, and bring them into range.** CDK projects normally list `aws-cdk-lib` and `constructs` themselves. If a listed version doesn't satisfy the peer range, update it with the project's package manager (e.g. `pnpm add aws-cdk-lib@<version>`), keeping the project's version style (exact pin or caret) and its dependency section. Use the lowest version that satisfies the range, unless the project's other dependencies require a newer one.
     - Tell the user which entries you updated (old → new).
     - Point out a major-version bump, such as `cdk-nag` 2 → 3: it can bring breaking changes and needs extra testing.
     - If an update conflicts with other dependencies, stop and ask the user.
   - **Check that the peers resolve after installing.** Read the install output for missing or unmet peer dependency warnings. Then confirm each peer resolves, e.g. `cdk-nag` exists in `node_modules` or appears in the output of `<pm> why cdk-nag`.
   - Not every package manager installs peers automatically. npm 7+ does, and pnpm 8+ does by default (`auto-install-peers`). Yarn (both 1.x and 2+) and pnpm with `auto-install-peers=false` only warn.
   - **If a peer that isn't listed in the project is missing after installing, tell the user and stop.** Name the peer and its required range, and let the user decide how to fix it.
3. Import from the `lambda` subpath (not the package root):

   ```ts
   import { NodeLambda, LAMBDA_BUNDLING_OPTS_NODEJS_ESM } from '@finnairoyj/cdk-constructs-lib/lambda'
   ```

   If TypeScript cannot resolve the subpath, the project's `moduleResolution` needs to support package `exports` (`node16`, `nodenext` or `bundler`).

   **If you change `moduleResolution` (or `module` alongside it), always tell the user explicitly.** Say what you changed, in which `tsconfig`, and what it might affect. The change applies to the whole project, not only this import. Possible effects:
   - **`module` has to change with it.** `node16`/`nodenext` need `module` set to the same value. `bundler` needs `module` set to `es2015` or later, or `preserve`, so it doesn't work with `module: commonjs`. Changing `module` can change the emitted JS and whether files are treated as CommonJS or ESM (driven by `"type"` in `package.json`).
   - **Relative imports need file extensions.** Under `node16`/`nodenext`, relative imports in ESM files need explicit extensions (`./foo.js`), which can produce many new type errors.
   - **Other packages resolve differently.** Package `exports` is now enforced for every dependency, so deep imports such as `some-pkg/lib/internal` can stop resolving. Type declarations may also be picked from different `exports` conditions.
   - **Other tooling is affected.** The change reaches everything that reads the `tsconfig`: `tsc` builds, `ts-node`/`tsx` used by `cdk synth`, Jest/Vitest transforms, ESLint's TypeScript parser, and `paths` aliases.

   Run the project's type-check, tests and `cdk synth` after the change and report the results. If the change causes widespread breakage, stop and ask the user how to proceed rather than fixing large amounts of unrelated code.

## Props

Required: `functionName`, `serviceName`, `environment` (`'dev' | 'test' | 'preprod' | 'prod' | 'tools' | 'sandbox'`), `entry`.

These rules are checked with zod at synth time, so fix a failed check before synthesizing again:

- `functionName`: 3–40 chars, `[a-zA-Z0-9-]` only (this applies to the full name when `forceFunctionName` is set)
- `serviceName`: 3–20 chars, `[a-zA-Z0-9-]` only
- `entry`: must end in `.ts` or `.js` (`.mts`/`.mjs`/`.cjs` are rejected)
- `timeout`: 1 s – 15 min; `memorySize`: 128–10240; `retryAttempts`: 0–2

Defaults that differ from a plain `NodejsFunction`:

| Prop                    | NodeLambda default                                           | Plain NodejsFunction default  |
| ----------------------- | ------------------------------------------------------------ | ----------------------------- |
| `runtime`               | `Runtime.NODEJS_24_X`                                        | latest Node runtime in CDK    |
| `architecture`          | `Architecture.ARM_64`                                        | `X86_64`                      |
| `memorySize`            | `256`                                                        | `128`                         |
| `timeout`               | `Duration.seconds(30)`                                       | 3 s                           |
| `loggingFormat`         | `LoggingFormat.JSON`                                         | TEXT                          |
| `systemLogLevelV2`      | `WARN`                                                       | —                             |
| `applicationLogLevelV2` | `INFO`                                                       | —                             |
| log group               | own group, `INFREQUENT_ACCESS` class, `SIX_MONTHS`, `RETAIN` | Lambda-created, never expires |
| `handler`               | `handlerName: 'handler'`                                     | `handler`                     |

### Which value to use when migrating

For every prop, go down this list and use the first one that applies:

1. **The value the existing code sets explicitly**, e.g. `memorySize: 512`, `timeout: Duration.seconds(10)`, `architecture: Architecture.X86_64`, retention or log class. Carry it over unchanged so the function behaves the same after the migration. Omit the prop only if the old value equals the NodeLambda default.
2. **Otherwise, the NodeLambda default.** Leave the prop out and let the construct decide.

If step 2 applies to a prop that the old code left to the plain `NodejsFunction` default, and that default differs (see the table above), behaviour changes. Common cases are timeout 3 s → 30 s, memory 128 → 256, `X86_64` → `ARM_64`, and text → JSON logs. List each of these changes for the user. Call out `X86_64` → `ARM_64` in particular, because native dependencies or binaries built for x86 break on ARM.

**Datadog layer versions are the only exception.** One of the main benefits of `NodeLambda` is that the library keeps the Datadog layers up to date centrally:

- Use `datadogEnabled: true`. **Never copy `nodeLayerVersion` or `extensionLayerVersion` from the old code.** Always leave them out so the library defaults are used, even if the old versions were newer or older.
- Compare the old layer versions with the library defaults. To get the defaults, read the installed package, e.g. search `node_modules/@finnairoyj/cdk-constructs-lib/dist/esm/lambda.mjs` for `LayerVersion ??`. Don't rely on version numbers written in docs, because they go out of date. **If a version changes, tell the user** the old and new versions for both the Node layer and the Extension layer. A layer upgrade can change tracing/APM behaviour and runtime compatibility.
- All other Datadog settings follow the same rule as other props. An old explicit value (e.g. `service`, `site`, `enableDatadogTracing`, `flushMetricsToLogs`, `captureLambdaPayload`, `sourceCodeIntegration`, `enableDatadogLogs`) is carried over into `datadogConfig`, unless it equals the `DatadogConfig` default. A setting the old code didn't set falls back to the `DatadogConfig` default. If that default differs from the old behaviour, tell the user.

Prop names that differ from `NodejsFunction`: `environment` is the **Finnair environment**. Lambda env vars go in `environmentVariables`, and the handler export goes in `handlerName`. Pass extra IAM through `lambdaExecutionRoleAdditionalPermissions` (`PolicyStatement[]`) and `managedPolicies` (`IManagedPolicy[]`).

These props are passed straight through to the function: `bundling`, `vpc`, `vpcSubnets`, `securityGroups`, `layers`, `tracing`, `description`, `currentVersionOptions`, `ephemeralStorageSize`, `filesystem`, `reservedConcurrentExecutions`, `retryAttempts`. Props that are not listed (e.g. `deadLetterQueue`, `events`, `insightsVersion`, `paramsAndSecrets`, `initialPolicy`, `role`) are **not supported**. Tell the user when a migration needs one of these instead of dropping it silently.

Public members: only `lambdaFunction` (the `NodejsFunction`) is public. To get the role or log group, use `lambdaFunction.role` / `lambdaFunction.logGroup`. Pass `x.lambdaFunction` wherever an `IFunction` is needed (API Gateway integrations, event targets, `grantInvoke`, etc.).

## The execution role is immutable. Read this before writing IAM code

The construct attaches its role with `.withoutPolicyUpdates()`. **Any attempt to add to the role after construction is silently dropped**: no error, no warning, and the permission is missing at runtime. This covers:

- `resource.grantX(fn.lambdaFunction)` (e.g. `table.grantReadWriteData(...)`, `bucket.grantRead(...)`, `secret.grantRead(...)`, `queue.grantSendMessages(...)`, `key.grantDecrypt(...)`)
- `fn.lambdaFunction.addToRolePolicy(...)` / `fn.lambdaFunction.role?.addManagedPolicy(...)`
- permissions that CDK adds implicitly on the role:
  - **event sources** (`addEventSource(new SqsEventSource(q))`, DynamoDB/Kinesis streams): the event source mapping is created, but the role has no read permissions
  - **VPC**: `AWSLambdaVPCAccessExecutionRole` is **not** attached. Add `managedPolicies: [ManagedPolicy.fromAwsManagedPolicyName('service-role/AWSLambdaVPCAccessExecutionRole')]`
  - **X-Ray** (`tracing: Tracing.ACTIVE`): `xray:PutTraceSegments` / `xray:PutTelemetryRecords` are not added. Add them yourself, or add the `AWSXRayDaemonWriteAccess` managed policy

Always declare permissions up front with `lambdaExecutionRoleAdditionalPermissions` / `managedPolicies`, and scope them to the specific resource ARNs. If a resource is KMS-encrypted with a customer key, include `kms:Decrypt` (and `kms:GenerateDataKey` for writes) on that key. You don't need to add anything for secrets under `shared/*` or the Datadog API key, because the built-in policy already covers them.

These still work because they don't touch the role: `fn.lambdaFunction.addPermission(...)`, `fn.lambdaFunction.grantInvoke(otherPrincipal)`, `addEventSource` (for the mapping itself), and resource-based policies on other resources.

## A) Creating a new Lambda

```ts
import path from 'node:path'
import { Duration } from 'aws-cdk-lib'
import { Effect, PolicyStatement } from 'aws-cdk-lib/aws-iam'
import { NodeLambda, LAMBDA_BUNDLING_OPTS_NODEJS_ESM } from '@finnairoyj/cdk-constructs-lib/lambda'

const orders = new NodeLambda(this, 'OrdersLambda', {
  serviceName: 'orders-api', // keep in one shared constant per service
  functionName: 'get-orders', // → orders-api-get-orders-<env>
  environment: props.environment, // FinnairEnv
  entry: path.resolve(import.meta.dirname, '../../src/handlers/get-orders.ts'),
  timeout: Duration.seconds(10),
  memorySize: 512,
  bundling: LAMBDA_BUNDLING_OPTS_NODEJS_ESM,
  environmentVariables: {
    TABLE_NAME: table.tableName,
  },
  lambdaExecutionRoleAdditionalPermissions: [
    new PolicyStatement({
      effect: Effect.ALLOW,
      actions: ['dynamodb:GetItem', 'dynamodb:Query'],
      resources: [table.tableArn, `${table.tableArn}/index/*`],
    }),
  ],
  datadogEnabled: true,
})

api.root.addResource('orders').addMethod('GET', new LambdaIntegration(orders.lambdaFunction))
```

Guidelines:

- Let the construct compose the name, and don't use `forceFunctionName` for new functions. Don't put the environment into `functionName`, because the construct appends it.
- Choose bundling:
  - `LAMBDA_BUNDLING_OPTS_NODEJS_ESM` produces ESM output and uses the AWS SDK from the runtime. Use it by default for ESM projects.
  - `LAMBDA_BUNDLING_OPTS_NODEJS_ESM_BUNDLE_SDK` produces ESM output with a pinned SDK version bundled in.
  - For CommonJS projects, or projects that need a custom `tsconfig`/`sourceMapMode`, pass their own `BundlingOptions`. You can spread a preset and override fields: `{ ...LAMBDA_BUNDLING_OPTS_NODEJS_ESM, tsconfig: '...' }`.
  - Use `import.meta.dirname` in ESM projects and `__dirname` in CJS projects, matching the surrounding code.
- Only set props whose values differ from the defaults.
- For log metric filters, subscription filters or Live Tail, set `logGroupClass: LogGroupClass.STANDARD`. The `INFREQUENT_ACCESS` class doesn't support those features, and a log group's class can't be changed after creation.
- Datadog: `datadogEnabled: true` is enough. It uses the shared Datadog API key secret, site `datadoghq.eu`, `env` = `environment` and `service` = `serviceName`. Override these through `datadogConfig` (e.g. `{ service: 'x', enableDatadogLogs: true, captureLambdaPayload: true }`). For Datadog-only logs, combine `datadogConfig.enableDatadogLogs: true` with the `NodeLambda` prop `disableCloudWatchLogs: true` (denies CloudWatch log writes in the role). Synth fails if `disableCloudWatchLogs` is set without Datadog log forwarding, or together with `flushMetricsToLogs`. Don't also add a `DatadogLambda` for the same function.
- Add or extend a CDK unit/snapshot test for the stack, and run the project's lint, tests and `cdk synth`.

## B) Migrating from `NodejsFunction`

A reference migration is commit `8251217` on the `dgplat-150-cdk-construct-for-lambda` branch of `FinnairOyj/finnair-com-service-config`. It replaced a `createNodeJsFunction` helper (own `LogGroup` plus `NodejsFunction` plus a shared `instrumentWithDD` util) with three `NodeLambda`s.

### 1. Inventory first

Find every `NodejsFunction` (and helper wrappers around it) and record the following for each one. **Ask the user when the right choice isn't clear from the code.**

- construct id, scope and `functionName` (the deployed name). Also check whether anything outside the stack refers to the name: other stacks, Datadog monitors, dashboards, alarms, runbooks.
- explicit `LogGroup` (name, retention, removal policy) or `logRetention`; any metric/subscription filters on it
- IAM: `initialPolicy`, `role`, `addToRolePolicy`, and every `grant*(fn)` call anywhere in the codebase (search for the variable name)
- `vpc`, `tracing`, `addEventSource`, `deadLetterQueue`, `events`, layers, Lambda Insights
- env vars, bundling options, runtime, architecture, memory, timeout, handler
- Datadog: `DatadogLambda` / `addLambdaFunctions` usage and its settings (service, API key source, logs, payload capture, Node and Extension layer versions)
- a `Deny` on `logs:CreateLogStream` / `logs:PutLogEvents` (or `datadog-ci lambda cloudwatch disable`) that stops CloudWatch logs

### 2. Rewrite each function

**Always give the `NodeLambda` the same construct id and the same scope that the old `NodejsFunction` had.** For example, `new NodejsFunction(this, 'ConfigFeatureLambda', …)` becomes `new NodeLambda(this, 'ConfigFeatureLambda', …)`.

The construct creates the function under the child id `Default`, which CDK leaves out of logical IDs, and the role under `ServiceRole`. With the same id and scope, the Lambda function and its role keep their CloudFormation logical IDs and are **updated in place**.

If the id or scope changes, the logical ID changes too, and CloudFormation treats the function as a new resource. It **creates the new resource first and deletes the old one afterwards**. The creation fails because the old function still holds that function name, so the deployment fails and rolls back. This always happens when the function name is kept, e.g. with `forceFunctionName: true`.

If the old code builds functions through a helper that receives the id (e.g. `createNodeJsFunction(this, 'ConfigFeatureLambda', …)`), use the id passed to the helper, not an id built inside it. Don't rename, restructure or nest these constructs as part of the migration. If the user wants to change an id anyway, warn them about this failure first.

Map the props (`environment` → `environmentVariables`, `handler` → `handlerName`, the phase → `environment`) and add `serviceName`.

**Keep existing function names (usually recommended).** A name change replaces the function, which breaks anything that refers to it by name. To keep the current name, pass it as-is and set `forceFunctionName: true`:

```ts
new NodeLambda(this, 'ConfigFeatureLambda', {
  // same construct id as the old NodejsFunction
  environment: props.PHASE,
  serviceName,
  functionName: `config-feature-${props.PHASE}`, // unchanged deployed name
  forceFunctionName: true,
  entry: path.resolve(__dirname, '../../../src/api/config.ts'),
  environmentVariables: getDefaultEnvironmentVariables({ SECRETS_NAME: secret.secretName }),
  memorySize: 512,
  bundling: bundlingOptions, // keep the project's existing bundling
  logGroupClass: LogGroupClass.STANDARD, // keep the old log class behaviour
  lambdaExecutionRoleAdditionalPermissions: [
    new PolicyStatement({
      // was config.addToRolePolicy(...)
      actions: ['secretsmanager:GetSecretValue'],
      effect: Effect.ALLOW,
      resources: [secret.secretArn],
    }),
  ],
  datadogEnabled: true,
})
```

The 40-character / `[a-zA-Z0-9-]` rule also applies to forced names. If an existing name breaks it, it can't be kept, so tell the user.

Also do the following:

- **Prop values:** follow [Which value to use when migrating](#which-value-to-use-when-migrating). Carry over explicit old values, fall back to NodeLambda defaults, and note every behaviour change caused by the fallback.
- **Env vars:** remove `ENVIRONMENT_NAME`, `ENV`, `DD_GIT_COMMIT_SHA` and `DD_VERSION` from the project's env vars, because the construct injects them. Keep everything else, e.g. `DD_SERVICE_MAPPING`, `DD_GIT_REPOSITORY_URL`, `AWS_XRAY_CONTEXT_MISSING`. `environmentVariables` wins over the built-ins if a key collides.
- **IAM:** convert every `addToRolePolicy`, `initialPolicy` and `grant*(fn)` into `lambdaExecutionRoleAdditionalPermissions` / `managedPolicies`, and add the VPC, X-Ray and event-source permissions explained above. Delete the old grant calls, because they would be silent no-ops.
- **Usages:** replace references to the old function variable with `x.lambdaFunction` (integrations, `grantInvoke`, event targets, outputs).
- **Datadog:** delete the project's own `DatadogLambda` / `instrumentWithDD` helper and set `datadogEnabled: true` on each function. Never pass the layer versions, and report any layer version change. Carry the other old Datadog settings into `datadogConfig` following the precedence rule above. Each `NodeLambda` instruments itself. The API key now comes from the shared Secrets Manager secret instead of an SSM parameter such as `/fcom/datadog-api-key`. Remove `datadog-cdk-constructs-v2` from the project only if nothing else uses it.
- **CloudWatch logs disabled by hand:** replace the old `Deny` policy with `disableCloudWatchLogs: true` instead of carrying it over in `lambdaExecutionRoleAdditionalPermissions`.
- Delete helpers, utils, imports and constants that are no longer used.

### 3. Log groups (the most common deployment failure)

The construct always creates a **new** `LogGroup` resource with a new logical ID, so the old log group leaves the template.

- If the old log group was an explicit `LogGroup` with the default `RETAIN`, it is orphaned and kept in the account. With `DESTROY`, it is deleted along with its logs. Tell the user which one applies.
- **Name collisions:** CloudFormation fails with "already exists" if a log group with the new name already exists in the account. This happens when:
  - without `forceFunctionName`, the new name is `/aws/lambda/<service>-<fn>-<env>`, and the function already had that name, so Lambda had auto-created that group, or an old retained group has it
  - with `forceFunctionName`, the new name is `/aws/lambda/<functionName>-logs`, which normally doesn't collide (this is why the reference migration uses it)

  If there is a collision, the user must delete or rename the existing group before deploying, or pick a different name.

- Move any metric filters or subscription filters to `fn.lambdaFunction.logGroup`, and use `LogGroupClass.STANDARD` for them.
- Log queries, dashboards and alerts that use the old log group name need updating.

### 4. Verify

1. Run the project's type-check, lint and unit tests. Update snapshots, then **review the snapshot diff**. These changes are expected:
   - new `<Id>LogGroup…` resources, and the old log groups removed
   - the role's `…ServiceRoleDefaultPolicy…` removed, with its statements moved into the role's inline `LambdaExecutionRolePolicy`, plus the shared-secret/KMS statements
   - Datadog SSM parameter references replaced by the shared secret ARN
   - new `Name` / `cdk-constructs-lib` tags and cdk-nag metadata
   - changed env vars

   The `AWS::Lambda::Function` and `AWS::IAM::Role` logical IDs must be exactly the same as before. If one has been removed and a new one added, the construct id or scope is wrong. Fix it before doing anything else (see step 2). Treat any other change as a bug to explain or fix.

2. Run `cdk synth`, and ideally `cdk diff` against a deployed dev stack. Confirm that functions and roles show as modified, not replaced, and that no permission you expected has disappeared.
3. Summarize the result for the user:
   - the functions that were migrated
   - any names or behaviour that changed, including props that fell back to NodeLambda defaults
   - Datadog layer version changes (old → new) and the `datadogConfig` settings carried over from the old code
   - old log groups that will be orphaned or deleted
   - permissions that were converted
   - anything that couldn't be migrated (unsupported props)
