CloudWatchLogsMatcher
CloudWatchLogsMatcher routes a CloudWatch Logs subscription delivery to a handler.
A subscription filter forwards matching log events to your Lambda as a gzipped, base64 payload. The matcher decodes it into log events and their metadata, then works out which of your routes should handle it by log group, subscription filter or message type.
Install
npm install @latcher/base @latcher/cloudwatch@latcher/base is a peer dependency, so install it yourself. It exports Latcher, which every matcher plugs into.
Create the matcher
import { createCloudWatchLogsMatcher } from '@latcher/cloudwatch'
import { logInvocation } from './middleware/logInvocation'
const cloudWatchLogsMatcher = createCloudWatchLogsMatcher({
middleware: [logInvocation], // Optional
})middleware is the only option and it can be left out. createCloudWatchLogsMatcher() on its own gives you a matcher with no shared middleware.
Options
| Option | Type | Required | Default | Description |
|---|---|---|---|---|
middleware | CloudWatchLogsMiddleware[] | No | [] | Runs for every delivery this matcher handles, before any route middleware. See Middleware |
Register routes
cloudWatchLogsMatcher.route({
filters: {
logGroup: '/aws/lambda/my-function',
messageType: 'DATA_MESSAGE',
},
middleware: [withRequestContext], // Optional
handler: processLogs,
})filters and handler are required, though filters can be an empty object to match every delivery.
route() returns the matcher, so you can chain registrations.
cloudWatchLogsMatcher.route(lambdaLogsRoute).route(ecsLogsRoute)Routes are ranked by how specific they are. Where two overlap, the one matching only a subset of the other is tried first, whatever order you registered them in. Registration order decides the rest, so give each route filters no other route can match. See match order for how ranking works and what it cannot settle.
A subscription carries decoded log data whose shape CloudWatch fixes rather than a payload you control, so there is no schema to attach and nothing to validate. Match on the log group, the subscription filter or the message type instead.
A delivery that matches no route throws, which fails the whole invocation. Register a route with empty filters as a catch-all if you would rather swallow log groups you do not recognise, and see nothing matched for what the other matchers do instead.
Convenience methods
dataMessage() and controlMessage() set the messageType filter for you, so you pass everything except that key. The two calls below register the same route.
cloudWatchLogsMatcher.dataMessage({
filters: { logGroup: '/aws/lambda/my-function' },
handler: processLogs,
})
cloudWatchLogsMatcher.route({
filters: { logGroup: '/aws/lambda/my-function', messageType: 'DATA_MESSAGE' },
handler: processLogs,
})| Method | Sets | Takes |
|---|---|---|
dataMessage() | messageType: 'DATA_MESSAGE' | The filters without messageType, plus middleware and handler |
controlMessage() | messageType: 'CONTROL_MESSAGE' | The same |
Each drops the messageType key from the filters you pass and sets it itself, so putting a messageType in the object is a type error. See convenience methods for how the other matchers use them.
Note: a subscription filter with a Lambda destination only delivers DATA_MESSAGE. A controlMessage() route therefore only runs if something else invokes the function with that payload.
Filters
Every filter key on one route, showing each form a value can take. All of them are optional, so set the ones that pick out the deliveries you want and leave the rest off.
cloudWatchLogsMatcher.route({
filters: {
logGroup: ['/aws/lambda/my-function', '/aws/lambda/other-function'], // Or a pattern: /^\/aws\/lambda\//
subscriptionFilter: 'error-alerts',
messageType: ['DATA_MESSAGE', 'CONTROL_MESSAGE'],
custom: ({ logEvents }) => logEvents.some((event) => event.message.includes('ERROR')),
},
handler: processLogs,
})| Filter | Type | Description |
|---|---|---|
logGroup | FilterStringMatcher | Matches against the delivery's log group name |
subscriptionFilter | FilterStringMatcher | Matches if any of the delivery's subscription filter names match. The record field is subscriptionFilters, an array, and this key matches when one of them does |
messageType | 'DATA_MESSAGE' | 'CONTROL_MESSAGE' or an array of either | Matches the message type CloudWatch sends |
custom | (input: CloudWatchLogsDecodedData) => boolean | Promise<boolean> | Anything the other filters cannot express. Can be async |
FilterStringMatcher is string | RegExp | Array<string | RegExp>. See filters for how each form matches, including the * wildcard.
Only a custom reaches the log events and the account owner. It is handed the full decoded data, so it is where you match on logEvents, logStream or owner, which no dedicated key covers. See custom for where it sits in the filter order.
Handler
Handlers take one argument and return nothing.
import { logger } from '@latcher/base'
import type { CloudWatchLogsRequest } from '@latcher/cloudwatch'
export async function processLogs(request: CloudWatchLogsRequest): Promise<void> {
logger.info(`${request.logEvents.length} events from ${request.logGroup}`)
}Request object
The request is the decoded log data plus the raw event and the Lambda context.
| Field | Type | Description |
|---|---|---|
logEvents | CloudWatchLogsLogEvent[] | The decoded log events, each with id, timestamp and message |
logGroup | string | The log group the events came from |
logStream | string | The log stream within that group |
subscriptionFilters | string[] | The subscription filter names that matched this delivery |
messageType | string | DATA_MESSAGE for log data, CONTROL_MESSAGE for the reachability check |
owner | string | The AWS account ID the log group belongs to |
event | CloudWatchLogsEvent | The untouched event from AWS, holding the still-encoded awslogs.data |
context | Context | The Lambda context |
CloudWatchLogsEvent, CloudWatchLogsLogEvent and Context come from aws-lambda, not from this package.
Response type
Handlers return Promise<void>. A log delivery has nothing useful to send back, so the matcher hands nothing to Lambda. There is no CloudWatchLogsResponse type; the handler signature returns Promise<void> directly.
Throwing is how you signal failure. See Failures and retries for what that does.
Inferred handlers
This matcher has no schemas, so inference is not about typing a body. defineRoute hands your handler a fully typed CloudWatchLogsRequest without you importing or naming it.
import { logger } from '@latcher/base'
import { defineRoute } from '@latcher/cloudwatch'
export const processLogsRoute = defineRoute({
filters: { logGroup: '/aws/lambda/my-function', messageType: 'DATA_MESSAGE' },
}).handle(async ({ logGroup, logStream, logEvents }) => {
logger.info(`${logEvents.length} events from ${logGroup}/${logStream}`)
})
cloudWatchLogsMatcher.route(processLogsRoute)Not knowing the request shape pays off most in a Lambda taking several event sources, since you never have to look any of them up. See inferred handlers, where the same source is written both ways to compare.
Annotated handlers
Annotating the request yourself splits route setup from business logic. The request type takes no generic parameters, so the annotation is just CloudWatchLogsRequest.
// handlers/processLogs.ts
import { logger } from '@latcher/base'
import type { CloudWatchLogsRequest } from '@latcher/cloudwatch'
export async function processLogs(request: CloudWatchLogsRequest): Promise<void> {
logger.info(`${request.logEvents.length} events from ${request.logGroup}`)
}// cloudwatch.ts
import { createCloudWatchLogsMatcher } from '@latcher/cloudwatch'
import { processLogs } from './handlers/processLogs'
const cloudWatchLogsMatcher = createCloudWatchLogsMatcher()
cloudWatchLogsMatcher.dataMessage({
filters: { logGroup: '/aws/lambda/my-function' },
handler: processLogs,
})With no schema and no generic parameters, an annotated handler receives exactly what an inferred one does, and the same function assigns through route(), dataMessage() and controlMessage() alike. See annotated handlers for the worked version.
Failures and retries
A delivery that matches no route throws No route matched for log group <name>, and a handler that throws propagates the error out of the Lambda. There is no batch here, one event carries one delivery, so there is no partial reporting to configure.
A subscription filter invokes your function asynchronously, so Lambda applies its usual async retry and on-failure destination to a delivery that throws. Throwing is the only signal you have, since there is no return value the subscription reads.
Middleware
Matcher and route middleware are both typed CloudWatchLogsMiddleware, and the chain runs once per delivery.
import { logger } from '@latcher/base'
import type { CloudWatchLogsMiddleware } from '@latcher/cloudwatch'
export const logInvocation: CloudWatchLogsMiddleware = async (request, next) => {
logger.info(`Handling delivery from ${request.logGroup}`)
return next(request)
}const cloudWatchLogsMatcher = createCloudWatchLogsMatcher({ middleware: [logInvocation] })
cloudWatchLogsMatcher.route({
filters: { logGroup: '/aws/lambda/my-function' },
middleware: [withRequestContext],
handler: processLogs,
})See middleware for the execution order and the three levels it attaches at.
Types
All exported from @latcher/cloudwatch.
| Type | Description |
|---|---|
CloudWatchLogsRequest | The handler argument |
CloudWatchLogsFilters | The filters object |
CloudWatchLogsEventFilters | The filters without messageType, taken by dataMessage() and controlMessage() |
CloudWatchLogsMessageType | 'DATA_MESSAGE' | 'CONTROL_MESSAGE' |
CloudWatchLogsMiddleware | Matcher and route middleware |
CloudWatchLogsRouteDefinition | A full route passed to route() |
CloudWatchLogsDataMessageRouteDefinition | A route passed to dataMessage() |
CloudWatchLogsControlMessageRouteDefinition | A route passed to controlMessage() |
CloudWatchLogsMatcherOptions | Options for createCloudWatchLogsMatcher |
The CloudWatchLogsMatcher class and the createCloudWatchLogsMatcher and defineRoute functions come from the same place.
No type on this matcher takes a generic parameter. The request, filters and middleware are fixed, since the decoded log shape is the same for every delivery, so a reader arriving from a matcher like SQS will find no generic parameters to pass here.
Code example
One Lambda subscribed to a couple of log group patterns, splitting Lambda logs, ECS logs and the subscription's control message across three handlers.
Open a file: index.ts | CloudWatch matcher | handlers
The two data routes match different log group patterns and the control route matches a different message type, so no delivery can match two and the order you register them in makes no difference. A subscription only delivers from the log groups you attach it to, so these three routes cover every delivery the function can receive.
index.ts hands the matcher to Latcher, which is what AWS invokes and what every matcher in the Lambda gets registered on. See matchers for how the two levels of matching fit together.